From de57b4cc616e8c14bcb5eb57559df459deeaf4f5 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Fri, 7 Aug 2026 15:39:55 +0800 Subject: [PATCH 01/33] =?UTF-8?q?feat(framework):=20=E8=A1=A5=20provider?= =?UTF-8?q?=20=E6=8A=BD=E8=B1=A1=E6=8E=A5=E5=8F=A3=E4=B8=8E=E6=8A=A0?= =?UTF-8?q?=E5=9B=BE/=E8=A7=86=E9=A2=91=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit providers/ 此前只有三个 create_*_client 工厂,没有可供上层依赖的抽象类型, ai_engine 无法在不 import 具体实现的前提下声明它需要什么能力。 - interfaces.py:ImageProvider / VideoProvider / MatteProvider 三个 Protocol, 零依赖,供上层按能力而非按厂商声明依赖。 - matte.py:OnnxU2NetMatteProvider,onnxruntime 直跑 u2netp。不用 rembg:其底层 同样依赖 onnxruntime,且 numba 老链在 3.12 无轮子。onnxruntime 导入失败时降级 到 Pillow 兜底而非崩溃。 - sufy.py:SufyImageProvider / SufyVideoProvider。视频成品下载加三次退避重试与 长度校验 —— 该步发生在提交任务、轮询、等待全部成功之后,此时费用已产生、视频 已生成好,只差取回数据,连接断一次整单作废。实测同一角色连续两单死在这里各烧 一次费用。test_sufy_video_download 的四条断言拿修复前的旧实现做过对照,确认其中 三条在修复前会失败。 依赖声明: - qiniu>=7.14 —— 此前未声明,镜像能起、/docs 也 200,只有第一次 POST /media/upload 才 ModuleNotFoundError。 - onnxruntime>=1.17,<1.24 —— 1.24 起不再发布 macOS Intel(x86_64) wheel,Intel Mac 装不上。1.23.x 仍覆盖 Intel/arm64/Linux + py3.12,API 一致,抠图代码零改动。 本 PR 不依赖其他未合分支:providers 不 import windup_common.models。 --- backend/packages/framework/pyproject.toml | 4 - .../windup_framework/providers/__init__.py | 17 +- .../windup_framework/providers/interfaces.py | 34 +++ .../src/windup_framework/providers/matte.py | 103 +++++++ .../src/windup_framework/providers/sufy.py | 153 +++++++++++ backend/tests/test_matte_provider.py | 16 ++ backend/tests/test_sufy_video_download.py | 82 ++++++ backend/uv.lock | 259 +----------------- 8 files changed, 409 insertions(+), 259 deletions(-) create mode 100644 backend/packages/framework/src/windup_framework/providers/interfaces.py create mode 100644 backend/packages/framework/src/windup_framework/providers/matte.py create mode 100644 backend/packages/framework/src/windup_framework/providers/sufy.py create mode 100644 backend/tests/test_matte_provider.py create mode 100644 backend/tests/test_sufy_video_download.py diff --git a/backend/packages/framework/pyproject.toml b/backend/packages/framework/pyproject.toml index 7084c760..44c78a4c 100644 --- a/backend/packages/framework/pyproject.toml +++ b/backend/packages/framework/pyproject.toml @@ -22,10 +22,6 @@ dependencies = [ "pillow>=10.4", # 对象存储(七牛 Kodo);若换 OSS/S3/MinIO 改 oss2 / boto3 / minio。 "qiniu>=7.14", - # 用户模块:密码哈希 / Redis / 邮件 - "passlib[bcrypt]>=1.7", - "redis>=5.0", - "resend>=2.0", # 以下按选型启用: # "rocketmq-client", # RocketMQ Python 客户端(5.x gRPC 版 / C++ 绑定版二选一) ] diff --git a/backend/packages/framework/src/windup_framework/providers/__init__.py b/backend/packages/framework/src/windup_framework/providers/__init__.py index 3524bbf3..61edb2f6 100644 --- a/backend/packages/framework/src/windup_framework/providers/__init__.py +++ b/backend/packages/framework/src/windup_framework/providers/__init__.py @@ -1,8 +1,15 @@ -"""按模型能力划分的 AI Provider 接口。""" +"""按模型能力划分的 AI Provider:官方客户端工厂 + 能力接口 + SUFY 实现。""" from windup_framework.config.provider import AIProviderSettings from windup_framework.providers.chat import create_chat_model from windup_framework.providers.image import create_image_client +from windup_framework.providers.interfaces import ( + ImageProvider, + MatteProvider, + VideoProvider, +) +from windup_framework.providers.matte import OnnxU2NetMatteProvider +from windup_framework.providers.sufy import SufyImageProvider, SufyVideoProvider from windup_framework.providers.video import create_video_client __all__ = [ @@ -10,4 +17,12 @@ "create_chat_model", "create_image_client", "create_video_client", + # 能力接口(ai_engine 依赖这些稳定契约) + "ImageProvider", + "VideoProvider", + "MatteProvider", + # 实现 + "SufyVideoProvider", + "SufyImageProvider", + "OnnxU2NetMatteProvider", ] diff --git a/backend/packages/framework/src/windup_framework/providers/interfaces.py b/backend/packages/framework/src/windup_framework/providers/interfaces.py new file mode 100644 index 00000000..697962d5 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/interfaces.py @@ -0,0 +1,34 @@ +"""AI 模型底层适配器接口(framework)—— behind interface,key 由 config 注入。 + +ai_engine 经这些接口调模型,不直接读 env、不锁死具体供应商 / 模型名(可 A/B 换)。 +实测在用:图像 = gemini-flash-image;视频 = kling-v2-5-turbo(2026-07-27 端到端实测 +到 completed;#53 早期"仅 o1 可用、v2-5-turbo 下架"的结论已被该实测推翻);抠图 = rembg。 + +本文件是接口契约(真);具体 HTTP 实现见 :mod:`.sufy`。 +""" +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class ImageProvider(Protocol): + """文 + 参考图 → 图(视角规整 / 定妆 / 逐帧生成)。""" + + def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: ... + + +@runtime_checkable +class VideoProvider(Protocol): + """首帧图 + 动作 prompt → 视频(i2v,步态位移动作用)。""" + + def i2v( + self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720" + ) -> bytes: ... + + +@runtime_checkable +class MatteProvider(Protocol): + """主体抠图(rembg / u2net)—— 按主体抠,不抠颜色(浅色角色撞背景会抠穿)。""" + + def cutout(self, frame: bytes) -> bytes: ... diff --git a/backend/packages/framework/src/windup_framework/providers/matte.py b/backend/packages/framework/src/windup_framework/providers/matte.py new file mode 100644 index 00000000..050997b8 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/matte.py @@ -0,0 +1,103 @@ +"""主体抠图 MatteProvider —— onnxruntime 直跑 u2netp,不依赖 rembg。 + +为什么不用 rembg:rembg → pymatting → numba 0.53 / llvmlite 0.36 这条老链在 Python +3.12 无轮子(实测装不上)。而 rembg 内核就是"u2netp.onnx 过一遍 onnxruntime";默认 +``alpha_matting=False`` 时根本不碰 pymatting。故直调 onnxruntime,甩掉整条死重依赖, +3.12 干净可装、可进 lock。同模型(u2netp),同质量。 + +模型解析顺序:显式 ``model_path`` → 缓存目录已存在 → 从 ``model_url`` 惰性下载。 +onnxruntime 惰性导入(启动慢、按需加载),会话按需构建一次。 +""" +from __future__ import annotations + +import io +import urllib.request +from pathlib import Path + +import numpy as np +from PIL import Image + +from .interfaces import MatteProvider + +# u2netp:轻量版(~4.7MB)。rembg 官方 release 托管;国内不可达时可预置 model_path。 +_U2NETP_URL = "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2netp.onnx" +_DEFAULT_CACHE = Path.home() / ".cache" / "windup" / "u2netp.onnx" + +# u2net 预处理常量(与 rembg 一致)。 +_MEAN = (0.485, 0.456, 0.406) +_STD = (0.229, 0.224, 0.225) +_SIZE = (320, 320) + + +class OnnxU2NetMatteProvider(MatteProvider): + """u2netp.onnx via onnxruntime。frame bytes → 抠好的 PNG(RGBA) bytes。""" + + def __init__(self, model_path: str | Path | None = None, model_url: str = _U2NETP_URL) -> None: + self._model_path = Path(model_path) if model_path else _DEFAULT_CACHE + self._model_url = model_url + self._session = None # 惰性 + + def _ensure_model(self) -> Path: + if not self._model_path.exists(): + self._model_path.parent.mkdir(parents=True, exist_ok=True) + urllib.request.urlretrieve(self._model_url, self._model_path) + return self._model_path + + def _get_session(self): + if self._session is None: + try: + import onnxruntime as ort # 惰性:导入慢 + except ImportError: + return None # onnxruntime 不可用(如 macOS x86_64),走 Pillow 兜底 + self._session = ort.InferenceSession( + str(self._ensure_model()), providers=["CPUExecutionProvider"] + ) + return self._session + + def _predict_mask(self, img: Image.Image) -> Image.Image: + """u2netp 前向 → 单通道显著性 mask(L,原图尺寸)。""" + im = img.convert("RGB").resize(_SIZE, Image.LANCZOS) + ary = np.array(im).astype(np.float32) + ary = ary / max(float(ary.max()), 1e-6) + tmp = np.zeros((_SIZE[1], _SIZE[0], 3), dtype=np.float32) + for c in range(3): + tmp[:, :, c] = (ary[:, :, c] - _MEAN[c]) / _STD[c] + tensor = np.expand_dims(tmp.transpose(2, 0, 1), 0).astype(np.float32) + + session = self._get_session() + pred = session.run(None, {session.get_inputs()[0].name: tensor})[0][:, 0, :, :] + mi, ma = float(pred.min()), float(pred.max()) + pred = (pred - mi) / max(ma - mi, 1e-6) + mask = (pred.squeeze() * 255).astype(np.uint8) + return Image.fromarray(mask, "L").resize(img.size, Image.LANCZOS) + + def cutout(self, frame: bytes) -> bytes: + img = Image.open(io.BytesIO(frame)).convert("RGBA") + session = self._get_session() + if session is not None: + mask = self._predict_mask(img) + else: + mask = self._fallback_mask(img) + cut = Image.composite(img, Image.new("RGBA", img.size, (0, 0, 0, 0)), mask) + buf = io.BytesIO() + cut.save(buf, "PNG") + return buf.getvalue() + + @staticmethod + def _fallback_mask(img: Image.Image) -> Image.Image: + """Pillow 兜底:取四角主色做 chroma-key 式去背(精度远低于 u2netp,仅开发用)。""" + import numpy as np + + ary = np.array(img.convert("RGB")) + # 取四角 8×8 采样主色 + corners = np.concatenate([ + ary[:8, :8].reshape(-1, 3), + ary[:8, -8:].reshape(-1, 3), + ary[-8:, :8].reshape(-1, 3), + ary[-8:, -8:].reshape(-1, 3), + ]) + bg = corners.mean(axis=0) + diff = np.linalg.norm(ary.astype(float) - bg, axis=2) + # 阈值:距离 < 60 视为背景 + mask = (diff > 60).astype(np.uint8) * 255 + return Image.fromarray(mask, "L").resize(img.size, Image.LANCZOS) diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py new file mode 100644 index 00000000..27dfdbb7 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -0,0 +1,153 @@ +"""Provider 接口的 SUFY / qnaigc(OpenAI-compatible)同步实现。 + +视频走异步任务协议(2026-07-27 端到端实测): + POST /videos {model, prompt, size, seconds, mode, input_reference} + 轮询 GET /videos/{id} → status==completed → task_result.videos[0].url → 下载 mp4 +key / base_url 由 ``AIProviderSettings`` 注入,provider 内不读 env。 +重依赖(rembg)惰性导入,保证模块导入零成本。 +""" +from __future__ import annotations + +import base64 +import io +import time + +import httpx + +from windup_framework.config.provider import AIProviderSettings, settings + +from .interfaces import ImageProvider, VideoProvider + +# 只有 kling-video-o1 走 image_list;v2 系列 / sora 走 input_reference(字段按模型选,塞错任务会 failed)。 +_IMAGE_LIST_MODELS = ("kling-video-o1",) +DEFAULT_VIDEO_MODEL = "kling-v2-5-turbo" + + +def _first_frame_datauri(frame: bytes, size: str) -> str: + """首帧 bytes → 等比缩放 + 背景色补边到目标尺寸 → JPG(RGB,q90) base64 dataURI。 + + 不强拉到目标尺寸(母版多为横幅,强压成方会把角色压成瘦长鬼影);JPG 因 PNG base64 + 会 VENDOR_FAILED(实测)。 + """ + from PIL import Image + + w, h = (int(x) for x in size.split("x")) + im = Image.open(io.BytesIO(frame)).convert("RGB") + pad = im.getpixel((0, 0)) + fitted = im.copy() + fitted.thumbnail((w, h), Image.LANCZOS) + canvas = Image.new("RGB", (w, h), pad) + canvas.paste(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2)) + buf = io.BytesIO() + canvas.save(buf, "JPEG", quality=90) + return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode() + + +class SufyVideoProvider(VideoProvider): + """kling i2v(默认 v2-5-turbo)。首帧 + 动作 prompt → mp4 bytes。""" + + def __init__( + self, + config: AIProviderSettings = settings, + model: str = DEFAULT_VIDEO_MODEL, + mode: str = "std", + poll_interval: float = 60.0, + max_min: int = 30, + ) -> None: + self._cfg = config + self._model = model + self._mode = mode + self._poll = poll_interval + self._max_min = max_min + + def _client(self) -> httpx.Client: + return httpx.Client( + base_url=self._cfg.normalized_base_url, + headers={"Authorization": f"Bearer {self._cfg.api_key}"}, + timeout=self._cfg.timeout, + ) + + def i2v( + self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720" + ) -> bytes: + body: dict = { + "model": self._model, + "prompt": prompt, + "size": size, + "seconds": str(seconds), + "mode": self._mode, + } + if self._model in _IMAGE_LIST_MODELS: + b64 = _first_frame_datauri(first_frame, size).split(",", 1)[1] + body["image_list"] = [{"image": b64}] + else: + body["input_reference"] = _first_frame_datauri(first_frame, size) + + with self._client() as client: + job = client.post("/videos", json=body).raise_for_status().json() + jid = job.get("id") + url = None + for _ in range(max(1, int(self._max_min * 60 // self._poll))): + time.sleep(self._poll) + st = client.get(f"/videos/{jid}").raise_for_status().json() + status = st.get("status") + if status == "completed": + vids = (st.get("task_result") or {}).get("videos") or [] + url = vids[0].get("url") if vids else None + break + if status in ("failed", "cancelled"): + raise RuntimeError(f"i2v 失败: {status}") + if not url: + raise RuntimeError("i2v 未取得视频 URL(超时或失败)") + return _download(client, url) + + +class IncompleteDownloadError(RuntimeError): + """视频下载到的字节数与 ``Content-Length`` 不符。""" + + +def _download(client: httpx.Client, url: str, tries: int = 3) -> bytes: + """下载已生成好的视频,带重试 + 长度校验。 + + 为什么单次读取不够(2026-08-05 实测,同一角色连续两单复现):原实现是 + ``client.get(url).raise_for_status().content``。**视频此时已经生成、费用已经产生**, + 只要读 body 时连接断一次,整单就废:: + + peer closed connection without sending complete message body + (received 720450 bytes, expected 929531) + + 重试是安全的:这是对成品 URL 的 GET,幂等且不再计费——**代价是一次重下, + 不重试的代价是一次重新生成**。 + + 长度校验是因为截断不一定抛异常:服务端提前关流而客户端已收到部分 body 时, + ``.content`` 可能直接返回短 bytes,那样坏视频会一路流到出帧环节才暴露, + 在那里看起来像"解码失败",很难回溯到这里。``Content-Length`` 缺失(分块传输)时跳过校验。 + """ + last: Exception | None = None + for attempt in range(tries): + try: + response = client.get(url) + response.raise_for_status() + body = response.content + expected = response.headers.get("content-length") + if expected and len(body) != int(expected): + raise IncompleteDownloadError(f"视频下载不完整: {len(body)}/{expected} 字节") + return body + except (httpx.HTTPError, IncompleteDownloadError) as exc: + last = exc + if attempt < tries - 1: + time.sleep(2**attempt) + raise RuntimeError(f"视频下载失败(已重试 {tries} 次): {last}") from last + + +class SufyImageProvider(ImageProvider): + """图像 provider(gemini-flash-image)。逐帧图生图路线(hit/idle)待开发。 + + 见 #53 / PerFrameStrategy:per-frame 路线不在"视频优先"首个竖线内,此处留真接口、 + 未接 HTTP,避免 ship 一个假装能跑的桩。walk 主链不经此 provider。 + """ + + def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: + raise NotImplementedError( + "逐帧图生图 provider 待开发(见 #53 / PerFrameStrategy);walk 视频主链不用它" + ) diff --git a/backend/tests/test_matte_provider.py b/backend/tests/test_matte_provider.py new file mode 100644 index 00000000..9c9bd184 --- /dev/null +++ b/backend/tests/test_matte_provider.py @@ -0,0 +1,16 @@ +"""OnnxU2NetMatteProvider 契约测试(不加载模型 / 不联网:构造 + 协议合规)。""" + +from windup_framework.providers import MatteProvider, OnnxU2NetMatteProvider + + +def test_onnx_matte_satisfies_matte_provider_protocol(): + # 运行时可检查协议:有 cutout 即满足 MatteProvider(server/ai_engine 依赖此契约) + provider = OnnxU2NetMatteProvider(model_path="/nonexistent/u2netp.onnx") + assert isinstance(provider, MatteProvider) + assert callable(provider.cutout) + + +def test_onnx_matte_lazy_no_model_load_on_construct(): + # 构造不触发下载 / 会话创建(惰性),模型缺失也不报错 + provider = OnnxU2NetMatteProvider(model_path="/nonexistent/u2netp.onnx") + assert provider._session is None diff --git a/backend/tests/test_sufy_video_download.py b/backend/tests/test_sufy_video_download.py new file mode 100644 index 00000000..07671656 --- /dev/null +++ b/backend/tests/test_sufy_video_download.py @@ -0,0 +1,82 @@ +"""视频成品下载的重试与完整性校验(不联网:用 httpx MockTransport)。 + +回归对象是 2026-08-05 实测两次连续复现的一类失败:视频已生成、费用已产生, +却因为读 body 时断了一次连接就整单丢弃。见 ``providers.sufy._download`` 的 docstring。 +""" + +import httpx +import pytest + +from windup_framework.providers.sufy import IncompleteDownloadError, _download + +VIDEO = b"\x00\x01mp4-bytes" * 64 + + +def _client(handler) -> httpx.Client: + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def test_retries_after_peer_closed_connection(monkeypatch): + """第一次断连、第二次成功 —— 原实现在这里会整单丢弃。""" + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] == 1: + raise httpx.RemoteProtocolError( + "peer closed connection without sending complete message body", request=request + ) + return httpx.Response(200, content=VIDEO) + + with _client(handler) as client: + assert _download(client, "https://example.invalid/v.mp4") == VIDEO + assert calls["n"] == 2 + + +def test_rejects_truncated_body_that_does_not_raise(monkeypatch): + """服务端声明的长度与实收不符时必须失败,而不是把坏视频往下游送。 + + 截断不一定抛异常。放过去的话,坏视频要到出帧环节才暴露成"解码失败", + 很难回溯到下载这一步。 + """ + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + + def handler(request: httpx.Request) -> httpx.Response: + # 只回一半 body,但 Content-Length 仍声明全长 + return httpx.Response( + 200, content=VIDEO[: len(VIDEO) // 2], headers={"content-length": str(len(VIDEO))} + ) + + with _client(handler) as client, pytest.raises(RuntimeError, match="已重试 3 次"): + _download(client, "https://example.invalid/v.mp4") + + +def test_accepts_chunked_response_without_content_length(monkeypatch): + """分块传输没有 Content-Length,此时跳过校验而不是误判为不完整。""" + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, stream=httpx.ByteStream(VIDEO)) + + with _client(handler) as client: + assert _download(client, "https://example.invalid/v.mp4") == VIDEO + + +def test_gives_up_after_three_tries_and_reports_the_last_cause(monkeypatch): + """一直断连时要显式失败,并把最后一次的真实原因带出来。""" + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + raise httpx.ConnectError("connection reset", request=request) + + with _client(handler) as client, pytest.raises(RuntimeError, match="connection reset"): + _download(client, "https://example.invalid/v.mp4") + assert calls["n"] == 3 + + +def test_incomplete_download_error_is_a_runtime_error(): + """调用方按 RuntimeError 兜底即可,不必单独 import 这个子类。""" + assert issubclass(IncompleteDownloadError, RuntimeError) diff --git a/backend/uv.lock b/backend/uv.lock index bfcbe78f..70c41c57 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -14,7 +14,6 @@ members = [ dev = [ { name = "import-linter", specifier = ">=2.0" }, { name = "pytest", specifier = ">=8.0" }, - { name = "pytest-cov", specifier = ">=5.0" }, { name = "ruff", specifier = ">=0.6" }, ] @@ -49,72 +48,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494" }, ] -[[package]] -name = "bcrypt" -version = "5.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be" }, - { url = "https://mirrors.aliyun.com/pypi/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493" }, - { url = "https://mirrors.aliyun.com/pypi/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10" }, - { url = "https://mirrors.aliyun.com/pypi/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927" }, -] - [[package]] name = "certifi" version = "2026.7.22" @@ -218,105 +151,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934" }, ] -[[package]] -name = "coverage" -version = "7.15.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921" }, - { url = "https://mirrors.aliyun.com/pypi/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734" }, - { url = "https://mirrors.aliyun.com/pypi/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84" }, -] - [[package]] name = "distro" version = "1.9.0" @@ -326,28 +160,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, ] -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af" }, -] - -[[package]] -name = "email-validator" -version = "2.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "dnspython" }, - { name = "idna" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4" }, -] - [[package]] name = "fastapi" version = "0.139.2" @@ -938,7 +750,7 @@ wheels = [ [[package]] name = "openai" -version = "2.50.0" +version = "2.53.0" source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } dependencies = [ { name = "anyio" }, @@ -950,9 +762,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/f5/e7735f2af272ee179a287911a698b3cbdb59d7a4ac4874571363adf1e4de/openai-2.50.0.tar.gz", hash = "sha256:5128f7caf4a6b01aefd6e7e93efe170a2c3427b8de286b9af5cdff3aa47e02c8" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/00/ca/db315b3bb748c26c644a3f85b7d509e774354d6518d47080b1446005ee41/openai-2.50.0-py3-none-any.whl", hash = "sha256:90bdddcc5a2fa529b350fac9c5780d87e5c361dcc6090ab57b0d470b0d7af7fa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618" }, ] [[package]] @@ -1056,20 +868,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, ] -[[package]] -name = "passlib" -version = "1.7.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1" }, -] - -[package.optional-dependencies] -bcrypt = [ - { name = "bcrypt" }, -] - [[package]] name = "pillow" version = "12.3.0" @@ -1238,11 +1036,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" }, ] -[package.optional-dependencies] -email = [ - { name = "email-validator" }, -] - [[package]] name = "pydantic-core" version = "2.46.4" @@ -1375,20 +1168,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, ] -[[package]] -name = "pytest-cov" -version = "7.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "coverage" }, - { name = "pluggy" }, - { name = "pytest" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" @@ -1465,15 +1244,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/1d/56/9368cd96d2132017f5748a812cb20a87263498de314e1823472cb4bdbad9/qiniu-7.18.0-py3-none-any.whl", hash = "sha256:0f1be608ac6800ad5f32690d1aa02353b6b2ff5edc78f32a2318859d375a27df" }, ] -[[package]] -name = "redis" -version = "8.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb" }, -] - [[package]] name = "regex" version = "2026.7.19" @@ -1589,19 +1359,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, ] -[[package]] -name = "resend" -version = "2.35.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f0/b9/e07f2f2bd992ef4565b9c315dfd46b3df66ce89df1d928f442b9b39cbe3b/resend-2.35.0.tar.gz", hash = "sha256:26ced7b22cbd89f7b8c7ba9719d0708ab10c06ddd0f91ba6ec861f3a328101b3" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/cc/92/1c0912b68ae082a55dfdd32e4b5117905ae9fd1b6efc5f5e7c69a4ee6894/resend-2.35.0-py2.py3-none-any.whl", hash = "sha256:cd75299d626f4735af52910989b3f51919032ef316e2d60563c79c319e7b24a6" }, -] - [[package]] name = "rich" version = "15.0.0" @@ -2089,7 +1846,7 @@ version = "0.1.0" source = { editable = "packages/app" } dependencies = [ { name = "fastapi" }, - { name = "pydantic", extra = ["email"] }, + { name = "pydantic" }, { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, @@ -2101,7 +1858,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, - { name = "pydantic", extras = ["email"], specifier = ">=2.7" }, + { name = "pydantic", specifier = ">=2.7" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, @@ -2131,15 +1888,12 @@ dependencies = [ { name = "langchain-openai" }, { name = "numpy" }, { name = "onnxruntime" }, - { name = "passlib", extra = ["bcrypt"] }, { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "qiniu" }, - { name = "redis" }, - { name = "resend" }, { name = "sqlalchemy" }, { name = "windup-common" }, ] @@ -2151,15 +1905,12 @@ requires-dist = [ { name = "langchain-openai", specifier = ">=0.3" }, { name = "numpy", specifier = ">=1.26" }, { name = "onnxruntime", specifier = ">=1.17,<1.24" }, - { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7" }, { name = "pillow", specifier = ">=10.4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.4" }, { name = "pyjwt", specifier = ">=2.9" }, { name = "qiniu", specifier = ">=7.14" }, - { name = "redis", specifier = ">=5.0" }, - { name = "resend", specifier = ">=2.0" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "windup-common", editable = "packages/common" }, ] From bedeb4de49943a877e9ace3b3d5617589f7c414e Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Fri, 7 Aug 2026 18:16:01 +0800 Subject: [PATCH 02/33] =?UTF-8?q?fix(providers):=20=E9=A6=96=E5=B8=A7?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E6=8C=89=E6=A8=A1=E5=9E=8B=E9=80=89=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=8B=A6=E6=88=AA=E8=A2=AB=E9=9D=99=E9=BB=98=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=E7=9A=84=E5=8F=82=E8=80=83=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-07 用一张全新角色母版跑 kling-v3-omni 端到端时实测发现,费用已产生。 现象:提交成功、status=completed、16 帧齐、逐帧时长齐、下游抽帧/选帧/抠图/脚线对齐 全部正常工作,最终产出一组构图完整的序列帧。但画面里是一个**与母版毫无关系的写实路人** ——母版是插画风、赭黄长外套、背铜管乐器的乐手,产出是深绿外套的写实人物,且只有下半身 (提示词里 "the legs clearly visible" 被当成了取景指令)。 根因:首帧字段按**模型**选,不是按"本地图/公网 URL"选。厂商文档写明 Kling 用 image_list、Sora 用 input_reference,而本仓只把 kling-video-o1 列进了 image_list 名单。 kling-v3-omni 收到 input_reference 后既不报错也不采纳,退化成纯文生视频。 危险在于失败形态:老模型(v2 系列)塞错字段会 failed,还能发现;kling-v3-omni 是 **成功返回一个错误结果**,整条管线无一处能察觉。这与本批 PR 已修的"未实现路线返回空帧" 属同一类问题,只是发生在更外层——空帧至少还能靠"帧是空的"判出来,这个连帧都是好的。 两处修复: 1) _needs_image_list 显式归类 + kling-v3 前缀兜底。仅对已确认的型号切换字段: v2-5-turbo / v2-1 已实测可吃 input_reference(2026-07-27 端到端到 completed), 不动既有通路,避免为修一个模型而破坏三个。 2) _assert_reference_registered 在**下载视频之前**拦截。网关在 billing_type_description 里明写计费口径,送了首帧却拿到"无参考视频"即为铁证。 提交后与轮询到 completed 时各查一次。字段缺失时不拦——不同网关字段不一定存在, 宁可漏判也不误伤。 四条回归测试,变异测试确认有效:把 v3-omni 退回 input_reference(复现原 bug)、 去掉计费口径检查,各有 1 条用例失败;还原后 8 passed。 --- .../src/windup_framework/providers/sufy.py | 54 +++++++++++++++++-- backend/tests/test_sufy_video_download.py | 43 +++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py index 27dfdbb7..f9748fe3 100644 --- a/backend/packages/framework/src/windup_framework/providers/sufy.py +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -18,11 +18,29 @@ from .interfaces import ImageProvider, VideoProvider -# 只有 kling-video-o1 走 image_list;v2 系列 / sora 走 input_reference(字段按模型选,塞错任务会 failed)。 -_IMAGE_LIST_MODELS = ("kling-video-o1",) +# 首帧字段**按模型选**,不是按"本地图/公网 URL"选。厂商文档:Kling 用 image_list, +# Sora 用 input_reference。塞错的后果分两种,后一种更危险: +# - 老模型(v2 系列):任务 queued 后在生成时 failed "model is not supported"。 +# 提交层不报错,必须轮询到 completed 才算验证过。 +# - kling-v3-omni:**任务 completed,但参考图被静默忽略**,退化成纯文生视频。 +# 2026-08-07 实测:送一张插画风角色母版 + 侧走提示词,拿回一段写实路人走路的 +# 视频,费用照付、无任何异常。整条管线下游(抽帧/选帧/抠图/对齐)对此毫无察觉, +# 产出 16 帧"看起来成功"的错角色成品。 +# 故:新增 kling 模型时必须查文档确认字段,并按下面的 _needs_image_list 归类。 +_IMAGE_LIST_MODELS = ("kling-video-o1", "kling-v3-omni", "kling-v3") DEFAULT_VIDEO_MODEL = "kling-v2-5-turbo" +def _needs_image_list(model: str) -> bool: + """该模型的首帧是否走 ``image_list``(而非 ``input_reference``)。 + + 显式白名单 + ``kling-v3`` 前缀兜底 —— 厂商文档写明 Kling 系用 image_list,但 + v2-5-turbo / v2-1 已实测可吃 input_reference(2026-07-27 端到端到 completed), + 为不破坏既有通路,仅对已确认的型号切换。 + """ + return model in _IMAGE_LIST_MODELS or model.startswith("kling-v3") + + def _first_frame_datauri(frame: bytes, size: str) -> str: """首帧 bytes → 等比缩放 + 背景色补边到目标尺寸 → JPG(RGB,q90) base64 dataURI。 @@ -77,31 +95,57 @@ def i2v( "seconds": str(seconds), "mode": self._mode, } - if self._model in _IMAGE_LIST_MODELS: + if _needs_image_list(self._model): b64 = _first_frame_datauri(first_frame, size).split(",", 1)[1] - body["image_list"] = [{"image": b64}] + body["image_list"] = [{"image": b64, "type": "first_frame"}] else: body["input_reference"] = _first_frame_datauri(first_frame, size) with self._client() as client: job = client.post("/videos", json=body).raise_for_status().json() jid = job.get("id") + _assert_reference_registered(job, self._model) url = None for _ in range(max(1, int(self._max_min * 60 // self._poll))): time.sleep(self._poll) st = client.get(f"/videos/{jid}").raise_for_status().json() status = st.get("status") if status == "completed": + _assert_reference_registered(st, self._model) vids = (st.get("task_result") or {}).get("videos") or [] url = vids[0].get("url") if vids else None break if status in ("failed", "cancelled"): - raise RuntimeError(f"i2v 失败: {status}") + raise RuntimeError(f"i2v 失败: {status} — {st.get('error')}") if not url: raise RuntimeError("i2v 未取得视频 URL(超时或失败)") return _download(client, url) +class ReferenceIgnoredError(RuntimeError): + """送了首帧,网关却按"无参考视频"计费 —— 参考图被静默丢弃。""" + + +def _assert_reference_registered(payload: dict, model: str) -> None: + """确认网关**真的收下了**首帧,而不是当成纯文生视频跑。 + + 为什么需要这道检查:i2v 塞错首帧字段时,老模型会 failed(还能发现),但 + kling-v3-omni 会**成功返回**一段与母版毫无关系的文生视频 —— 费用照付、 + status=completed、帧数正常,下游抽帧/抠图/对齐全部照常工作,产出一组 + "看起来成功"的错角色成品(2026-08-07 实测,烧掉一单)。 + + 网关在 ``billing_type_description`` 里明写计费口径,含"无参考视频"即表示 + 它按文生视频计费。这是目前唯一能在**下载视频之前**发现该问题的信号, + 比事后对比画面便宜得多。字段缺失时不拦(不同网关字段不一定存在)。 + """ + desc = str(payload.get("billing_type_description") or "") + if "无参考视频" in desc: + raise ReferenceIgnoredError( + f"模型 {model} 按「{desc}」计费 —— 首帧被静默忽略,产出将与母版无关。" + "首帧字段按模型选:Kling 用 image_list,Sora 用 input_reference。" + ) + + class IncompleteDownloadError(RuntimeError): """视频下载到的字节数与 ``Content-Length`` 不符。""" diff --git a/backend/tests/test_sufy_video_download.py b/backend/tests/test_sufy_video_download.py index 07671656..a4e0013c 100644 --- a/backend/tests/test_sufy_video_download.py +++ b/backend/tests/test_sufy_video_download.py @@ -80,3 +80,46 @@ def handler(request: httpx.Request) -> httpx.Response: def test_incomplete_download_error_is_a_runtime_error(): """调用方按 RuntimeError 兜底即可,不必单独 import 这个子类。""" assert issubclass(IncompleteDownloadError, RuntimeError) + + +# ── 首帧字段按模型选 + 参考图被忽略要炸(2026-08-07 实测挣得)──────────────── + + +def test_kling_v3_uses_image_list_not_input_reference(): + """Kling 用 image_list,Sora 用 input_reference。塞错字段的后果分两种: + + 老模型 failed(还能发现),而 kling-v3-omni **成功返回一段与母版无关的文生视频** + ——费用照付、status=completed、帧数正常,下游全部照常工作。 + """ + from windup_framework.providers.sufy import _needs_image_list + + assert _needs_image_list("kling-v3-omni") + assert _needs_image_list("kling-v3") + assert _needs_image_list("kling-video-o1") + # v2 系列已实测可吃 input_reference,不改既有通路 + assert not _needs_image_list("kling-v2-5-turbo") + assert not _needs_image_list("kling-v2-1") + assert not _needs_image_list("sora-2") + + +def test_reference_ignored_is_detected_from_billing_description(): + """网关按「无参考视频」计费 = 首帧被静默丢弃,必须炸而不是继续下载。""" + import pytest + + from windup_framework.providers.sufy import ( + ReferenceIgnoredError, + _assert_reference_registered, + ) + + with pytest.raises(ReferenceIgnoredError, match="静默忽略"): + _assert_reference_registered( + {"billing_type_description": "std x 无参考视频 x 无声"}, "kling-v3-omni" + ) + + +def test_reference_registered_passes_and_missing_field_does_not_block(): + """正常带参考的计费口径放行;字段缺失时不拦(不同网关字段不一定存在)。""" + from windup_framework.providers.sufy import _assert_reference_registered + + _assert_reference_registered({"billing_type_description": "std x 图生视频 x 无声"}, "m") + _assert_reference_registered({}, "m") From d2659e310cf158b196ce62bb4e15ec828c46bdc6 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Fri, 7 Aug 2026 19:08:12 +0800 Subject: [PATCH 03/33] =?UTF-8?q?feat(providers):=20=E6=8C=89=E7=8E=B0?= =?UTF-8?q?=E8=A1=8C=20FAL=20=E9=98=9F=E5=88=97=E6=8E=A5=E5=8F=A3=E9=87=8D?= =?UTF-8?q?=E5=86=99=20i2v=EF=BC=8C=E5=B9=B6=E5=9B=9E=E9=80=80=E6=8C=89?= =?UTF-8?q?=E6=97=A7=E6=8E=A5=E5=8F=A3=E5=BD=A2=E7=8A=B6=E6=89=93=E7=9A=84?= =?UTF-8?q?=E4=B8=A4=E5=A4=84=E8=A1=A5=E4=B8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-07 拉网关 OpenAPI spec 逐个核对:平台现有 69 个 POST 视频端点,其中 22 个 图生视频**全部**在 FAL 队列面 /queue/... 下,首帧一律是 URL 形态字段(image_url / start_image_url),同日实测送 base64 dataURI 无一能用。原 SufyVideoProvider 建在 OpenAI 风格 /v1/videos + input_reference dataURI 上,是过时的接口形状——在它上面打的 两处补丁方向错了,一并回退: - _needs_image_list / _IMAGE_LIST_MODELS 里新增的 kling-v3-omni / kling-v3 - _assert_reference_registered / ReferenceIgnoredError 及其 3 条测试 新增 FalQueueVideoProvider 与旧实现并存(没有实测证据说 /v1/videos 已坏,sora 系可能 仍只在那一面)。要点: 1) 模型 → 端点的显式硬表 FAL_I2V_ENDPOINTS,不拼路径。每家有三样东西不同且都猜不出 来:提交路径的型号段;首帧字段名(同是 kling,o3 / v2.5-turbo 叫 image_url, v3 / v2.6 / o1 叫 start_image_url);轮询前缀(**不是**提交路径 + /requests, kling 六个型号共用 /queue/fal-ai/kling-video/requests/{id})。未登记的模型抛 UnknownVideoModelError,不做前缀匹配、不做兜底——猜出一条"存在但语义不同"的路径 (如把 image-to-video 猜成 reference-to-video)会正常出片、正常计费。 2) i2v 契约冲突:Protocol 收 bytes,FAL 面只吃公网 URL。选择"provider 自己适配", Protocol 签名不动——新增 FirstFrameUploader port,provider 构造时必传,内部把补边 后的首帧换成 URL。调用方零改动;母版已在公网时用 PreUploadedFirstFrame 复用该 URL、不重传。 3) 失败一律显式抛错,不静默降级:spec 明写「任务失败时后端也返回 COMPLETED,通过 detail 区分」,故 COMPLETED 还要查 detail;认不出的 status 当失败(继续轮询会把 "协议变了"伪装成"生成太慢");超时抛 VideoJobTimeoutError;参数校验在上传首帧之前 完成;下载复用既有 _download(重试 + 长度校验,治"视频已生成、费用已产生,下载断 一次整单作废")。 FAL 面鉴权是 Authorization: Key(不是 Bearer),base_url 需从 /v1 退回网关根 (/queue 与 /v1 平级)。两处都有 spec 依据,已写进注释与测试。 37 条新测试全程 mock 不联网;11 个变异(错端点 / 错字段名 / 错轮询前缀 / 去掉各处抛错 / 去掉下载重试 / 参数校验挪到上传后)逐个确认能被测到,全部 KILLED。 Co-Authored-By: Claude Opus 5 --- .../windup_framework/providers/__init__.py | 12 +- .../windup_framework/providers/interfaces.py | 24 +- .../src/windup_framework/providers/sufy.py | 514 ++++++++++++++++-- .../tests/test_fal_queue_video_provider.py | 448 +++++++++++++++ backend/tests/test_sufy_video_download.py | 43 -- 5 files changed, 938 insertions(+), 103 deletions(-) create mode 100644 backend/tests/test_fal_queue_video_provider.py diff --git a/backend/packages/framework/src/windup_framework/providers/__init__.py b/backend/packages/framework/src/windup_framework/providers/__init__.py index 61edb2f6..87e2121e 100644 --- a/backend/packages/framework/src/windup_framework/providers/__init__.py +++ b/backend/packages/framework/src/windup_framework/providers/__init__.py @@ -4,12 +4,18 @@ from windup_framework.providers.chat import create_chat_model from windup_framework.providers.image import create_image_client from windup_framework.providers.interfaces import ( + FirstFrameUploader, ImageProvider, MatteProvider, VideoProvider, ) from windup_framework.providers.matte import OnnxU2NetMatteProvider -from windup_framework.providers.sufy import SufyImageProvider, SufyVideoProvider +from windup_framework.providers.sufy import ( + FalQueueVideoProvider, + PreUploadedFirstFrame, + SufyImageProvider, + SufyVideoProvider, +) from windup_framework.providers.video import create_video_client __all__ = [ @@ -21,8 +27,12 @@ "ImageProvider", "VideoProvider", "MatteProvider", + "FirstFrameUploader", # 实现 "SufyVideoProvider", + # FAL 队列面的 i2v(现役接口形态);首帧要公网 URL,故与 uploader 成对出现 + "FalQueueVideoProvider", + "PreUploadedFirstFrame", "SufyImageProvider", "OnnxU2NetMatteProvider", ] diff --git a/backend/packages/framework/src/windup_framework/providers/interfaces.py b/backend/packages/framework/src/windup_framework/providers/interfaces.py index 697962d5..fa353b3d 100644 --- a/backend/packages/framework/src/windup_framework/providers/interfaces.py +++ b/backend/packages/framework/src/windup_framework/providers/interfaces.py @@ -20,13 +20,35 @@ def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: ... @runtime_checkable class VideoProvider(Protocol): - """首帧图 + 动作 prompt → 视频(i2v,步态位移动作用)。""" + """首帧图 + 动作 prompt → 视频(i2v,步态位移动作用)。 + + **入参恒为 bytes,不是 URL** —— 上游(strategy)手里只有母版 bytes,让每个调用点 + 自己想办法弄出一个公网 URL 会把"对象存储"这件事扩散到整条管线。有的供应商接口 + 只吃公网 URL(FAL 队列面全部如此),那是**该 provider 自己的适配问题**:它在 + 构造时接一个 :class:`FirstFrameUploader`,在 provider 内部把 bytes 换成 URL。 + 见 :class:`~.sufy.FalQueueVideoProvider`。 + """ def i2v( self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720" ) -> bytes: ... +@runtime_checkable +class FirstFrameUploader(Protocol): + """首帧 bytes → **公网可取的 URL**(给只吃 URL 的视频接口用)。 + + 为什么是一个 port 而不是直接在 provider 里写上传:framework 里"对象存储"是另一 + 条独立的线(见 ``windup_framework.storage`` 与依赖里的 ``qiniu``),由组装层决定 + 用哪个桶、什么有效期、要不要复用已有的图。provider 只声明"我需要一个 URL"。 + + 实现方必须保证:返回的 URL 对**供应商的服务器**可取(不是只对内网/本机可取), + 且在整个生成周期内有效(i2v 任务排队 + 生成常见数分钟)。 + """ + + def upload(self, frame: bytes, content_type: str) -> str: ... + + @runtime_checkable class MatteProvider(Protocol): """主体抠图(rembg / u2net)—— 按主体抠,不抠颜色(浅色角色撞背景会抠穿)。""" diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py index f9748fe3..eadd78f1 100644 --- a/backend/packages/framework/src/windup_framework/providers/sufy.py +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -1,51 +1,55 @@ -"""Provider 接口的 SUFY / qnaigc(OpenAI-compatible)同步实现。 +"""Provider 接口的 SUFY / qnaigc(Modelink 网关)同步实现。 + +网关上挂着**两套互不兼容的视频接口面**,本模块两条都实现、并存不替换: + +1. OpenAI 风格(:class:`SufyVideoProvider`)——首帧走 base64 dataURI:: + + POST /v1/videos {model, prompt, size, seconds, mode, input_reference} + 轮询 GET /v1/videos/{id} → status==completed → task_result.videos[0].url → 下载 mp4 + + 2026-07-27 对 kling-v2-5-turbo 端到端实测到 completed。留着是因为没有实测证据说它 + 坏了,sora 系可能仍只在这一面。 + +2. FAL 队列(:class:`FalQueueVideoProvider`)——首帧走**公网 URL**:: + + POST /queue/{厂商}/{型号}/[{mode}/]image-to-video {..., image_url|start_image_url} + 轮询 GET /queue/{家族}/requests/{request_id}/status → COMPLETED → result.video.url + + 2026-08-07 拉网关 OpenAPI spec 逐个核对:平台现有 69 个 POST 视频端点,其中 22 个 + 图生视频**全部**在这一面,全部要 URL 形态的首帧字段,没有一个吃 dataURI。 + +两面鉴权也不同(spec 明写):FAL 面 ``Authorization: Key {api_key}``,OpenAI 面 ``Bearer``。 -视频走异步任务协议(2026-07-27 端到端实测): - POST /videos {model, prompt, size, seconds, mode, input_reference} - 轮询 GET /videos/{id} → status==completed → task_result.videos[0].url → 下载 mp4 key / base_url 由 ``AIProviderSettings`` 注入,provider 内不读 env。 -重依赖(rembg)惰性导入,保证模块导入零成本。 +重依赖(PIL)惰性导入,保证模块导入零成本。 """ from __future__ import annotations import base64 import io import time +from collections.abc import Mapping +from dataclasses import dataclass, field import httpx from windup_framework.config.provider import AIProviderSettings, settings -from .interfaces import ImageProvider, VideoProvider - -# 首帧字段**按模型选**,不是按"本地图/公网 URL"选。厂商文档:Kling 用 image_list, -# Sora 用 input_reference。塞错的后果分两种,后一种更危险: -# - 老模型(v2 系列):任务 queued 后在生成时 failed "model is not supported"。 -# 提交层不报错,必须轮询到 completed 才算验证过。 -# - kling-v3-omni:**任务 completed,但参考图被静默忽略**,退化成纯文生视频。 -# 2026-08-07 实测:送一张插画风角色母版 + 侧走提示词,拿回一段写实路人走路的 -# 视频,费用照付、无任何异常。整条管线下游(抽帧/选帧/抠图/对齐)对此毫无察觉, -# 产出 16 帧"看起来成功"的错角色成品。 -# 故:新增 kling 模型时必须查文档确认字段,并按下面的 _needs_image_list 归类。 -_IMAGE_LIST_MODELS = ("kling-video-o1", "kling-v3-omni", "kling-v3") -DEFAULT_VIDEO_MODEL = "kling-v2-5-turbo" - - -def _needs_image_list(model: str) -> bool: - """该模型的首帧是否走 ``image_list``(而非 ``input_reference``)。 +from .interfaces import FirstFrameUploader, ImageProvider, VideoProvider - 显式白名单 + ``kling-v3`` 前缀兜底 —— 厂商文档写明 Kling 系用 image_list,但 - v2-5-turbo / v2-1 已实测可吃 input_reference(2026-07-27 端到端到 completed), - 为不破坏既有通路,仅对已确认的型号切换。 - """ - return model in _IMAGE_LIST_MODELS or model.startswith("kling-v3") +# 只有 kling-video-o1 走 image_list;v2 系列 / sora 走 input_reference(字段按模型选,塞错任务会 failed)。 +_IMAGE_LIST_MODELS = ("kling-video-o1",) +DEFAULT_VIDEO_MODEL = "kling-v2-5-turbo" -def _first_frame_datauri(frame: bytes, size: str) -> str: - """首帧 bytes → 等比缩放 + 背景色补边到目标尺寸 → JPG(RGB,q90) base64 dataURI。 +def _fit_first_frame(frame: bytes, size: str) -> bytes: + """首帧 bytes → 等比缩放 + 背景色补边到目标尺寸 → JPG(RGB,q90) bytes。 不强拉到目标尺寸(母版多为横幅,强压成方会把角色压成瘦长鬼影);JPG 因 PNG base64 会 VENDOR_FAILED(实测)。 + + 这一步同时是 kling 系"输出画幅"的唯一控制点:kling 的 i2v 端点没有 resolution/size + 字段,成片画幅跟随首帧,所以 ``size`` 只能在这里生效。 """ from PIL import Image @@ -58,7 +62,12 @@ def _first_frame_datauri(frame: bytes, size: str) -> str: canvas.paste(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2)) buf = io.BytesIO() canvas.save(buf, "JPEG", quality=90) - return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode() + return buf.getvalue() + + +def _first_frame_datauri(frame: bytes, size: str) -> str: + """首帧 → base64 dataURI(OpenAI 风格 ``/v1/videos`` 面专用;FAL 面不吃 dataURI)。""" + return "data:image/jpeg;base64," + base64.b64encode(_fit_first_frame(frame, size)).decode() class SufyVideoProvider(VideoProvider): @@ -95,23 +104,21 @@ def i2v( "seconds": str(seconds), "mode": self._mode, } - if _needs_image_list(self._model): + if self._model in _IMAGE_LIST_MODELS: b64 = _first_frame_datauri(first_frame, size).split(",", 1)[1] - body["image_list"] = [{"image": b64, "type": "first_frame"}] + body["image_list"] = [{"image": b64}] else: body["input_reference"] = _first_frame_datauri(first_frame, size) with self._client() as client: job = client.post("/videos", json=body).raise_for_status().json() jid = job.get("id") - _assert_reference_registered(job, self._model) url = None for _ in range(max(1, int(self._max_min * 60 // self._poll))): time.sleep(self._poll) st = client.get(f"/videos/{jid}").raise_for_status().json() status = st.get("status") if status == "completed": - _assert_reference_registered(st, self._model) vids = (st.get("task_result") or {}).get("videos") or [] url = vids[0].get("url") if vids else None break @@ -122,30 +129,6 @@ def i2v( return _download(client, url) -class ReferenceIgnoredError(RuntimeError): - """送了首帧,网关却按"无参考视频"计费 —— 参考图被静默丢弃。""" - - -def _assert_reference_registered(payload: dict, model: str) -> None: - """确认网关**真的收下了**首帧,而不是当成纯文生视频跑。 - - 为什么需要这道检查:i2v 塞错首帧字段时,老模型会 failed(还能发现),但 - kling-v3-omni 会**成功返回**一段与母版毫无关系的文生视频 —— 费用照付、 - status=completed、帧数正常,下游抽帧/抠图/对齐全部照常工作,产出一组 - "看起来成功"的错角色成品(2026-08-07 实测,烧掉一单)。 - - 网关在 ``billing_type_description`` 里明写计费口径,含"无参考视频"即表示 - 它按文生视频计费。这是目前唯一能在**下载视频之前**发现该问题的信号, - 比事后对比画面便宜得多。字段缺失时不拦(不同网关字段不一定存在)。 - """ - desc = str(payload.get("billing_type_description") or "") - if "无参考视频" in desc: - raise ReferenceIgnoredError( - f"模型 {model} 按「{desc}」计费 —— 首帧被静默忽略,产出将与母版无关。" - "首帧字段按模型选:Kling 用 image_list,Sora 用 input_reference。" - ) - - class IncompleteDownloadError(RuntimeError): """视频下载到的字节数与 ``Content-Length`` 不符。""" @@ -184,6 +167,421 @@ def _download(client: httpx.Client, url: str, tries: int = 3) -> bytes: raise RuntimeError(f"视频下载失败(已重试 {tries} 次): {last}") from last +# ── FAL 队列面 ────────────────────────────────────────────────────────────── +# 2026-08-07 拉网关 OpenAPI spec 核对得到:平台的 22 个图生视频端点全在 /queue/ 下, +# 首帧字段一律是 URL 形态(image_url / start_image_url),同日实测送 dataURI 无一能用。 +# (spec 里 seedance / vidu-q3 / kling-v3-turbo 三家的字段说明写着"URL 或 base64", +# 与实测冲突,未复验。本实现一律只发公网 URL —— 那是 22 个端点的共同解。) +# +# 每家有三样东西不一样,而且**没有一条能靠拼字符串猜出来**,所以下面是一张硬表: +# 1. 提交路径:型号段各不相同(o3 / v3 / v3/turbo / v2.6 / v2.5-turbo / o1), +# 有的带 {mode} 路径参数、有的不带(veo / seedance / minimax / vidu 不带)。 +# 2. 首帧字段名:同是 kling,o3 与 v2.5-turbo 叫 image_url,v3 / v2.6 / o1 却叫 +# start_image_url。塞错字段 = 送了图但模型没收到。 +# 3. 轮询前缀:**不是**提交路径加个 /requests。kling 六个型号共用一个 +# /queue/fal-ai/kling-video/requests/{id},型号段与 mode 段都不出现。 +# 这一条是最容易想当然拼错的地方。 +# +# 另有两处形态差异也写进表里,因为取值形式不同会被网关 400: +# - 时长字段都叫 duration,但取值分三种形态:"5"(kling/seedance)、"8s"(veo)、 +# 5(minimax/vidu,整数)。 +# - 分辨率:kling 系**没有**这个字段(成片画幅跟随首帧,所以 size 只能靠补边生效); +# 其余各家的档位枚举各不相同。 + + +class UnknownVideoModelError(RuntimeError): + """模型不在端点表里 —— 不猜路径,直接拒。 + + 猜错的代价不对称:猜出一条不存在的路径只是 404(便宜),猜出一条**存在但语义不同** + 的路径(如把 image-to-video 猜成 reference-to-video)会正常出片、正常计费, + 产出却与预期不符。故这里只认表,不做前缀匹配、不做拼接兜底。 + """ + + +class UnsupportedVideoOptionError(RuntimeError): + """该模型不支持这个 mode / 时长 / 画幅 —— 提交前就拒,别等网关 400。""" + + +class FirstFrameNotPublicError(RuntimeError): + """uploader 没给出 http(s) URL —— 首帧供应商取不到。""" + + +class VideoJobFailedError(RuntimeError): + """FAL 任务失败。 + + 含一种伪装成功:spec 明写「任务失败时后端也返回 COMPLETED,通过 detail 字段区分」。 + 只看 status 会把失败当成功,然后在"取不到视频 URL"处报一个莫名其妙的错。 + """ + + +class VideoJobTimeoutError(RuntimeError): + """轮询预算耗尽仍未出片(任务可能还在跑,费用可能已产生)。""" + + +@dataclass(frozen=True) +class FalI2VEndpoint: + """一个模型在 FAL 队列面上的调用形状。字段全部取自网关 OpenAPI spec。""" + + submit_path: str # 含 {mode} 则该模型必须给 mode + image_field: str # image_url / start_image_url + queue_base: str # 轮询与取结果的前缀,与 submit_path 不同 + seconds: frozenset[int] # 允许的时长 + modes: frozenset[str] = frozenset() # 空 = 路径里没有 {mode} + duration_style: str = "str" # str -> "5" | str_s -> "8s" | int -> 5 + resolution_field: str | None = None # None = 该模型没有分辨率档位,画幅跟随首帧 + resolutions: Mapping[int, str] = field(default_factory=dict) # 首帧高度 → 档位枚举 + audio_field: str | None = None # 有则显式关掉:序列帧不要声音,别平白多花钱 + + +_KLING_QUEUE = "/queue/fal-ai/kling-video" +_KLING_3_SECONDS = frozenset(range(3, 16)) + +# 键是**本仓自己的模型名**(与 ``AIProviderSettings.model`` 对齐)。FAL 面的 body 里 +# 没有 model 字段 —— 型号是路径的一部分,这也是"必须查表"的根本原因。 +FAL_I2V_ENDPOINTS: Mapping[str, FalI2VEndpoint] = { + "kling-v3-omni": FalI2VEndpoint( + submit_path=f"{_KLING_QUEUE}/o3/{{mode}}/image-to-video", + image_field="image_url", + queue_base=_KLING_QUEUE, + seconds=_KLING_3_SECONDS, + modes=frozenset({"standard", "std", "pro", "4k"}), + audio_field="generate_audio", + ), + "kling-v3": FalI2VEndpoint( + submit_path=f"{_KLING_QUEUE}/v3/{{mode}}/image-to-video", + image_field="start_image_url", # 与同族 o3 的 image_url 不同,别顺手写成一样 + queue_base=_KLING_QUEUE, + seconds=_KLING_3_SECONDS, + modes=frozenset({"standard", "std", "pro", "4k"}), + audio_field="generate_audio", # spec 默认 true + ), + "kling-v3-turbo": FalI2VEndpoint( + submit_path=f"{_KLING_QUEUE}/v3/turbo/{{mode}}/image-to-video", + image_field="image_url", + queue_base=_KLING_QUEUE, + seconds=_KLING_3_SECONDS, + modes=frozenset({"standard", "pro"}), # 注意没有 "std" + ), + "kling-v2-6": FalI2VEndpoint( + submit_path=f"{_KLING_QUEUE}/v2.6/{{mode}}/image-to-video", + image_field="start_image_url", + queue_base=_KLING_QUEUE, + seconds=frozenset({5, 10}), + modes=frozenset({"pro"}), # 只有 pro + audio_field="generate_audio", # spec 默认 true + ), + "kling-v2-5-turbo": FalI2VEndpoint( + submit_path=f"{_KLING_QUEUE}/v2.5-turbo/{{mode}}/image-to-video", + image_field="image_url", + queue_base=_KLING_QUEUE, + seconds=frozenset({5, 10}), + modes=frozenset({"standard", "std", "pro"}), + ), + "kling-video-o1": FalI2VEndpoint( + submit_path=f"{_KLING_QUEUE}/o1/{{mode}}/image-to-video", + image_field="start_image_url", + queue_base=_KLING_QUEUE, + seconds=frozenset(range(3, 11)), + modes=frozenset({"standard", "std", "pro"}), + ), + "veo3.1": FalI2VEndpoint( + submit_path="/queue/fal-ai/veo3.1/image-to-video", + image_field="image_url", + queue_base="/queue/fal-ai/veo3.1", + seconds=frozenset({4, 6, 8}), + duration_style="str_s", # 只有 veo 带 "s" 后缀 + resolution_field="resolution", + resolutions={720: "720p", 1080: "1080p", 2160: "4k"}, + audio_field="generate_audio", # spec 默认 true + ), + "seedance-2.0": FalI2VEndpoint( + submit_path="/queue/bytedance/seedance-2.0/image-to-video", + image_field="image_url", + queue_base="/queue/bytedance/seedance-2.0", + seconds=frozenset(range(4, 16)), + resolution_field="resolution", + resolutions={480: "480p", 720: "720p", 1080: "1080p", 2160: "4k"}, + audio_field="generate_audio", + ), + "minimax-h3": FalI2VEndpoint( + submit_path="/queue/minimax/h3/image-to-video", + image_field="image_url", + queue_base="/queue/minimax/h3", + seconds=frozenset(range(5, 16)), + duration_style="int", + resolution_field="resolution", + # 只有 768P / 2K 两档。720 高的首帧没有对应档位,此时**报错而不是就近选 768P**: + # 悄悄换档 = 出片尺寸与调用方要的不一致,而序列帧下游是按尺寸对齐的。 + resolutions={768: "768P"}, + ), + "vidu-q3-pro": FalI2VEndpoint( + submit_path="/queue/fal-ai/vidu/q3/image-to-video/pro", + image_field="image_url", + queue_base="/queue/fal-ai/vidu", # 家族级前缀,不含 q3/pro + seconds=frozenset(range(1, 17)), + duration_style="int", + resolution_field="resolution", + resolutions={540: "540p", 720: "720p", 1080: "1080p"}, + audio_field="audio", # q3 默认 true + ), +} + +DEFAULT_FAL_VIDEO_MODEL = "kling-v2-5-turbo" + + +def fal_endpoint(model: str) -> FalI2VEndpoint: + """查表取端点定义;查不到就炸,绝不猜。""" + try: + return FAL_I2V_ENDPOINTS[model] + except KeyError: + known = ", ".join(sorted(FAL_I2V_ENDPOINTS)) + raise UnknownVideoModelError( + f"模型 {model!r} 不在 FAL 图生视频端点表里。已登记: {known}。" + "新增模型请去网关 OpenAPI spec 抄提交路径 / 首帧字段名 / 轮询前缀三项后登记,不要拼路径。" + ) from None + + +def fal_submit_path(model: str, mode: str) -> str: + """拼出提交路径(唯一允许的"拼接"就是把表里的 {mode} 填上)。""" + endpoint = fal_endpoint(model) + if not endpoint.modes: + return endpoint.submit_path + if mode not in endpoint.modes: + raise UnsupportedVideoOptionError( + f"模型 {model} 不支持 mode={mode!r},可选: {sorted(endpoint.modes)}" + ) + return endpoint.submit_path.format(mode=mode) + + +def assert_i2v_options(model: str, seconds: int, size: str) -> None: + """把"这个模型收不收这些参数"验完。纯计算,故可在**上传首帧之前**先调。""" + endpoint = fal_endpoint(model) + if seconds not in endpoint.seconds: + raise UnsupportedVideoOptionError( + f"模型 {model} 不支持 {seconds} 秒,可选: {sorted(endpoint.seconds)}" + ) + if endpoint.resolution_field: + _fal_resolution(model, endpoint, size) + + +def fal_i2v_body(model: str, prompt: str, image_url: str, seconds: int, size: str) -> dict: + """按模型形态组装请求体。任何一项不被该模型支持都当场炸,不做就近替换。""" + assert_i2v_options(model, seconds, size) + endpoint = fal_endpoint(model) + # 10 个端点的时长字段都叫 duration,只是取值形态不同。 + body: dict = { + endpoint.image_field: image_url, + "prompt": prompt, + "duration": _fal_duration(model, endpoint, seconds), + } + if endpoint.resolution_field: + body[endpoint.resolution_field] = _fal_resolution(model, endpoint, size) + if endpoint.audio_field: + # 序列帧不要声音:多数端点默认 true,不显式关掉等于白付音轨的钱和时间。 + body[endpoint.audio_field] = False + return body + + +def _fal_duration(model: str, endpoint: FalI2VEndpoint, seconds: int) -> str | int: + if endpoint.duration_style == "int": + return int(seconds) + if endpoint.duration_style == "str_s": + return f"{seconds}s" + if endpoint.duration_style == "str": + return str(seconds) + raise UnsupportedVideoOptionError( + f"模型 {model} 的 duration_style 登记有误: {endpoint.duration_style!r}" + ) + + +def _fal_resolution(model: str, endpoint: FalI2VEndpoint, size: str) -> str: + try: + height = int(size.split("x")[1]) + except (IndexError, ValueError): + raise UnsupportedVideoOptionError(f"size 形如 1280x720,收到 {size!r}") from None + try: + return endpoint.resolutions[height] + except KeyError: + raise UnsupportedVideoOptionError( + f"模型 {model} 没有 {size} 对应的分辨率档位,支持的高度: {sorted(endpoint.resolutions)}" + ) from None + + +def _api_root(base_url: str) -> str: + """把 OpenAI 兼容面的 base_url 退回网关根。 + + 配置里的 ``AI_BASE_URL`` 指向 OpenAI 面(``.../v1``),而 FAL 的 ``/queue/...`` 与 + ``/v1/...`` 是**平级**的(spec 的 servers 就是裸域名)。直接拿 base_url 拼会得到 + ``/v1/queue/...`` → 404。 + """ + root = base_url.rstrip("/") + return root[: -len("/v1")] if root.endswith("/v1") else root + + +class PreUploadedFirstFrame(FirstFrameUploader): + """首帧已经在公网上时的零成本 uploader(不传任何东西,直接返回该 URL)。 + + 典型场景:server 侧的母版本来就存在 ``Character.reference_image_url``,重新上传一份 + 纯属浪费。 + + **代价写在这里,别踩**:走这条路等于跳过 :func:`_fit_first_frame` 的补边, + ``i2v(size=...)`` 对 kling 系就失效了(kling 没有分辨率字段,成片画幅跟随首帧)。 + 要控制成片画幅,请给一个真正会上传 bytes 的 uploader。 + """ + + def __init__(self, url: str) -> None: + if not url.startswith(("http://", "https://")): + raise FirstFrameNotPublicError(f"首帧 URL 必须是 http(s),收到 {url!r}") + self._url = url + + def upload(self, frame: bytes, content_type: str) -> str: + """两个入参是 port 契约的一部分,本实现用不上(图已经在公网)。""" + return self._url + + +class FalQueueVideoProvider(VideoProvider): + """FAL 队列面的 i2v。首帧 bytes → 经 uploader 换成公网 URL → 队列任务 → mp4 bytes。 + + 与 :class:`SufyVideoProvider` 并存:那条是 OpenAI 风格 ``/v1/videos``(首帧走 + dataURI),两套接口面在网关上同时存在,路径 / 鉴权 / 首帧形态全都不同。 + + ``uploader`` 是**必填**且无默认值 —— 构造不出一个"没有上传能力的 FAL provider", + 免得跑到线上才发现首帧送不出去(那时任务已经提交、钱已经花了)。 + """ + + def __init__( + self, + uploader: FirstFrameUploader, + config: AIProviderSettings = settings, + model: str = DEFAULT_FAL_VIDEO_MODEL, + mode: str = "std", + poll_interval: float = 15.0, + max_min: int = 30, + ) -> None: + # 构造即校验:未知模型 / 不支持的 mode 在**花钱之前**就炸掉。 + self._path = fal_submit_path(model, mode) + self._endpoint = fal_endpoint(model) + self._uploader = uploader + self._cfg = config + self._model = model + self._mode = mode + self._poll = poll_interval + self._max_min = max_min + + def _client(self) -> httpx.Client: + return httpx.Client( + base_url=_api_root(self._cfg.normalized_base_url), + # FAL 面是 ``Key``,不是 ``Bearer``(spec 的 securitySchemes 里两套并列写明)。 + headers={"Authorization": f"Key {self._cfg.api_key}"}, + timeout=self._cfg.timeout, + ) + + def i2v( + self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720" + ) -> bytes: + # 先验参数再上传:上传首帧通常要花钱/占带宽,不该为一个必然被拒的请求先传图。 + assert_i2v_options(self._model, seconds, size) + body = fal_i2v_body(self._model, prompt, self._upload(first_frame, size), seconds, size) + with self._client() as client: + request_id = _fal_submit(client, self._path, body) + url = _await_fal_video_url( + client, self._endpoint, request_id, self._poll, self._max_min + ) + # 成品 URL 是网关签发的下载链接,用同一个 client 取:这条下载路径(带鉴权头、 + # 带重试与长度校验)是 2026-08-05 实测挣来的,不为"看起来更干净"去动它。 + return _download(client, url) + + def _upload(self, first_frame: bytes, size: str) -> str: + url = self._uploader.upload(_fit_first_frame(first_frame, size), "image/jpeg") + if not isinstance(url, str) or not url.startswith(("http://", "https://")): + # dataURI / 本地路径在这一面必然产不出正确结果:要么被网关 400,要么更糟 —— + # 被当成"没有首帧"跑成文生视频,照样计费。宁可在提交前炸。 + raise FirstFrameNotPublicError( + f"uploader 必须返回 http(s) 公网 URL(供应商服务器要能取到),收到 {url!r}" + ) + return url + + +def _fal_submit(client: httpx.Client, path: str, body: dict) -> str: + """提交任务,拿 request_id。被拒时把网关的 detail.msg 带出来(否则只剩一个 400)。""" + try: + payload = client.post(path, json=body).raise_for_status().json() + except httpx.HTTPStatusError as exc: + raise VideoJobFailedError( + f"i2v 提交被拒(HTTP {exc.response.status_code},POST {path}): {_fal_error_text(exc.response)}" + ) from exc + request_id = payload.get("request_id") + if not request_id: + raise VideoJobFailedError(f"i2v 提交返回里没有 request_id: {payload}") + return str(request_id) + + +def _fal_error_text(response: httpx.Response) -> str: + try: + detail = response.json().get("detail") + except ValueError: + return response.text[:200] + if isinstance(detail, dict): + return str(detail.get("msg") or detail) + return str(detail) + + +def _await_fal_video_url( + client: httpx.Client, + endpoint: FalI2VEndpoint, + request_id: str, + poll_interval: float, + max_min: int, +) -> str: + """轮询到出片,返回成品视频 URL。任何非成功终态都抛错,绝不返回空。 + + 三处踩点: + - 进行中的状态是 HTTP 202,``raise_for_status`` 不会拦,得看 status 字段。 + - spec 明写「任务失败时后端也返回 COMPLETED,通过 detail 字段区分成功/失败」, + 所以 COMPLETED 还要再看 detail —— 只认 status 会把失败当成功。 + - 认不出的 status 一律当失败,不要 continue:那会一直转到超时,把一个"协议变了" + 的问题伪装成"生成太慢"。 + """ + status_path = f"{endpoint.queue_base}/requests/{request_id}/status" + for _ in range(max(1, int(max_min * 60 // poll_interval))): + time.sleep(poll_interval) + state = client.get(status_path).raise_for_status().json() + status = str(state.get("status") or "") + if status in ("IN_QUEUE", "IN_PROGRESS"): + continue + if status == "FAILED": + raise VideoJobFailedError(f"i2v 任务失败({request_id}): {state.get('detail')}") + if status != "COMPLETED": + raise VideoJobFailedError(f"i2v 任务返回未知状态 {status!r}({request_id}): {state}") + if state.get("detail"): + raise VideoJobFailedError( + f"i2v 任务 COMPLETED 但带 detail = 实为失败({request_id}): {state.get('detail')}" + ) + url = ((state.get("result") or {}).get("video") or {}).get("url") + return url if url else _fal_result_url(client, endpoint, request_id) + raise VideoJobTimeoutError( + f"i2v 轮询 {max_min} 分钟仍未出片({request_id});任务可能仍在跑,费用可能已产生" + ) + + +def _fal_result_url(client: httpx.Client, endpoint: FalI2VEndpoint, request_id: str) -> str: + """COMPLETED 但状态响应里没带 URL 时,按 fal 协议再取一次结果。 + + 不是兜底降级,是协议本身就有的第二步(提交响应里的 ``response_url`` 指的就是它): + 各家 status 响应是否内联 result 并不一致。此时**视频已生成、费用已产生**, + 为少一次 GET 而丢掉整单不划算。取不到才炸。 + """ + path = f"{endpoint.queue_base}/requests/{request_id}" + try: + payload = client.get(path).raise_for_status().json() + except httpx.HTTPError as exc: + raise VideoJobFailedError(f"i2v 已完成但取结果失败({request_id}): {exc}") from exc + url = (payload.get("video") or {}).get("url") + if not url: + raise VideoJobFailedError(f"i2v 已完成但结果里没有视频 URL({request_id}): {payload}") + return str(url) + + class SufyImageProvider(ImageProvider): """图像 provider(gemini-flash-image)。逐帧图生图路线(hit/idle)待开发。 diff --git a/backend/tests/test_fal_queue_video_provider.py b/backend/tests/test_fal_queue_video_provider.py new file mode 100644 index 00000000..dc5323df --- /dev/null +++ b/backend/tests/test_fal_queue_video_provider.py @@ -0,0 +1,448 @@ +"""FAL 队列面 i2v 的回归测试(全程不联网:httpx.MockTransport + monkeypatch)。 + +护住的是三类"花了钱才发现"的错: + 1. 端点表写错 —— 提交路径 / 首帧字段名 / 轮询前缀三项各家都不同,猜不出来; + 2. 失败被当成成功 —— spec 明写失败也可能返回 COMPLETED,只看 status 会漏; + 3. 视频已生成却把整单丢掉 —— 下载重试与长度校验必须仍然生效。 +""" + +import io +import json + +import httpx +import pytest + +from windup_framework.config.provider import AIProviderSettings +from windup_framework.providers.interfaces import VideoProvider +from windup_framework.providers.sufy import ( + FAL_I2V_ENDPOINTS, + FalQueueVideoProvider, + FirstFrameNotPublicError, + PreUploadedFirstFrame, + UnknownVideoModelError, + UnsupportedVideoOptionError, + VideoJobFailedError, + VideoJobTimeoutError, + _api_root, + _await_fal_video_url, + fal_endpoint, + fal_i2v_body, + fal_submit_path, +) + +VIDEO = b"\x00\x01mp4-bytes" * 64 +FRAME_URL = "https://cdn.invalid/master.jpg" +VIDEO_URL = "https://cdn.invalid/out.mp4" + +# 逐项抄自网关 OpenAPI spec(2026-08-07 下载的那批)。 +# 元组 = (提交路径, 首帧字段名, 轮询/取结果前缀)。轮询前缀**不是**提交路径 + /requests: +# kling 六个型号共用一个家族级前缀,vidu 也把 q3/pro 段去掉了。 +EXPECTED_ENDPOINTS = { + "kling-v3-omni": ( + "/queue/fal-ai/kling-video/o3/{mode}/image-to-video", + "image_url", + "/queue/fal-ai/kling-video", + ), + "kling-v3": ( + "/queue/fal-ai/kling-video/v3/{mode}/image-to-video", + "start_image_url", + "/queue/fal-ai/kling-video", + ), + "kling-v3-turbo": ( + "/queue/fal-ai/kling-video/v3/turbo/{mode}/image-to-video", + "image_url", + "/queue/fal-ai/kling-video", + ), + "kling-v2-6": ( + "/queue/fal-ai/kling-video/v2.6/{mode}/image-to-video", + "start_image_url", + "/queue/fal-ai/kling-video", + ), + "kling-v2-5-turbo": ( + "/queue/fal-ai/kling-video/v2.5-turbo/{mode}/image-to-video", + "image_url", + "/queue/fal-ai/kling-video", + ), + "kling-video-o1": ( + "/queue/fal-ai/kling-video/o1/{mode}/image-to-video", + "start_image_url", + "/queue/fal-ai/kling-video", + ), + "veo3.1": ( + "/queue/fal-ai/veo3.1/image-to-video", + "image_url", + "/queue/fal-ai/veo3.1", + ), + "seedance-2.0": ( + "/queue/bytedance/seedance-2.0/image-to-video", + "image_url", + "/queue/bytedance/seedance-2.0", + ), + "minimax-h3": ( + "/queue/minimax/h3/image-to-video", + "image_url", + "/queue/minimax/h3", + ), + "vidu-q3-pro": ( + "/queue/fal-ai/vidu/q3/image-to-video/pro", + "image_url", + "/queue/fal-ai/vidu", + ), +} + +COMPLETED = {"status": "COMPLETED", "detail": None, "result": {"video": {"url": VIDEO_URL}}} + + +def _png(width: int = 900, height: int = 500) -> bytes: + """真图,不是假 bytes —— 首帧补边那一步会真的解码它。""" + from PIL import Image + + buf = io.BytesIO() + Image.new("RGB", (width, height), (30, 60, 90)).save(buf, "PNG") + return buf.getvalue() + + +def _config() -> AIProviderSettings: + # base_url 故意带 /v1:FAL 面在网关根,provider 必须自己退回去。 + return AIProviderSettings(base_url="https://gw.invalid/v1", api_key="test-key") + + +def _install_transport(monkeypatch, handler) -> None: + """让 provider 自己造的 client 走 MockTransport,同时保留它设的 base_url / 鉴权头。""" + real_client = httpx.Client + + def factory(**kwargs): + return real_client(transport=httpx.MockTransport(handler), **kwargs) + + monkeypatch.setattr("windup_framework.providers.sufy.httpx.Client", factory) + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + + +class _Uploader: + """记录被上传的首帧,返回一个固定的公网 URL。""" + + def __init__(self, url: str = FRAME_URL) -> None: + self.url = url + self.uploaded: list[tuple[bytes, str]] = [] + + def upload(self, frame: bytes, content_type: str) -> str: + self.uploaded.append((frame, content_type)) + return self.url + + +def _gateway(calls: list, *, states: list[dict], result: dict | None = None): + """一个最小的 FAL 网关:提交给 request_id,状态按 states 顺序吐,视频 URL 给 bytes。""" + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + if request.method == "POST": + return httpx.Response(200, json={"request_id": "req-1", "status": "IN_QUEUE"}) + if request.url.path.endswith("/status"): + state = states[min(len(calls) - 2, len(states) - 1)] + in_flight = state.get("status") in ("IN_QUEUE", "IN_PROGRESS") + return httpx.Response(202 if in_flight else 200, json=state) + if request.url.path.endswith("/requests/req-1"): + return httpx.Response(200, json=result or {}) + return httpx.Response(200, content=VIDEO) + + return handler + + +def _provider(monkeypatch, handler, model: str = "kling-v2-5-turbo", mode: str = "std"): + _install_transport(monkeypatch, handler) + return FalQueueVideoProvider(_Uploader(), config=_config(), model=model, mode=mode) + + +# ── 端点表:三项各家都不同,只能查表 ──────────────────────────────────────── + + +@pytest.mark.parametrize("model", sorted(EXPECTED_ENDPOINTS)) +def test_each_model_resolves_to_the_path_and_image_field_in_the_spec(model): + """每个模型解析出 spec 里的提交路径、首帧字段名与轮询前缀。""" + submit_path, image_field, queue_base = EXPECTED_ENDPOINTS[model] + endpoint = fal_endpoint(model) + + assert endpoint.submit_path == submit_path + assert endpoint.image_field == image_field + assert endpoint.queue_base == queue_base + # 字段名要真的落到请求体上,而不是只写在表里。 + # 时长 / 画幅取该模型自己支持的值:各家能接的取值本就不同(veo 没有 5 秒, + # minimax 没有 720 档),用一组固定值反而会把这个测试变成时长测试。 + seconds = min(endpoint.seconds) + size = f"1280x{min(endpoint.resolutions)}" if endpoint.resolutions else "1280x720" + assert image_field in fal_i2v_body(model, "walk", FRAME_URL, seconds, size) + + +def test_table_holds_only_models_checked_against_the_spec(): + """新增模型必须同时补 EXPECTED_ENDPOINTS,逼作者回 spec 抄那三项。""" + assert set(FAL_I2V_ENDPOINTS) == set(EXPECTED_ENDPOINTS) + + +def test_same_family_different_generation_uses_different_image_field(): + """o3 / v2.5-turbo 是 image_url,v3 / v2.6 / o1 是 start_image_url —— 最容易顺手写错的一处。""" + assert fal_endpoint("kling-v3-omni").image_field == "image_url" + assert fal_endpoint("kling-v3").image_field == "start_image_url" + assert fal_endpoint("kling-video-o1").image_field == "start_image_url" + + +def test_unknown_model_raises_instead_of_guessing_a_path(): + with pytest.raises(UnknownVideoModelError, match="不在 FAL 图生视频端点表里"): + fal_endpoint("kling-v9-imaginary") + # 前缀像、但没登记的一样要炸(别退化成前缀匹配) + with pytest.raises(UnknownVideoModelError): + fal_submit_path("kling-v3-omni-pro", "std") + + +def test_unsupported_mode_raises_before_submitting(): + """v2.6 只有 pro;v3-turbo 只有 standard/pro(没有 std)。""" + with pytest.raises(UnsupportedVideoOptionError, match="mode"): + fal_submit_path("kling-v2-6", "std") + with pytest.raises(UnsupportedVideoOptionError, match="mode"): + fal_submit_path("kling-v3-turbo", "std") + assert fal_submit_path("kling-v2-6", "pro").endswith("/v2.6/pro/image-to-video") + + +def test_paths_without_a_mode_segment_ignore_mode(): + assert fal_submit_path("veo3.1", "std") == "/queue/fal-ai/veo3.1/image-to-video" + assert fal_submit_path("minimax-h3", "pro") == "/queue/minimax/h3/image-to-video" + + +# ── 请求体形态:时长三种写法、分辨率档位不做就近替换 ──────────────────────── + + +def test_duration_is_rendered_in_each_vendors_own_shape(): + assert fal_i2v_body("kling-v2-5-turbo", "p", FRAME_URL, 5, "1280x720")["duration"] == "5" + assert fal_i2v_body("veo3.1", "p", FRAME_URL, 8, "1280x720")["duration"] == "8s" + assert fal_i2v_body("minimax-h3", "p", FRAME_URL, 5, "1024x768")["duration"] == 5 + + +def test_unsupported_duration_raises(): + """v2.5-turbo 只有 5 / 10 秒。""" + with pytest.raises(UnsupportedVideoOptionError, match="秒"): + fal_i2v_body("kling-v2-5-turbo", "p", FRAME_URL, 7, "1280x720") + + +def test_models_without_a_resolution_knob_do_not_send_one(): + """kling 系没有 resolution 字段,画幅跟随首帧;硬塞会被网关 400。""" + assert "resolution" not in fal_i2v_body("kling-v3-omni", "p", FRAME_URL, 5, "1280x720") + + +def test_resolution_without_a_matching_tier_raises_instead_of_snapping(): + """minimax 只有 768P / 2K。悄悄把 720 换成 768P = 出片尺寸与调用方要的不一致。""" + assert fal_i2v_body("minimax-h3", "p", FRAME_URL, 5, "1024x768")["resolution"] == "768P" + with pytest.raises(UnsupportedVideoOptionError, match="分辨率档位"): + fal_i2v_body("minimax-h3", "p", FRAME_URL, 5, "1280x720") + + +def test_audio_is_switched_off_where_the_model_has_the_flag(): + """多数端点 generate_audio 默认 true;序列帧不要声音,不关等于白花钱。""" + assert fal_i2v_body("kling-v3", "p", FRAME_URL, 5, "1280x720")["generate_audio"] is False + assert fal_i2v_body("vidu-q3-pro", "p", FRAME_URL, 5, "1280x720")["audio"] is False + + +def test_base_url_v1_suffix_is_stripped_back_to_the_gateway_root(): + """/queue 与 /v1 平级,拿 base_url 直接拼会得到 /v1/queue/... → 404。""" + assert _api_root("https://gw.invalid/v1") == "https://gw.invalid" + assert _api_root("https://gw.invalid/v1/") == "https://gw.invalid" + assert _api_root("https://gw.invalid") == "https://gw.invalid" + + +# ── 端到端(mock):提交 → 轮询 → 下载 ────────────────────────────────────── + + +def test_end_to_end_hits_the_right_paths(monkeypatch): + calls: list[httpx.Request] = [] + provider = _provider( + monkeypatch, + _gateway(calls, states=[{"status": "IN_PROGRESS"}, COMPLETED]), + model="kling-v3", + mode="pro", + ) + + assert provider.i2v(_png(), "walk cycle", seconds=5, size="1280x720") == VIDEO + + submit, first_poll, second_poll, download = calls + assert submit.method == "POST" + assert submit.url.path == "/queue/fal-ai/kling-video/v3/pro/image-to-video" + # FAL 面是 Key 不是 Bearer(spec 的 securitySchemes 两套并列写明) + assert submit.headers["authorization"] == "Key test-key" + # 轮询打在家族级前缀上,不是提交路径 + /requests + assert first_poll.url.path == "/queue/fal-ai/kling-video/requests/req-1/status" + assert second_poll.url.path == first_poll.url.path + assert str(download.url) == VIDEO_URL + + +def test_first_frame_is_padded_then_uploaded_and_enters_the_body_as_a_url(monkeypatch): + from PIL import Image + + calls: list[httpx.Request] = [] + _install_transport(monkeypatch, _gateway(calls, states=[COMPLETED])) + uploader = _Uploader() + provider = FalQueueVideoProvider(uploader, config=_config(), model="kling-v2-5-turbo") + + provider.i2v(_png(900, 500), "walk", seconds=5, size="1280x720") + + frame, content_type = uploader.uploaded[0] + assert content_type == "image/jpeg" + assert Image.open(io.BytesIO(frame)).size == (1280, 720) # 补边到目标画幅 + assert json.loads(calls[0].content)["image_url"] == FRAME_URL + + +def test_no_request_is_sent_when_the_uploader_gives_no_public_url(monkeypatch): + """dataURI / 本地路径在这一面产不出正确结果,必须在**提交之前**炸。""" + + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError(f"不该发出任何请求: {request.url}") + + _install_transport(monkeypatch, handler) + provider = FalQueueVideoProvider(_Uploader("data:image/jpeg;base64,AAAA"), config=_config()) + + with pytest.raises(FirstFrameNotPublicError, match="http"): + provider.i2v(_png(), "walk") + + +def test_unsupported_options_are_rejected_before_the_frame_is_uploaded(monkeypatch): + """上传首帧要花钱/占带宽,不该为一个必然被拒的请求先传图。""" + + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError(f"不该发出任何请求: {request.url}") + + _install_transport(monkeypatch, handler) + uploader = _Uploader() + provider = FalQueueVideoProvider(uploader, config=_config(), model="kling-v2-5-turbo") + + with pytest.raises(UnsupportedVideoOptionError, match="秒"): + provider.i2v(_png(), "walk", seconds=7) + assert uploader.uploaded == [] + + +def test_unknown_model_and_bad_mode_are_rejected_at_construction(): + """炸在构造,而不是等到 i2v 真去提交任务。""" + with pytest.raises(UnknownVideoModelError): + FalQueueVideoProvider(_Uploader(), config=_config(), model="nope") + with pytest.raises(UnsupportedVideoOptionError): + FalQueueVideoProvider(_Uploader(), config=_config(), model="kling-v2-6", mode="std") + + +def test_satisfies_the_video_provider_contract(): + assert isinstance(FalQueueVideoProvider(_Uploader(), config=_config()), VideoProvider) + + +# ── 轮询的失败面:任何非成功终态都要炸 ────────────────────────────────────── + + +def _poll(states: list[dict], monkeypatch, *, result: dict | None = None, max_min: int = 30): + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + seen = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/status"): + state = states[min(seen["n"], len(states) - 1)] + seen["n"] += 1 + return httpx.Response(200, json=state) + return httpx.Response(200, json=result or {}) + + client = httpx.Client(transport=httpx.MockTransport(handler), base_url="https://gw.invalid") + with client: + return _await_fal_video_url(client, fal_endpoint("kling-v3"), "req-1", 1.0, max_min) + + +def test_failed_status_raises(monkeypatch): + with pytest.raises(VideoJobFailedError, match="任务失败"): + _poll([{"status": "FAILED", "detail": {"msg": "内容审核不通过"}}], monkeypatch) + + +def test_completed_with_detail_is_a_disguised_failure(monkeypatch): + """spec 明写:失败时后端也返回 COMPLETED,靠 detail 区分。只看 status 会当成功。""" + with pytest.raises(VideoJobFailedError, match="实为失败"): + _poll( + [{"status": "COMPLETED", "detail": {"msg": "upstream error"}, "result": {}}], + monkeypatch, + ) + + +def test_unrecognised_status_is_treated_as_failure(monkeypatch): + """continue 下去会把"协议变了"伪装成"生成太慢",转满预算才报超时。""" + with pytest.raises(VideoJobFailedError, match="未知状态"): + _poll([{"status": "SUCCEEDED"}], monkeypatch) + + +def test_timeout_raises_instead_of_returning_nothing(monkeypatch): + with pytest.raises(VideoJobTimeoutError, match="仍未出片"): + _poll([{"status": "IN_PROGRESS"}], monkeypatch, max_min=1) + + +def test_completed_without_inline_url_falls_back_to_the_result_endpoint(monkeypatch): + """视频已生成、费用已产生,不为省一次 GET 丢整单;取不到才炸。""" + states = [{"status": "COMPLETED", "detail": None, "result": {}}] + assert _poll(states, monkeypatch, result={"video": {"url": VIDEO_URL}}) == VIDEO_URL + + with pytest.raises(VideoJobFailedError, match="没有视频 URL"): + _poll(states, monkeypatch, result={}) + + +# ── 下载重试:视频已生成、费用已产生,断一次不能整单作废 ──────────────────── + + +def test_download_retry_still_applies_on_the_fal_route(monkeypatch): + downloads = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return httpx.Response(200, json={"request_id": "req-1"}) + if request.url.path.endswith("/status"): + return httpx.Response(200, json=COMPLETED) + downloads["n"] += 1 + if downloads["n"] == 1: + raise httpx.RemoteProtocolError( + "peer closed connection without sending complete message body", request=request + ) + return httpx.Response(200, content=VIDEO) + + provider = _provider(monkeypatch, handler) + assert provider.i2v(_png(), "walk") == VIDEO + assert downloads["n"] == 2 + + +def test_truncated_download_is_still_caught_by_the_length_check(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return httpx.Response(200, json={"request_id": "req-1"}) + if request.url.path.endswith("/status"): + return httpx.Response(200, json=COMPLETED) + return httpx.Response(200, content=VIDEO[:10], headers={"content-length": str(len(VIDEO))}) + + provider = _provider(monkeypatch, handler) + with pytest.raises(RuntimeError, match="已重试 3 次"): + provider.i2v(_png(), "walk") + + +# ── 提交被拒:把网关给的原因带出来 ────────────────────────────────────────── + + +def test_rejected_submit_surfaces_the_gateway_reason(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(400, json={"detail": {"msg": "image_url is required"}}) + + provider = _provider(monkeypatch, handler) + with pytest.raises(VideoJobFailedError, match="image_url is required"): + provider.i2v(_png(), "walk") + + +def test_submit_without_request_id_raises(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"status": "IN_QUEUE"}) + + provider = _provider(monkeypatch, handler) + with pytest.raises(VideoJobFailedError, match="request_id"): + provider.i2v(_png(), "walk") + + +# ── 已在公网的首帧:零成本 uploader ──────────────────────────────────────── + + +def test_pre_uploaded_first_frame_returns_the_url_as_is(): + uploader = PreUploadedFirstFrame(FRAME_URL) + assert uploader.upload(b"ignored", "image/jpeg") == FRAME_URL + with pytest.raises(FirstFrameNotPublicError): + PreUploadedFirstFrame("/tmp/local.png") diff --git a/backend/tests/test_sufy_video_download.py b/backend/tests/test_sufy_video_download.py index a4e0013c..07671656 100644 --- a/backend/tests/test_sufy_video_download.py +++ b/backend/tests/test_sufy_video_download.py @@ -80,46 +80,3 @@ def handler(request: httpx.Request) -> httpx.Response: def test_incomplete_download_error_is_a_runtime_error(): """调用方按 RuntimeError 兜底即可,不必单独 import 这个子类。""" assert issubclass(IncompleteDownloadError, RuntimeError) - - -# ── 首帧字段按模型选 + 参考图被忽略要炸(2026-08-07 实测挣得)──────────────── - - -def test_kling_v3_uses_image_list_not_input_reference(): - """Kling 用 image_list,Sora 用 input_reference。塞错字段的后果分两种: - - 老模型 failed(还能发现),而 kling-v3-omni **成功返回一段与母版无关的文生视频** - ——费用照付、status=completed、帧数正常,下游全部照常工作。 - """ - from windup_framework.providers.sufy import _needs_image_list - - assert _needs_image_list("kling-v3-omni") - assert _needs_image_list("kling-v3") - assert _needs_image_list("kling-video-o1") - # v2 系列已实测可吃 input_reference,不改既有通路 - assert not _needs_image_list("kling-v2-5-turbo") - assert not _needs_image_list("kling-v2-1") - assert not _needs_image_list("sora-2") - - -def test_reference_ignored_is_detected_from_billing_description(): - """网关按「无参考视频」计费 = 首帧被静默丢弃,必须炸而不是继续下载。""" - import pytest - - from windup_framework.providers.sufy import ( - ReferenceIgnoredError, - _assert_reference_registered, - ) - - with pytest.raises(ReferenceIgnoredError, match="静默忽略"): - _assert_reference_registered( - {"billing_type_description": "std x 无参考视频 x 无声"}, "kling-v3-omni" - ) - - -def test_reference_registered_passes_and_missing_field_does_not_block(): - """正常带参考的计费口径放行;字段缺失时不拦(不同网关字段不一定存在)。""" - from windup_framework.providers.sufy import _assert_reference_registered - - _assert_reference_registered({"billing_type_description": "std x 图生视频 x 无声"}, "m") - _assert_reference_registered({}, "m") From 5db21353a220cfd25ce5da8e78b2699cf6f6eaec Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Sat, 8 Aug 2026 00:23:08 +0800 Subject: [PATCH 04/33] =?UTF-8?q?fix(providers):=20=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E8=A1=A5=E5=BA=95=E8=89=B2=E6=B8=85=E7=90=86=EF=BC=8C=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E7=8C=9C=E8=83=8C=E6=99=AF=E8=89=B2=E7=9A=84=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处,都是 2026-08-07 用三个全新角色母版实测出来的。 1) u2netp 对闭合区域天然失灵 四足角色腿间的背景是一块被主体围住的空隙,显著性模型把它当成主体内部,整块底色 留在产物里;轮廓上还带一圈底色描边。母版底色是刻意生成的纯色、均匀度极高(实测 四角标准差 1.0–1.2),拿它做一次窄阈值清理正好补上这个洞。 阈值必须窄。实测一个铁锈橙毛 (222,130,70) 的角色配玫红底 (222,41,124):两者红通道 完全相同、欧氏距离仅 104。先后试过两版宽阈值 chroma,都把橙毛判成半透明并去"反解", 越解越坏(先成橄榄绿、再成亮绿)。取 38 时橙毛 d≈117 完全不受影响,而闭合空隙里的 背景 d≈0 干净移除。三个角色残留 2.54%/0.44%/1.25% → 0.17%/0.21%/0.26%。 与"按颜色抠是死路"那条规则的边界:那条说的是拿颜色当**主体判据**(白底浅色角色会 被抠穿)。这里主体判据仍是 u2netp,颜色只用来**做减法**,绝不新增主体像素;底色不够 均匀时(四角 std > 8)直接跳过,等于不清理。 2) 去掉 onnxruntime 缺失时的静默兜底 旧行为是回落到"取四角主色做 chroma-key"。两个问题:猜背景色——白底母版四角就是白色, 浅色角色与背景撞色会被抠穿;静默——开发机上看着能跑、输出其实是坏的,要到产物验收 才发现。改为抛 RuntimeError。 五条回归测试,变异测试确认有效:阈值放宽到 120(误伤橙毛)、去掉均匀性守卫、清理系数 允许 >1(凭空造主体)、恢复静默兜底,各有用例失败;还原后 7 passed。 --- .../src/windup_framework/providers/matte.py | 73 ++++++++++------- backend/tests/test_matte_provider.py | 80 +++++++++++++++++++ 2 files changed, 125 insertions(+), 28 deletions(-) diff --git a/backend/packages/framework/src/windup_framework/providers/matte.py b/backend/packages/framework/src/windup_framework/providers/matte.py index 050997b8..7105ba56 100644 --- a/backend/packages/framework/src/windup_framework/providers/matte.py +++ b/backend/packages/framework/src/windup_framework/providers/matte.py @@ -29,6 +29,39 @@ _SIZE = (320, 320) +# 只清理"几乎精确等于底色"的像素。阈值必须窄:2026-08-07 实测,一个铁锈橙毛 +# (222,130,70)的角色配玫红底(222,41,124),两者红通道完全相同、欧氏距离仅 104 —— +# 宽阈值会把毛判成半透明并去"反解",越解越坏(先成橄榄绿再成亮绿)。橙毛 d≈117, +# 阈值 38 完全碰不到它;而闭合空隙里的背景 d≈0,能干净移除。 +_KEY_KILL = 38.0 # d < 此值 → 判为纯背景 +_KEY_SOFT = 14.0 # 到 _KEY_KILL + _KEY_SOFT 之间线性过渡,避免硬边锯齿 +_BG_FLAT_STD = 8.0 # 四角色标准差上限;超过说明底不是纯色,不做任何清理 + + +def _flat_bg_penalty(rgb: np.ndarray) -> np.ndarray: + """底色清理系数(0=纯背景,1=主体),形状与图同宽高。 + + 为什么需要它:u2netp 是显著性模型,对**闭合区域**天然失灵 —— 四足角色腿间的 + 背景是一块被主体围住的空隙,显著性把它当成主体内部,整块底色留在产物里 + (2026-08-07 实测)。而母版底色是刻意生成的纯色,均匀度极高(实测四角标准差 1.0~1.2), + 用它做一次窄阈值清理就能补上这个洞。 + + 与"按颜色抠是死路"那条规则的边界:那条说的是**拿颜色当主体判据**(白底浅色角色 + 会被抠穿)。这里主体判据仍然是 u2netp,颜色只用来**做减法** —— 绝不新增主体像素, + 最坏情况是少清理一点,不会抠穿角色。底色不够均匀时(std 超阈值)直接返回全 1, + 等于不清理。 + """ + corners = np.concatenate([ + rgb[:12, :12].reshape(-1, 3), rgb[:12, -12:].reshape(-1, 3), + rgb[-12:, :12].reshape(-1, 3), rgb[-12:, -12:].reshape(-1, 3), + ]) + if float(corners.std(axis=0).max()) > _BG_FLAT_STD: + return np.ones(rgb.shape[:2], dtype=np.float32) # 底不是纯色 → 不动 + key = np.median(corners, axis=0).astype(np.float32) + d = np.linalg.norm(rgb - key, axis=2) + return np.clip((d - _KEY_KILL) / _KEY_SOFT, 0.0, 1.0).astype(np.float32) + + class OnnxU2NetMatteProvider(MatteProvider): """u2netp.onnx via onnxruntime。frame bytes → 抠好的 PNG(RGBA) bytes。""" @@ -47,8 +80,14 @@ def _get_session(self): if self._session is None: try: import onnxruntime as ort # 惰性:导入慢 - except ImportError: - return None # onnxruntime 不可用(如 macOS x86_64),走 Pillow 兜底 + except ImportError as e: # pragma: no cover - 取决于安装环境 + # **不静默降级。** 这里曾在 ImportError 时回落到"取四角主色做 chroma-key", + # 有两个问题:①猜背景色 —— 白底母版四角就是白色,浅色角色(骨白/银甲)与背景 + # 撞色会被抠穿;②静默 —— 开发机上看着能跑、输出其实是坏的,要到产物验收才发现。 + raise RuntimeError( + "onnxruntime 不可用,无法做主体抠图。请安装 onnxruntime" + "(注意 <1.24 才有 macOS Intel 轮子)。" + ) from e self._session = ort.InferenceSession( str(self._ensure_model()), providers=["CPUExecutionProvider"] ) @@ -73,31 +112,9 @@ def _predict_mask(self, img: Image.Image) -> Image.Image: def cutout(self, frame: bytes) -> bytes: img = Image.open(io.BytesIO(frame)).convert("RGBA") - session = self._get_session() - if session is not None: - mask = self._predict_mask(img) - else: - mask = self._fallback_mask(img) - cut = Image.composite(img, Image.new("RGBA", img.size, (0, 0, 0, 0)), mask) + alpha = np.asarray(self._predict_mask(img), dtype=np.float32) / 255.0 + alpha = alpha * _flat_bg_penalty(np.asarray(img.convert("RGB"), dtype=np.float32)) + out = np.dstack([np.asarray(img.convert("RGB")), alpha * 255.0]).astype(np.uint8) buf = io.BytesIO() - cut.save(buf, "PNG") + Image.fromarray(out, "RGBA").save(buf, "PNG") return buf.getvalue() - - @staticmethod - def _fallback_mask(img: Image.Image) -> Image.Image: - """Pillow 兜底:取四角主色做 chroma-key 式去背(精度远低于 u2netp,仅开发用)。""" - import numpy as np - - ary = np.array(img.convert("RGB")) - # 取四角 8×8 采样主色 - corners = np.concatenate([ - ary[:8, :8].reshape(-1, 3), - ary[:8, -8:].reshape(-1, 3), - ary[-8:, :8].reshape(-1, 3), - ary[-8:, -8:].reshape(-1, 3), - ]) - bg = corners.mean(axis=0) - diff = np.linalg.norm(ary.astype(float) - bg, axis=2) - # 阈值:距离 < 60 视为背景 - mask = (diff > 60).astype(np.uint8) * 255 - return Image.fromarray(mask, "L").resize(img.size, Image.LANCZOS) diff --git a/backend/tests/test_matte_provider.py b/backend/tests/test_matte_provider.py index 9c9bd184..4647f2bb 100644 --- a/backend/tests/test_matte_provider.py +++ b/backend/tests/test_matte_provider.py @@ -14,3 +14,83 @@ def test_onnx_matte_lazy_no_model_load_on_construct(): # 构造不触发下载 / 会话创建(惰性),模型缺失也不报错 provider = OnnxU2NetMatteProvider(model_path="/nonexistent/u2netp.onnx") assert provider._session is None + + +# ── 底色清理(2026-08-07 实测挣得)──────────────────────────────────────────── + + +def _rgb(w, h, bg, blob=None): + import numpy as np + a = np.zeros((h, w, 3), dtype=np.float32) + a[:, :] = bg + if blob: + (x0, y0, x1, y1), c = blob + a[y0:y1, x0:x1] = c + return a + + +def test_flat_background_is_killed_but_subject_untouched(): + """纯色底 → 系数 0(会被清掉);主体色 → 系数 1(一像素不动)。""" + from windup_framework.providers.matte import _flat_bg_penalty + + bg = (222, 41, 124) # 实测的玫红底 + fur = (222, 130, 70) # 铁锈橙毛:与底色红通道相同,欧氏距离仅约 104 + a = _rgb(80, 60, bg, blob=((20, 15, 60, 45), fur)) + p = _flat_bg_penalty(a) + assert p[2, 2] == 0.0, "四角纯背景必须被判为 0" + assert p[30, 40] == 1.0, "橙毛必须完全不受影响 —— 宽阈值会把它反解成绿色" + + +def test_enclosed_background_gap_is_killed(): + """被主体围住的背景空隙也要清掉 —— u2netp 对闭合区域天然失灵。""" + from windup_framework.providers.matte import _flat_bg_penalty + + bg = (222, 41, 124) + a = _rgb(80, 60, bg, blob=((16, 16, 64, 44), (100, 120, 140))) # 避开取样用的 12×12 角落 + a[24:34, 30:50] = bg # 主体内部挖一个洞,填回底色 + p = _flat_bg_penalty(a) + assert p[30, 40] == 0.0, "闭合空隙里的底色必须被清掉" + assert p[20, 20] == 1.0, "洞外的主体不受影响" + + +def test_non_flat_background_disables_cleanup_entirely(): + """底色不均匀时一律不清理 —— 宁可漏,不可误伤。""" + import numpy as np + + from windup_framework.providers.matte import _flat_bg_penalty + + rng = np.random.default_rng(0) + noisy = rng.uniform(0, 255, (60, 80, 3)).astype(np.float32) + assert (_flat_bg_penalty(noisy) == 1.0).all() + + +def test_cleanup_only_subtracts_never_adds_subject(): + """系数恒在 [0,1] —— 只做减法,最坏情况是少清理,不会凭空造出主体。""" + from windup_framework.providers.matte import _flat_bg_penalty + + a = _rgb(40, 40, (0, 255, 0), blob=((5, 5, 35, 35), (200, 60, 60))) + p = _flat_bg_penalty(a) + assert p.min() >= 0.0 and p.max() <= 1.0 + + +def test_missing_onnxruntime_raises_instead_of_guessing_background(): + """装不上就报出来,不能回落到"猜四角主色"——白底浅色角色会被抠穿。""" + import builtins + + import pytest + + from windup_framework.providers.matte import OnnxU2NetMatteProvider + + real = builtins.__import__ + + def blocked(name, *a, **k): + if name == "onnxruntime": + raise ImportError("blocked for test") + return real(name, *a, **k) + + builtins.__import__ = blocked + try: + with pytest.raises(RuntimeError, match="onnxruntime"): + OnnxU2NetMatteProvider()._get_session() + finally: + builtins.__import__ = real From 9b10b556750878c7dffbaee8e18712ac9a2c6670 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 15:37:09 +0800 Subject: [PATCH 05/33] =?UTF-8?q?fix(framework):=20=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E5=8F=96=E4=B8=BB=E7=BA=BF=E7=89=88=E6=9C=AC?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=20rebase=20=E6=97=B6=20lock=20=E4=B8=8E=20py?= =?UTF-8?q?project=20=E4=B8=8D=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rebase 到 main 时解冲突取了主线的 uv.lock,但 framework/pyproject.toml 取了本分支的, 后者少了主线用户模块加的 passlib[bcrypt] / redis / resend —— CI 装依赖时按 pyproject 解析,于是 conftest.py 导入 bcrypt 失败(ModuleNotFoundError,本地因 venv 里已装而没暴露)。 主线的 pyproject 已含本分支需要的全部依赖(onnxruntime<1.24 / qiniu / pillow / numpy, 连注释都是从这条线过去的),故直接取主线版本,两边并集自然成立。uv lock --check 通过。 --- backend/packages/framework/pyproject.toml | 4 + backend/uv.lock | 259 +++++++++++++++++++++- 2 files changed, 258 insertions(+), 5 deletions(-) diff --git a/backend/packages/framework/pyproject.toml b/backend/packages/framework/pyproject.toml index 44c78a4c..7084c760 100644 --- a/backend/packages/framework/pyproject.toml +++ b/backend/packages/framework/pyproject.toml @@ -22,6 +22,10 @@ dependencies = [ "pillow>=10.4", # 对象存储(七牛 Kodo);若换 OSS/S3/MinIO 改 oss2 / boto3 / minio。 "qiniu>=7.14", + # 用户模块:密码哈希 / Redis / 邮件 + "passlib[bcrypt]>=1.7", + "redis>=5.0", + "resend>=2.0", # 以下按选型启用: # "rocketmq-client", # RocketMQ Python 客户端(5.x gRPC 版 / C++ 绑定版二选一) ] diff --git a/backend/uv.lock b/backend/uv.lock index 70c41c57..bfcbe78f 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -14,6 +14,7 @@ members = [ dev = [ { name = "import-linter", specifier = ">=2.0" }, { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, { name = "ruff", specifier = ">=0.6" }, ] @@ -48,6 +49,72 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -151,6 +218,105 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934" }, ] +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -160,6 +326,28 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4" }, +] + [[package]] name = "fastapi" version = "0.139.2" @@ -750,7 +938,7 @@ wheels = [ [[package]] name = "openai" -version = "2.53.0" +version = "2.50.0" source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } dependencies = [ { name = "anyio" }, @@ -762,9 +950,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/f5/e7735f2af272ee179a287911a698b3cbdb59d7a4ac4874571363adf1e4de/openai-2.50.0.tar.gz", hash = "sha256:5128f7caf4a6b01aefd6e7e93efe170a2c3427b8de286b9af5cdff3aa47e02c8" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618" }, + { url = "https://mirrors.aliyun.com/pypi/packages/00/ca/db315b3bb748c26c644a3f85b7d509e774354d6518d47080b1446005ee41/openai-2.50.0-py3-none-any.whl", hash = "sha256:90bdddcc5a2fa529b350fac9c5780d87e5c361dcc6090ab57b0d470b0d7af7fa" }, ] [[package]] @@ -868,6 +1056,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, ] +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + [[package]] name = "pillow" version = "12.3.0" @@ -1036,6 +1238,11 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.4" @@ -1168,6 +1375,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1244,6 +1465,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/1d/56/9368cd96d2132017f5748a812cb20a87263498de314e1823472cb4bdbad9/qiniu-7.18.0-py3-none-any.whl", hash = "sha256:0f1be608ac6800ad5f32690d1aa02353b6b2ff5edc78f32a2318859d375a27df" }, ] +[[package]] +name = "redis" +version = "8.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb" }, +] + [[package]] name = "regex" version = "2026.7.19" @@ -1359,6 +1589,19 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, ] +[[package]] +name = "resend" +version = "2.35.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f0/b9/e07f2f2bd992ef4565b9c315dfd46b3df66ce89df1d928f442b9b39cbe3b/resend-2.35.0.tar.gz", hash = "sha256:26ced7b22cbd89f7b8c7ba9719d0708ab10c06ddd0f91ba6ec861f3a328101b3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/cc/92/1c0912b68ae082a55dfdd32e4b5117905ae9fd1b6efc5f5e7c69a4ee6894/resend-2.35.0-py2.py3-none-any.whl", hash = "sha256:cd75299d626f4735af52910989b3f51919032ef316e2d60563c79c319e7b24a6" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1846,7 +2089,7 @@ version = "0.1.0" source = { editable = "packages/app" } dependencies = [ { name = "fastapi" }, - { name = "pydantic" }, + { name = "pydantic", extra = ["email"] }, { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, @@ -1858,7 +2101,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, - { name = "pydantic", specifier = ">=2.7" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.7" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, @@ -1888,12 +2131,15 @@ dependencies = [ { name = "langchain-openai" }, { name = "numpy" }, { name = "onnxruntime" }, + { name = "passlib", extra = ["bcrypt"] }, { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "qiniu" }, + { name = "redis" }, + { name = "resend" }, { name = "sqlalchemy" }, { name = "windup-common" }, ] @@ -1905,12 +2151,15 @@ requires-dist = [ { name = "langchain-openai", specifier = ">=0.3" }, { name = "numpy", specifier = ">=1.26" }, { name = "onnxruntime", specifier = ">=1.17,<1.24" }, + { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7" }, { name = "pillow", specifier = ">=10.4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.4" }, { name = "pyjwt", specifier = ">=2.9" }, { name = "qiniu", specifier = ">=7.14" }, + { name = "redis", specifier = ">=5.0" }, + { name = "resend", specifier = ">=2.0" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "windup-common", editable = "packages/common" }, ] From ae3657d33367159d0006a4044617c1ead529bd96 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 18:12:07 +0800 Subject: [PATCH 06/33] =?UTF-8?q?fix(providers):=20=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E4=B8=8D=E5=86=8D=E6=8A=8A=20API=20key=20?= =?UTF-8?q?=E5=B8=A6=E7=BB=99=E6=88=90=E5=93=81=E5=9F=9F=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 机器审 PR #179 P1。成品 URL 是网关响应里的绝对地址(正常指向 CDN,异常可以是 网关返回的任意地址),原实现复用带 Authorization 的网关 client 直接 GET。httpx 只在跨源**重定向**时才自动摘 Authorization,对一开始就跨源的直连请求会原样带上 client 级 headers —— API key 因此发给了那个域名。 改法: - 按目标地址判定后显式摘凭证,不是一律摘。网关也可能签发自己域名下的下载链接, 那条路径摘了头就是 401,所以同源保留、跨源摘掉 Authorization 与 Cookie。 - Proxy-Authorization 不动:它是给代理的,与目标是否同源无关。 - 同源判据对齐 httpx 自己的 `_redirect_headers`(scheme + host + 端口), 未 import 其私有函数,免得被上游改名。 - 请求改为进重试循环之前构造,非 http(s) 地址在发出任何一次请求之前就炸。 - 2026-08-05 实测挣来的三次退避重试与 Content-Length 校验原样保留(视频已生成、 费用已产生,断一次不能整单作废),FAL 面调用处那句"用同一个 client 带鉴权头取" 的注释同步更正 —— 它正是这个泄漏的出处。 变异验证 13 个:12 被杀。唯一存活的是单独拆掉"默认端口补齐" —— httpx 0.28 已把 :443/:80 归一化成 port=None,该行与 scheme 比较互为冗余,两条同时拆即被杀。 Co-Authored-By: Claude Opus 5 --- .../src/windup_framework/providers/sufy.py | 62 +++++++- .../tests/test_fal_queue_video_provider.py | 4 + backend/tests/test_sufy_video_download.py | 134 +++++++++++++++++- 3 files changed, 193 insertions(+), 7 deletions(-) diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py index eadd78f1..ca621abc 100644 --- a/backend/packages/framework/src/windup_framework/providers/sufy.py +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -133,6 +133,54 @@ class IncompleteDownloadError(RuntimeError): """视频下载到的字节数与 ``Content-Length`` 不符。""" +class UnsafeDownloadUrlError(RuntimeError): + """成品 URL 的协议不是 http(s) —— 不下载。 + + 这个 URL 来自网关响应,是外部输入。直接丢给 httpx 去 GET 一个 ``file://`` / ``data:`` + 只会在重试三次之后报一个跟协议无关的传输错,不如在这里就说清是地址不对。 + """ + + +def _same_origin(url: httpx.URL, other: httpx.URL) -> bool: + """同源判定(scheme + host + 端口,默认端口按 scheme 补齐)。 + + 语义对齐 httpx 自己在跨源重定向时摘凭证用的 ``Client._redirect_headers``; + 没直接 import 它的私有 ``_same_origin``,免得被上游改名。 + + "默认端口补齐"这一步在 httpx 0.28 下其实判不出新差别(它已把 ``:443`` / ``:80`` + 归一化成 ``port is None``,2026-08-10 变异测试确认单独拆掉这行无用例失败)。留着的理由 + 是与 httpx 保持同一套判据:一旦上游不再归一化,少了它 ``https://gw`` 与 ``https://gw:443`` + 就成了跨源,会把该带的凭证摘掉、把同源下载打成 401。 + """ + default = {"http": 80, "https": 443} + return ( + url.scheme == other.scheme + and url.host == other.host + and (url.port or default.get(url.scheme)) == (other.port or default.get(other.scheme)) + ) + + +def _download_request(client: httpx.Client, url: str) -> httpx.Request: + """构造成品下载请求;目标不在网关同源时,把 client 级凭证摘掉。 + + 为什么必须摘(2026-08-10 机器审提出):成品 URL 是**网关响应里的绝对地址**,正常情况 + 指向 CDN 域名,异常情况可以是网关返回的任意地址。而 httpx 只在跨源**重定向**时才自动 + 摘 Authorization,对这种一开始就跨源的直连请求,client 级 headers 会原样带过去 —— + 于是 ``Authorization: Bearer/Key `` 被发给了那个域名,等于把 API key 交出去。 + + 同源时保留凭证:网关也可能签发自己域名下的下载链接,那条路径摘了头就是 401。 + 所以按目标地址判定,不是一律摘、也不是一律留。 + """ + request = client.build_request("GET", url) + if request.url.scheme not in ("http", "https"): + raise UnsafeDownloadUrlError(f"成品 URL 必须是 http(s),收到 {str(request.url)!r}") + if not _same_origin(request.url, client.base_url): + # 只摘目标域名不该看到的:Proxy-Authorization 是给代理的,与目标是否同源无关,别动它。 + request.headers.pop("Authorization", None) + request.headers.pop("Cookie", None) + return request + + def _download(client: httpx.Client, url: str, tries: int = 3) -> bytes: """下载已生成好的视频,带重试 + 长度校验。 @@ -149,11 +197,17 @@ def _download(client: httpx.Client, url: str, tries: int = 3) -> bytes: 长度校验是因为截断不一定抛异常:服务端提前关流而客户端已收到部分 body 时, ``.content`` 可能直接返回短 bytes,那样坏视频会一路流到出帧环节才暴露, 在那里看起来像"解码失败",很难回溯到这里。``Content-Length`` 缺失(分块传输)时跳过校验。 + + 凭证处理见 :func:`_download_request`。请求在进循环之前就构造好:地址不合法要在 + 发出任何一次请求之前炸,而不是重试三次之后。 """ + request = _download_request(client, url) last: Exception | None = None for attempt in range(tries): try: - response = client.get(url) + # send 不会再合并 client 级 headers(build_request 时已合并过), + # 所以上面摘掉的 Authorization 不会被重新加回来。 + response = client.send(request) response.raise_for_status() body = response.content expected = response.headers.get("content-length") @@ -487,8 +541,10 @@ def i2v( url = _await_fal_video_url( client, self._endpoint, request_id, self._poll, self._max_min ) - # 成品 URL 是网关签发的下载链接,用同一个 client 取:这条下载路径(带鉴权头、 - # 带重试与长度校验)是 2026-08-05 实测挣来的,不为"看起来更干净"去动它。 + # 重试与长度校验是 2026-08-05 实测挣来的,不为"看起来更干净"去动它。 + # 但凭证不跟着走:成品 URL 多是 CDN 绝对地址,跨源时 _download 会摘掉 + # Authorization(见 _download_request —— 原来那句"用同一个 client 带鉴权头取" + # 就是 2026-08-10 机器审报的 key 泄漏)。 return _download(client, url) def _upload(self, first_frame: bytes, size: str) -> str: diff --git a/backend/tests/test_fal_queue_video_provider.py b/backend/tests/test_fal_queue_video_provider.py index dc5323df..05e353b7 100644 --- a/backend/tests/test_fal_queue_video_provider.py +++ b/backend/tests/test_fal_queue_video_provider.py @@ -270,6 +270,10 @@ def test_end_to_end_hits_the_right_paths(monkeypatch): assert first_poll.url.path == "/queue/fal-ai/kling-video/requests/req-1/status" assert second_poll.url.path == first_poll.url.path assert str(download.url) == VIDEO_URL + # 成品 URL 在 CDN 域名下(gw.invalid → cdn.invalid),这一跳不能带 API key。 + # 端到端这一层单独断言:_download 的单测再全,也管不住调用方哪天又把凭证塞回来。 + assert "authorization" not in download.headers, "API key 被发给了 CDN(PR #179 P1)" + assert download.url.host != submit.url.host def test_first_frame_is_padded_then_uploaded_and_enters_the_body_as_a_url(monkeypatch): diff --git a/backend/tests/test_sufy_video_download.py b/backend/tests/test_sufy_video_download.py index 07671656..25e4ec85 100644 --- a/backend/tests/test_sufy_video_download.py +++ b/backend/tests/test_sufy_video_download.py @@ -1,21 +1,41 @@ -"""视频成品下载的重试与完整性校验(不联网:用 httpx MockTransport)。 +"""视频成品下载的凭证边界、重试与完整性校验(不联网:用 httpx MockTransport)。 -回归对象是 2026-08-05 实测两次连续复现的一类失败:视频已生成、费用已产生, -却因为读 body 时断了一次连接就整单丢弃。见 ``providers.sufy._download`` 的 docstring。 +两个回归对象: + +1. 2026-08-05 实测两次连续复现:视频已生成、费用已产生,却因为读 body 时断了一次连接 + 就整单丢弃。见 ``providers.sufy._download`` 的 docstring。 +2. 2026-08-10 机器审(PR #179 P1):成品 URL 是网关返回的绝对地址,复用带 Authorization + 的 client 去下载 = 把 API key 发给了 CDN(或网关返回的任意地址)。 + 见 ``providers.sufy._download_request`` 的 docstring。 """ import httpx import pytest -from windup_framework.providers.sufy import IncompleteDownloadError, _download +from windup_framework.providers.sufy import ( + IncompleteDownloadError, + UnsafeDownloadUrlError, + _download, +) VIDEO = b"\x00\x01mp4-bytes" * 64 +GATEWAY = "https://gw.invalid/v1" def _client(handler) -> httpx.Client: return httpx.Client(transport=httpx.MockTransport(handler)) +def _authed_client(handler, base_url: str = GATEWAY) -> httpx.Client: + """带凭证的网关 client —— provider 真正持有的就是这种(Authorization + cookie jar)。""" + return httpx.Client( + transport=httpx.MockTransport(handler), + base_url=base_url, + headers={"Authorization": "Key secret-api-key"}, + cookies={"session": "s3cr3t"}, + ) + + def test_retries_after_peer_closed_connection(monkeypatch): """第一次断连、第二次成功 —— 原实现在这里会整单丢弃。""" monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) @@ -80,3 +100,109 @@ def handler(request: httpx.Request) -> httpx.Response: def test_incomplete_download_error_is_a_runtime_error(): """调用方按 RuntimeError 兜底即可,不必单独 import 这个子类。""" assert issubclass(IncompleteDownloadError, RuntimeError) + + +# ── 凭证边界:成品 URL 是网关给的外部地址,不能带着 API key 去取 ────────────── + + +def test_cross_origin_download_does_not_leak_the_api_key(monkeypatch): + """跨源下载必须摘掉 client 级凭证。 + + 这是 PR #179 P1 的直接回归:httpx 只在跨源**重定向**时自动摘 Authorization, + 对一开始就跨源的直连请求会原样带上 —— 于是 CDN 域名收到了 API key。 + """ + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + seen: dict[str, str | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["authorization"] = request.headers.get("authorization") + seen["cookie"] = request.headers.get("cookie") + return httpx.Response(200, content=VIDEO) + + with _authed_client(handler) as client: + assert _download(client, "https://cdn.invalid/out.mp4") == VIDEO + + assert seen["authorization"] is None, "API key 被发给了 CDN" + assert seen["cookie"] is None, "会话 cookie 被发给了 CDN" + + +def test_same_origin_download_keeps_the_gateway_credential(monkeypatch): + """同源(网关自己签发的下载链接)必须保留凭证,否则那条路径就是 401。 + + 一律摘头会把这个功能弄坏,所以判据是目标地址,不是"下载一律不带凭证"。 + """ + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + seen: list[str | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.headers.get("authorization")) + return httpx.Response(200, content=VIDEO) + + with _authed_client(handler) as client: + # 第二个地址显式写出默认端口 443。httpx 0.28 会把默认端口归一化掉(URL.port -> None), + # 所以这条今天走不到"补默认端口"那行;留着是钉住这个前提 —— httpx 哪天不再归一化, + # 少了默认端口补齐就会把它误判成跨源、把凭证摘掉,这条会先叫。 + assert _download(client, "https://gw.invalid/files/out.mp4") == VIDEO + assert _download(client, "https://gw.invalid:443/files/out.mp4") == VIDEO + + assert seen == ["Key secret-api-key", "Key secret-api-key"] + + +def test_downgrade_to_plain_http_is_treated_as_cross_origin(monkeypatch): + """同 host 但 scheme 从 https 掉到 http —— 也要摘凭证。 + + 默认端口被 httpx 归一化成 None,host 又相同,所以同源判定里**少比一个 scheme** + 就会把它当自己人,于是 API key 走明文 HTTP 发出去。httpx 自己在重定向那侧也是 + 单独处理 http/https 的(``_is_https_redirect``),方向只允许 http→https,不允许反过来。 + """ + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + seen: dict[str, str | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["authorization"] = request.headers.get("authorization") + return httpx.Response(200, content=VIDEO) + + with _authed_client(handler) as client: + assert _download(client, "http://gw.invalid/files/out.mp4") == VIDEO + assert seen["authorization"] is None, "API key 走明文 HTTP 发了出去" + + # 再来一格显式非默认端口:两边端口都是 8443,"补默认端口"那行判不出差别, + # 只有 scheme 比较能拦住。少了这一格,scheme 比较会显得可以删(实际不行)。 + with _authed_client(handler, base_url="https://gw.invalid:8443/v1") as client: + assert _download(client, "http://gw.invalid:8443/files/out.mp4") == VIDEO + assert seen["authorization"] is None, "非默认端口上的 https->http 降级没拦住" + + +def test_relative_result_path_stays_authenticated(monkeypatch): + """网关返回相对路径时,它解析到网关自己身上,凭证照带。""" + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + seen: dict[str, str | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["authorization"] = request.headers.get("authorization") + return httpx.Response(200, content=VIDEO) + + with _authed_client(handler) as client: + assert _download(client, "files/out.mp4") == VIDEO + + assert seen["url"] == "https://gw.invalid/v1/files/out.mp4" + assert seen["authorization"] == "Key secret-api-key" + + +def test_non_http_result_url_is_refused_before_any_request_goes_out(monkeypatch): + """协议不是 http(s) 就不发请求 —— 地址不对要立刻炸,不是重试三次后报传输错。 + + 注意 httpx 的边界:只有**带 host** 的绝对地址才保留原 scheme(``ftp://cdn/...``); + ``file:///etc/passwd`` 这种没有 host 的会被 httpx 当相对地址并入 base_url, + 结果是一个打到网关的 404,不经过这个分支。 + """ + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError(f"不该发出任何请求: {request.url}") + + for url in ("ftp://cdn.invalid/out.mp4", "file://cdn.invalid/out.mp4"): + with _authed_client(handler) as client: + with pytest.raises(UnsafeDownloadUrlError, match="http"): + _download(client, url) From 2a58964ae284a49aaca807238254ece1525bde16 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 21:35:08 +0800 Subject: [PATCH 07/33] =?UTF-8?q?feat(providers):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=96=87=E7=94=9F=E5=9B=BE=20provider=EF=BC=8C=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E4=B8=8D=E5=86=8D=E5=BF=85=E7=84=B6=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /generation/image 是可达端点,ImageTaskExecutor 默认实例化 SufyImageProvider, 而该类的 gen_image 直接抛 NotImplementedError —— 每个图像任务都稳定走到 FAILED。 端点看着可用、实际必失败,是本仓最忌讳的形态(机器审逮到)。 实现要点: - 走 OpenAI 兼容的 /chat/completions 面,参考图以 data URI 塞进 content 数组。 与 i2v 的提交-轮询-下载三段式是完全不同的调用形状,不复用 VideoProvider 通路。 - 对整个响应 JSON 正则取 data URI,不猜 message.content 的层级:不同网关包裹层级 不一致,猜错的代价是"调用成功、费用已产生、但我们报没图"。 - 空图重试 3 次。模型偶发返回一条不含图的正常响应;这与 _download 的网络重试是两 码事,后者治连接断。 - 校验 base64 解出的字节数下限 5000。响应里可能带几十字节的占位串,当图存下去就是 一个打不开的文件。 - 取不到有效图抛 RuntimeError,不返回空 bytes:上游会把返回值直接上传对象存储并写 进任务结果,0 字节的"成功"就是用户看到的裂图。 通路取自已跑通的实现(同日用它出过三张角色母版),非新写。 测试 5 条,全 mock 无付费调用,逐条做过变异测试: 把重试改成 1 次 / 去掉字节下限校验 / 丢掉参考图 / 拿不到图返回空 bytes / 成功后不 早退,五个变异各让 1~3 条用例变红。 --- .../src/windup_framework/providers/sufy.py | 83 +++++++++++- backend/tests/test_sufy_video_download.py | 122 ++++++++++++++++++ 2 files changed, 199 insertions(+), 6 deletions(-) diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py index ca621abc..579dd006 100644 --- a/backend/packages/framework/src/windup_framework/providers/sufy.py +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -27,6 +27,9 @@ import base64 import io +import json +import logging +import re import time from collections.abc import Mapping from dataclasses import dataclass, field @@ -37,6 +40,8 @@ from .interfaces import FirstFrameUploader, ImageProvider, VideoProvider +logger = logging.getLogger("windup.providers.sufy") + # 只有 kling-video-o1 走 image_list;v2 系列 / sora 走 input_reference(字段按模型选,塞错任务会 failed)。 _IMAGE_LIST_MODELS = ("kling-video-o1",) DEFAULT_VIDEO_MODEL = "kling-v2-5-turbo" @@ -638,14 +643,80 @@ def _fal_result_url(client: httpx.Client, endpoint: FalI2VEndpoint, request_id: return str(url) +DEFAULT_IMAGE_MODEL = "gemini-2.5-flash-image" + +# "调用成功但没返回有效图"的重试次数。与 _download 的网络重试是两码事:那个治连接断, +# 这个治模型返回了一条不含图的正常响应(实测偶发)。也是为什么下面要判 base64 长度 —— +# 返回里可能带一个几十字节的占位串,当图存下去就是一个打不开的文件。 +_IMAGE_TRIES = 3 +_MIN_IMAGE_BYTES = 5000 +_CONNECT_RETRIES = 3 + +# 从响应里捞 data URI。模型把图放在 message.content 里,而不同网关的包裹层级不一样 +# (有的 content 是字符串、有的是 parts 数组),故对整个响应 JSON 做一次正则, +# 不去猜层级 —— 猜错的代价是"调用成功、费用已产生、但我们说没图"。 +_DATA_URI = re.compile(r"data:image/[^;]+;base64,([A-Za-z0-9+/=]{100,})") + + class SufyImageProvider(ImageProvider): - """图像 provider(gemini-flash-image)。逐帧图生图路线(hit/idle)待开发。 + """文生图 / 图生图 provider(OpenAI 兼容的 ``/chat/completions`` 面)。 + + 调用形状与 i2v 那两个 provider 完全不同:图像走 chat 接口、参考图以 data URI 塞进 + ``content`` 数组,没有提交-轮询-下载三段式。 - 见 #53 / PerFrameStrategy:per-frame 路线不在"视频优先"首个竖线内,此处留真接口、 - 未接 HTTP,避免 ship 一个假装能跑的桩。walk 主链不经此 provider。 + 2026-08-10 修:此前 ``gen_image`` 直接抛 NotImplementedError,而 + ``POST /generation/image`` 端点是可达的、``ImageTaskExecutor`` 又默认实例化本类 —— + 于是每个图像任务都稳定走到 FAILED。端点看着可用、实际必失败,正是本仓最忌讳的形态 + (机器审逮到)。实现取自管线仓已跑通的通路(同日用它出过三张角色母版)。 """ - def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: - raise NotImplementedError( - "逐帧图生图 provider 待开发(见 #53 / PerFrameStrategy);walk 视频主链不用它" + def __init__( + self, + config: AIProviderSettings = settings, + model: str = DEFAULT_IMAGE_MODEL, + ) -> None: + self._cfg = config + self._model = model + + def _client(self) -> httpx.Client: + return httpx.Client( + base_url=self._cfg.normalized_base_url, + headers={"Authorization": f"Bearer {self._cfg.api_key}"}, + timeout=self._cfg.timeout, + # retries 只覆盖建连阶段的失败(SSL 握手、连接被重置)。本机走代理时这类抖动 + # 常见,已跑通的管线实现正是靠一层网络重试扛住的;不加会在人家能恢复的地方 + # 放弃。它不重试读超时与 5xx —— 那两种请求可能已达上游,重发会重复计费。 + transport=httpx.HTTPTransport(retries=_CONNECT_RETRIES), ) + + def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: + """提示词 + 参考图 → 一张 PNG bytes。拿不到有效图就抛,不返回空 bytes。 + + 为什么不返回空 bytes 兜底:上游 ``ImageTaskExecutor`` 会把返回值直接上传对象存储 + 并写进任务结果,一个 0 字节的"成功"会变成用户看到的一张裂图。 + """ + content: list[dict] = [{"type": "text", "text": prompt}] + for raw in refs: + b64 = base64.b64encode(raw).decode() + content.append({ + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{b64}"}, + }) + body = {"model": self._model, "messages": [{"role": "user", "content": content}]} + + last = "" + with self._client() as client: + for attempt in range(1, _IMAGE_TRIES + 1): + payload = client.post( + "/chat/completions", json=body, + ).raise_for_status().json() + found = _DATA_URI.search(json.dumps(payload)) + if found: + data = base64.b64decode(found.group(1)) + if len(data) >= _MIN_IMAGE_BYTES: + return data + last = f"图只有 {len(data)} 字节(下限 {_MIN_IMAGE_BYTES})" + else: + last = "响应里没有 data URI" + logger.warning("文生图第 %d/%d 次没拿到有效图:%s", attempt, _IMAGE_TRIES, last) + raise RuntimeError(f"文生图 {_IMAGE_TRIES} 次均未取得有效图:{last}") diff --git a/backend/tests/test_sufy_video_download.py b/backend/tests/test_sufy_video_download.py index 25e4ec85..9d43b360 100644 --- a/backend/tests/test_sufy_video_download.py +++ b/backend/tests/test_sufy_video_download.py @@ -206,3 +206,125 @@ def handler(request: httpx.Request) -> httpx.Response: with _authed_client(handler) as client: with pytest.raises(UnsafeDownloadUrlError, match="http"): _download(client, url) + + +# ── 文生图 provider(2026-08-10 实现;此前 gen_image 必抛错而端点可达)────────── + + +def _img_payload(b64: str) -> dict: + """模型把图放在 message.content 里,不同网关包裹层级不同。""" + return {"choices": [{"message": {"content": f"data:image/png;base64,{b64}"}}]} + + +def _big_b64(n: int = 6000) -> str: + import base64 + return base64.b64encode(b"\x89PNG" + b"\x00" * n).decode() + + +def _image_provider(handler): + import httpx + + from windup_framework.config.provider import AIProviderSettings + from windup_framework.providers.sufy import SufyImageProvider + + p = SufyImageProvider( + config=AIProviderSettings(base_url="https://gw.example.com/v1", api_key="k"), + ) + client = httpx.Client( + base_url="https://gw.example.com/v1", + headers={"Authorization": "Bearer k"}, + transport=httpx.MockTransport(handler), + ) + p._client = lambda: client + return p + + +def test_gen_image_returns_the_decoded_png(): + """端点可达而 provider 必抛错 = 每个图像任务稳定 FAILED。实现后必须真能出图。""" + def h(request): + import httpx + return httpx.Response(200, json=_img_payload(_big_b64())) + + data = _image_provider(h).gen_image("a knight", []) + assert data.startswith(b"\x89PNG") and len(data) > 5000 + + +def test_reference_images_are_sent_as_data_uris(): + """参考图走 content 数组里的 image_url,不是 multipart、不是单独字段。""" + import json as _json + + seen: dict = {} + + def h(request): + import httpx + seen["body"] = _json.loads(request.content) + return httpx.Response(200, json=_img_payload(_big_b64())) + + _image_provider(h).gen_image("x", [b"\x89PNGref"]) + content = seen["body"]["messages"][0]["content"] + kinds = [c["type"] for c in content] + assert kinds == ["text", "image_url"] + assert content[1]["image_url"]["url"].startswith("data:image/png;base64,") + + +def test_response_without_an_image_is_retried_then_raises(): + """模型偶发返回一条不含图的正常响应。重试后仍拿不到必须抛,不能返回空 bytes—— + 上游会把返回值直接上传对象存储并写进任务结果,0 字节的"成功"就是用户看到的裂图。""" + import pytest + + calls = {"n": 0} + + def h(request): + import httpx + calls["n"] += 1 + return httpx.Response(200, json={"choices": [{"message": {"content": "抱歉"}}]}) + + with pytest.raises(RuntimeError, match="未取得有效图"): + _image_provider(h).gen_image("x", []) + assert calls["n"] == 3, "应重试到上限而不是一次就放弃" + + +def test_undersized_image_is_rejected_not_returned(): + """响应里可能带一个几十字节的占位串,当图存下去就是打不开的文件。""" + import base64 + + import pytest + + tiny = base64.b64encode(b"\x89PNG" + b"\x00" * 200).decode() + + def h(request): + import httpx + return httpx.Response(200, json=_img_payload(tiny)) + + with pytest.raises(RuntimeError, match="字节"): + _image_provider(h).gen_image("x", []) + + +def test_first_successful_attempt_stops_retrying(): + calls = {"n": 0} + + def h(request): + import httpx + calls["n"] += 1 + if calls["n"] == 1: + return httpx.Response(200, json={"choices": [{"message": {"content": "空"}}]}) + return httpx.Response(200, json=_img_payload(_big_b64())) + + assert _image_provider(h).gen_image("x", []) + assert calls["n"] == 2 + + +def test_image_client_retries_connection_failures(): + """本机走代理时建连抖动常见;已跑通的管线实现靠一层网络重试扛住。 + + 只断言"配了连接重试"这个结构 —— 真去模拟 SSL 握手失败需要一个假 TCP 端点, + 那验的是 httpx 而不是我们的代码。 + """ + from windup_framework.providers.sufy import _CONNECT_RETRIES, SufyImageProvider + + assert _CONNECT_RETRIES >= 1 + client = SufyImageProvider()._client() + try: + assert client._transport._pool._retries == _CONNECT_RETRIES + finally: + client.close() From 1edbeb08a5ea7c829a62f5ce2c55112ea4869a4d Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 23:47:27 +0800 Subject: [PATCH 08/33] =?UTF-8?q?fix(providers):=20=E6=96=87=E7=94=9F?= =?UTF-8?q?=E5=9B=BE=E8=B5=B0=E9=85=8D=E7=BD=AE=E9=87=8C=E7=9A=84=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=EF=BC=8C=E5=B9=B6=E6=8A=8A"=E7=BD=91=E5=85=B3?= =?UTF-8?q?=E6=B2=A1=E8=BF=99=E4=B8=AA=E6=A8=A1=E5=9E=8B"=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E6=88=90=E8=83=BD=E7=85=A7=E7=9D=80=E4=BF=AE=E7=9A=84?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对抗复查自己今天这笔实现时发现的两处: 一、路径此前硬编码 "/chat/completions",而 AIProviderSettings.chat_completions_path 本来就在配置里、零消费方 —— 正是本轮在删的那类字段。改成读配置。 二、更要紧:同一把 key 下不同网关的模型目录**不一样**。实测 GET /v1/models: 一个网关 73 个模型、一个图像模型都没有;另一个 134 个、含本模块的默认模型 (2026-08-10 实测)。配错 AI_BASE_URL 时原始报错只是一条裸 404,读的人无从判断 该改配置还是改模型名。现在 400/404 一律翻译成指向 GET {base}/models 的错误。 这条修的是"错误信息不可操作",不是"配置错误本身"——后者要在部署侧确认网关目录里 确实有所用模型,代码管不了。 测试 +3(路径来自配置、400/404 给出目录提示)。变异测试:路径写死 1 条红、去掉错误 翻译 2 条红。 --- .../src/windup_framework/providers/sufy.py | 22 ++++++++++++-- backend/tests/test_sufy_video_download.py | 29 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py index 579dd006..a18063b7 100644 --- a/backend/packages/framework/src/windup_framework/providers/sufy.py +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -689,6 +689,24 @@ def _client(self) -> httpx.Client: transport=httpx.HTTPTransport(retries=_CONNECT_RETRIES), ) + def _post(self, client: httpx.Client, body: dict) -> dict: + """发一次请求。把"网关没有这个模型"翻译成能照着修的错误。 + + 为什么值得专门处理:同一把 key 下不同网关的模型目录**不一样**。实测 + ``GET /v1/models``:一个网关 73 个模型、一个图像模型都没有;另一个 134 个、 + 含本模块默认的那个(2026-08-10)。配错 ``AI_BASE_URL`` 时原始报错只是一条 + 404,读的人无从知道该去改配置还是改模型名。 + """ + resp = client.post(self._cfg.chat_completions_path, json=body) + if resp.status_code in (400, 404): + raise RuntimeError( + f"网关 {self._cfg.normalized_base_url} 拒绝了模型 {self._model!r}" + f"(HTTP {resp.status_code})。先确认该网关的目录里有它:" + f"GET {self._cfg.normalized_base_url}/models —— 不同网关目录不同," + f"同一把 key 也是。原始响应:{resp.text[:200]}" + ) + return resp.raise_for_status().json() + def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: """提示词 + 参考图 → 一张 PNG bytes。拿不到有效图就抛,不返回空 bytes。 @@ -707,9 +725,7 @@ def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: last = "" with self._client() as client: for attempt in range(1, _IMAGE_TRIES + 1): - payload = client.post( - "/chat/completions", json=body, - ).raise_for_status().json() + payload = self._post(client, body) found = _DATA_URI.search(json.dumps(payload)) if found: data = base64.b64decode(found.group(1)) diff --git a/backend/tests/test_sufy_video_download.py b/backend/tests/test_sufy_video_download.py index 9d43b360..820c179d 100644 --- a/backend/tests/test_sufy_video_download.py +++ b/backend/tests/test_sufy_video_download.py @@ -328,3 +328,32 @@ def test_image_client_retries_connection_failures(): assert client._transport._pool._retries == _CONNECT_RETRIES finally: client.close() + + +def test_request_path_comes_from_config_not_a_literal(): + """路径用配置里的 chat_completions_path —— 它此前零消费方,正是今天在删的那类字段。""" + + seen: dict = {} + + def h(request): + import httpx + seen["path"] = request.url.path + return httpx.Response(200, json=_img_payload(_big_b64())) + + p = _image_provider(h) + p._cfg = p._cfg.model_copy(update={"chat_completions_path": "/v9/custom-chat"}) + p.gen_image("x", []) + assert seen["path"].endswith("/v9/custom-chat"), seen["path"] + + +@pytest.mark.parametrize("code", [400, 404]) +def test_model_missing_from_the_gateway_catalogue_says_so(code): + """同一把 key 下不同网关的模型目录不一样(实测:一个 73 个模型零图像模型、 + 另一个 134 个含默认模型)。配错 AI_BASE_URL 时错误必须指向配置,不能只是裸 404。 + """ + def h(request): + import httpx + return httpx.Response(code, text='{"error":{"message":"model not found"}}') + + with pytest.raises(RuntimeError, match=r"/models"): + _image_provider(h).gen_image("x", []) From 1667219d20b60567e00bfa30dfadb5818c6291bd Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 11 Aug 2026 11:01:44 +0800 Subject: [PATCH 09/33] =?UTF-8?q?fix(providers):=20=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E8=A1=A5=E5=B0=81=E9=97=AD=E7=A9=BA=E6=B4=9E=EF=BC=8C=E4=BD=86?= =?UTF-8?q?=E8=85=BF=E9=97=B4=E7=A9=BA=E9=9A=99=E5=BF=85=E9=A1=BB=E6=8C=89?= =?UTF-8?q?=E9=A2=9C=E8=89=B2=E8=B1=81=E5=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 放大看交付帧,主体内部有透明洞,背景直接透出来。2026-08-11 在归档角色 「林间斥候」走路的 121 帧真实视频帧(1280×720)上把成因拆开量了一遍: - u2netp 自己在主体内部造的洞:8 帧抽样里 6 帧为 0 —— 不是主要成因; - 真正的成因是键控误杀:_flat_bg_penalty 每帧杀掉 820~2346 个 u2netp 判为 主体的像素。角色浅肤色 (243,221,200) 到母版灰底 (219,219,220) 的欧氏距离 只有 31.3,窄于 _KEY_KILL=38,于是大腿、小臂这些浅色皮肤被当底色抠掉。 这些被误杀的像素被主体围住,就是「封闭空洞」,填回去即修复。 **只按「不与画面边界连通」判定会把两腿之间填实。** 直觉上腿间空隙从下方通到 画幅底边所以天然安全,实测不成立:迈步相里两只靴子在下方交叠,把空隙彻底封死。 121 帧里 80 帧存在这种封闭的底色空隙,共 25173 像素;只判连通性的朴素版把这 25173 像素**全部**填成主体(最惨单帧 src_024 填掉 3172 像素,两条腿焊在一起, 截图见验证记录)。归档的 04_走路_原画帧/frame_03 同样有 129 像素的封闭腿间空隙。 所以判据是连通性 + 颜色两条一起:一个透明连通域只要「碰到画幅边界」或者 「里面存在任何一个确实是底色的像素」,就不是洞。两条否决合成一次扩散,种子 = 边界上的透明像素 ∪ 底色像素。实测结果: - 25173 个真空隙像素,守卫版填掉 0 个,朴素版填掉 25173 个; - 121 帧合计填回 51273 个被误杀的主体像素(朴素版 82118,多出来的就是空隙); - alpha 只增不减,改动值只能是 1.0,RGB 通道不碰 —— 没有洞的帧逐像素不变。 _HOLE_BG_TOL=14 的取值有实测依据:视频帧里纯背景区域的色距 p99.9≈6.5、 最大 11.1(压缩噪点),而被误杀的浅肤色连通域中位色距 ≥17.1,14 落在这条间隙里。 扩散不用逐像素 BFS:1280×720 约 92 万像素,纯 Python BFS 要几十秒,抠图是逐帧 调用的扛不住。改成按行/列游程传播,一个 pass 推过整条游程。实测填洞单独耗时 34ms,cutout 端到端 0.44s/帧。scipy 不在依赖里,没有为此新增依赖。 顺手把四角估底色抽成 _bg_key(),让「底色是什么」只有一个真相源 —— 键控清理和 填洞必须按同一个 key 判,否则一个把某块当背景清掉、另一个又把它当主体填回来。 变异测试(9 个变异逐个改坏实现 → 确认对应用例变红 → 还原,全部被杀): M1 去掉颜色守卫(种子只剩边界,即朴素设计)→ closed_leg_gap 红 M2 去掉边界种子 → border_touching 红 M3/M4 _spread 只做行传播 / 只做列传播 → spread_is_four_connected 红 M5 去掉「底不是纯色就停手」的早退 → non_flat_background 红 M6 填成 0.5 而不是 1.0 → enclosed_hole_is_filled 红 M7 _HOLE_BG_TOL 放大到 200 → enclosed_hole_is_filled 红 M8 _HOLE_BG_TOL 归零 → closed_leg_gap 红 M9 丢掉「封闭」条件 → closed_leg_gap 等 4 条红 另外 _spread 与逐像素 BFS 在 300 组随机掩码 + 螺旋形上逐点等价(用例里留了 25 组)。 CI: ruff / lint-imports(2 contracts kept) / pytest 185 passed 全过。 Co-Authored-By: Claude Opus 5 --- .../src/windup_framework/providers/matte.py | 99 +++++++- backend/tests/test_matte_provider.py | 218 ++++++++++++++++++ 2 files changed, 310 insertions(+), 7 deletions(-) diff --git a/backend/packages/framework/src/windup_framework/providers/matte.py b/backend/packages/framework/src/windup_framework/providers/matte.py index 7105ba56..ffc28866 100644 --- a/backend/packages/framework/src/windup_framework/providers/matte.py +++ b/backend/packages/framework/src/windup_framework/providers/matte.py @@ -37,6 +37,93 @@ _KEY_SOFT = 14.0 # 到 _KEY_KILL + _KEY_SOFT 之间线性过渡,避免硬边锯齿 _BG_FLAT_STD = 8.0 # 四角色标准差上限;超过说明底不是纯色,不做任何清理 +# 空洞填充用。_HOLE_ALPHA:低于此 alpha 才算"透明",参与空洞判定。 +# _HOLE_BG_TOL:到底色的距离低于此值 → 判为"确实是底色"。取值依据(2026-08-11 实测, +# 1280×720 真实视频帧):纯背景区域的色距 p99.9≈6.5、最大 11.1(视频压缩噪点); +# 而被误杀的浅肤色像素连通域中位色距 ≥17.1。14 落在这条 1.5 倍间隙里。 +_HOLE_ALPHA = 0.03 +_HOLE_BG_TOL = 14.0 + + +def _bg_key(rgb: np.ndarray) -> np.ndarray | None: + """四角取样估底色 key;底不够均匀(std 超阈值)时返回 None = 不做任何基于底色的判断。 + + 抽成独立函数是为了让"底色是什么"只有一个真相源 —— 键控清理(``_flat_bg_penalty``) + 和空洞填充(``_fill_enclosed_holes``)必须按同一个 key 判断,否则一个把某块当背景 + 清掉、另一个又把它当主体填回来,互相打架。 + """ + corners = np.concatenate([ + rgb[:12, :12].reshape(-1, 3), rgb[:12, -12:].reshape(-1, 3), + rgb[-12:, :12].reshape(-1, 3), rgb[-12:, -12:].reshape(-1, 3), + ]) + if float(corners.std(axis=0).max()) > _BG_FLAT_STD: + return None + return np.median(corners, axis=0).astype(np.float32) + + +def _spread(seed: np.ndarray, region: np.ndarray) -> np.ndarray: + """在 ``region`` 内从 ``seed`` 出发做 4-邻接连通扩散,返回可达集合。 + + 为什么不写逐像素 BFS:交付前的帧是 1280×720(约 92 万像素),纯 Python BFS 要几十秒, + 抠图是逐帧调用的,扛不住。这里按**行/列游程**传播 —— 一个 pass 就能把可达性推过 + 整条连续游程(距离不限),而不是每 pass 只推进一个像素,真实角色轮廓几个 pass 收敛。 + + 同一行里被非 region 像素隔断的两段游程,``cumsum(~region)`` 必然取到不同的 id, + 因此可以用 ``bincount`` 一次算出"每条游程里有没有种子"。 + """ + reach = seed & region + while True: + before = int(reach.sum()) + for transposed in (False, True): + reg = region.T if transposed else region + rch = reach.T if transposed else reach + rows, cols = reg.shape + run = np.cumsum(~reg, axis=1) + keys = run + np.arange(rows)[:, None] * (cols + 1) + hit = np.bincount(keys[rch], minlength=rows * (cols + 1)) > 0 + new = reg & hit[keys] + reach = new.T if transposed else new + if int(reach.sum()) == before: + return reach + + +def _fill_enclosed_holes(alpha: np.ndarray, rgb: np.ndarray) -> np.ndarray: + """把"被主体围住、且整块都不是底色"的透明连通域填回主体(alpha=1)。 + + 要解决的问题:u2netp 判错或键控误杀会在主体内部留下透明洞,放大看是背景直接透出来。 + + **为什么只判"不与边界连通"不够 —— 会把两腿之间填实。** 直觉上腿间空隙从下方通到 + 画面底边,所以"从边界出发的连通域"就能保护它。2026-08-11 在真实走路帧上实测: + **不成立**。迈步相里两只靴子在下方交叠,把腿间空隙彻底封死 —— 它就是一块不与边界 + 连通的背景域(实测 src_017 有 530 像素、归档 frame_03 有 129 像素),只按连通性判, + 这一整块会被填成主体,两条腿直接焊在一起。 + + 所以判据是**连通性 + 颜色**两条一起:一个透明连通域只要"碰到画面边界"或者"里面 + 存在任何一个确实是底色的像素",就不是洞。腿间空隙整块就是底色(实测中位色距 6.2, + 远低于 _HOLE_BG_TOL),必然被这条否决;而被误杀的主体像素(实测中位色距 ≥17.1) + 不含底色像素,才会被填。两条否决合成一次扩散:种子 = 边界上的透明像素 ∪ 底色像素。 + + 与 ``_flat_bg_penalty`` 的分工:那个函数按颜色**做减法**(把闭合空隙里的底色清掉), + 这个函数按颜色**决定不加回来** —— 同一个 key、同一个方向,不会互相拆台。 + """ + key = _bg_key(rgb) + if key is None: + return alpha # 底不是纯色 → 无从判断哪块是真空隙,一律不填 + transparent = alpha < _HOLE_ALPHA + if not transparent.any(): + return alpha + border = np.zeros_like(transparent) + border[0, :] = border[-1, :] = True + border[:, 0] = border[:, -1] = True + is_bg_color = np.linalg.norm(rgb - key, axis=2) < _HOLE_BG_TOL + seed = transparent & (border | is_bg_color) + holes = transparent & ~_spread(seed, transparent) + if not holes.any(): + return alpha + out = alpha.copy() + out[holes] = 1.0 + return out + def _flat_bg_penalty(rgb: np.ndarray) -> np.ndarray: """底色清理系数(0=纯背景,1=主体),形状与图同宽高。 @@ -51,13 +138,9 @@ def _flat_bg_penalty(rgb: np.ndarray) -> np.ndarray: 最坏情况是少清理一点,不会抠穿角色。底色不够均匀时(std 超阈值)直接返回全 1, 等于不清理。 """ - corners = np.concatenate([ - rgb[:12, :12].reshape(-1, 3), rgb[:12, -12:].reshape(-1, 3), - rgb[-12:, :12].reshape(-1, 3), rgb[-12:, -12:].reshape(-1, 3), - ]) - if float(corners.std(axis=0).max()) > _BG_FLAT_STD: + key = _bg_key(rgb) + if key is None: return np.ones(rgb.shape[:2], dtype=np.float32) # 底不是纯色 → 不动 - key = np.median(corners, axis=0).astype(np.float32) d = np.linalg.norm(rgb - key, axis=2) return np.clip((d - _KEY_KILL) / _KEY_SOFT, 0.0, 1.0).astype(np.float32) @@ -112,8 +195,10 @@ def _predict_mask(self, img: Image.Image) -> Image.Image: def cutout(self, frame: bytes) -> bytes: img = Image.open(io.BytesIO(frame)).convert("RGBA") + rgb = np.asarray(img.convert("RGB"), dtype=np.float32) alpha = np.asarray(self._predict_mask(img), dtype=np.float32) / 255.0 - alpha = alpha * _flat_bg_penalty(np.asarray(img.convert("RGB"), dtype=np.float32)) + alpha = alpha * _flat_bg_penalty(rgb) + alpha = _fill_enclosed_holes(alpha, rgb) out = np.dstack([np.asarray(img.convert("RGB")), alpha * 255.0]).astype(np.uint8) buf = io.BytesIO() Image.fromarray(out, "RGBA").save(buf, "PNG") diff --git a/backend/tests/test_matte_provider.py b/backend/tests/test_matte_provider.py index 4647f2bb..3e507759 100644 --- a/backend/tests/test_matte_provider.py +++ b/backend/tests/test_matte_provider.py @@ -94,3 +94,221 @@ def blocked(name, *a, **k): OnnxU2NetMatteProvider()._get_session() finally: builtins.__import__ = real + + +# ── 封闭空洞填充(2026-08-11 在 121 帧真实走路视频帧上实测挣得)────────────────── +# +# 背景:交付帧放大看,主体内部会有透明洞(背景直接透出来)。实测拆开成因: +# · u2netp 自身在主体内部造的洞:8 帧抽样里 6 帧为 0 —— 不是主要成因; +# · 键控误杀:_flat_bg_penalty 每帧杀掉 820~2346 个 u2netp 判为主体的像素 —— +# 浅肤色 (243,221,200) 到灰底 (219,219,220) 的欧氏距离只有 31.3,窄于 _KEY_KILL=38。 +# 这些被误杀的像素被主体围住,正是"封闭空洞",填回来即修复。 +# +# 但**只按"不与画面边界连通"判定会把两腿之间填实**:迈步相里两只靴子在下方交叠, +# 把腿间空隙彻底封死。实测 121 帧中 80 帧存在这种封闭的底色空隙,共 25173 像素; +# 朴素版(只判连通性)把这 25173 像素全部填成主体(最惨单帧 3172 像素,两腿焊死), +# 加了颜色守卫后填掉 0 像素。下面的用例把这条守住。 + + +def _walk_frame(*, gap_closed: bool, hole: bool = False, eroded_leg: bool = False): + """造一帧"迈步相":灰底 + 躯干 + 两条腿 + 腿间底色空隙。 + + ``gap_closed=True`` 时靴子在下方交叠、把腿间空隙封死(实测 80/121 帧是这形状)。 + 颜色取实测值:底 (219,219,220)、浅肤 (243,221,200)(两者距离 31.3,窄于 _KEY_KILL)。 + 返回 (rgb float32, alpha float32)。 + """ + import numpy as np + + bg, skin, cloth = (219, 219, 220), (243, 221, 200), (110, 130, 100) + h, w = 64, 64 + rgb = np.full((h, w, 3), bg, dtype=np.float32) + alpha = np.zeros((h, w), dtype=np.float32) + + def paint(y0, y1, x0, x1, color): + rgb[y0:y1, x0:x1] = color + alpha[y0:y1, x0:x1] = 1.0 + + paint(8, 32, 20, 44, cloth) # 躯干 + paint(32, 52, 20, 28, skin) # 后腿 + paint(32, 52, 36, 44, skin) # 前腿 + if gap_closed: + paint(52, 58, 20, 44, cloth) # 靴子交叠 → 腿间空隙被封死 + else: + paint(52, 58, 20, 28, cloth) # 两只靴子分开 → 空隙通到画面底边 + paint(52, 58, 36, 44, cloth) + if hole: + alpha[14:20, 28:36] = 0.0 # 躯干内部的洞:颜色还是衣服色 + if eroded_leg: + alpha[36:46, 22:26] = 0.0 # 腿内部被键控误杀的一条:颜色是浅肤色 + return rgb, alpha + + +def _gap_slice(): + """腿间空隙区域(rgb 一直是底色,alpha 一直应为 0)。""" + return (slice(32, 52), slice(28, 36)) + + +def test_enclosed_hole_in_subject_is_filled(): + """被主体围住、颜色不是底色的透明块 = 洞,填成主体。""" + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=True, hole=True) + out = _fill_enclosed_holes(alpha, rgb) + assert (out[14:20, 28:36] == 1.0).all(), "躯干内部的洞必须被填成主体" + + +def test_keyed_out_skin_inside_leg_is_filled(): + """被 _flat_bg_penalty 误杀的浅肤色(实测每帧 820~2346 px)要能填回来。""" + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=True, eroded_leg=True) + out = _fill_enclosed_holes(alpha, rgb) + assert (out[36:46, 22:26] == 1.0).all(), "浅肤色距底色 31.3,不是底色,必须填回主体" + + +def test_closed_leg_gap_is_never_filled(): + """**核心回归**:靴子交叠把腿间空隙封死时,它照样不能被填 —— 否则两腿焊在一起。 + + 实测:只判"不与边界连通"的朴素版在这里会把整块空隙填掉(121 帧共 25173 px)。 + """ + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=True) + ys, xs = _gap_slice() + assert (alpha[ys, xs] == 0.0).all(), "前提:空隙本来是透明的" + out = _fill_enclosed_holes(alpha, rgb) + assert (out[ys, xs] == 0.0).all(), "腿间空隙整块是底色,一个像素都不能填" + + +def test_open_leg_gap_is_never_filled(): + """空隙通到画面底边时同样不能填 —— 这条也钉死"绝不能按行/按列填"。 + + 按行填会看到"这一行左右都是主体"就把中间填上,正是这里要拦的。 + """ + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=False) + ys, xs = _gap_slice() + out = _fill_enclosed_holes(alpha, rgb) + assert (out[ys, xs] == 0.0).all(), "与边界连通的空隙不是洞" + assert (out == alpha).all(), "没有洞的帧必须逐像素不变" + + +def test_border_touching_transparent_area_is_never_filled(): + """贴着画幅边缘的透明区域不是洞 —— 哪怕它的颜色一点也不像底色。 + + 真实场景:i2v 出的帧经常把角色下半身裁出画,两腿之间是一条暗投影(不是干净底色), + 这条投影只从画幅下沿通向画外,左右被两条腿封死。只靠"颜色像不像底色"判断会把 + 它当成洞、填成主体(两腿又焊上了),所以"从边界出发"这条种子必须保留。 + """ + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=False) + for x0, x1 in ((20, 28), (36, 44)): # 两条腿一直延到画幅下沿 + rgb[52:64, x0:x1] = (110, 130, 100) + alpha[52:64, x0:x1] = 1.0 + rgb[32:64, 28:36] = (60, 55, 50) # 腿间暗投影:远离底色 + alpha[32:64, 28:36] = 0.0 # 只从下沿通向画外,左右被腿封死 + + out = _fill_enclosed_holes(alpha, rgb) + assert (out[32:64, 28:36] == 0.0).all(), "连到画幅边界的透明区域一律不是洞" + + +def test_frame_without_holes_is_pixel_identical(): + """没有洞 → 逐像素不变(防回归硬指标)。""" + import numpy as np + + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=True) + out = _fill_enclosed_holes(alpha, rgb) + assert np.array_equal(out, alpha) + + +def test_fill_only_adds_alpha_never_removes(): + """只做加法:alpha 绝不被改小,改动值只能是 1.0 —— 填洞不该顺手抠掉别的。""" + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=True, hole=True, eroded_leg=True) + out = _fill_enclosed_holes(alpha, rgb) + assert (out >= alpha).all() + assert (out[out != alpha] == 1.0).all() + + +def test_non_flat_background_disables_fill_entirely(): + """底色不均匀 → 无从判断哪块是真空隙,一律不填(与键控清理同一条纪律)。""" + import numpy as np + + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=True, hole=True) + rng = np.random.default_rng(0) + noisy = rng.uniform(0, 255, rgb.shape).astype(np.float32) + out = _fill_enclosed_holes(alpha, noisy) + assert np.array_equal(out, alpha), "底不是纯色时必须整帧不动" + + +def test_bg_key_returns_none_when_background_is_not_flat(): + """底色真相源:不均匀时返回 None,键控与填洞都据此停手。""" + import numpy as np + + from windup_framework.providers.matte import _bg_key + + rng = np.random.default_rng(1) + assert _bg_key(rng.uniform(0, 255, (40, 40, 3)).astype(np.float32)) is None + assert _bg_key(np.full((40, 40, 3), 219, dtype=np.float32)) is not None + + +def test_spread_is_four_connected_not_scanline(): + """扩散必须是真 4-邻接连通:L 形走廊要能拐弯走通,断开的孤岛不能被沾到。 + + 只做行传播(或只做列传播)都会让 L 形的另一条臂走不通,这条用例把两个方向都钉死。 + """ + import numpy as np + + from windup_framework.providers.matte import _spread + + region = np.zeros((20, 20), dtype=bool) + region[2, 2:18] = True # 横臂 + region[2:18, 17] = True # 竖臂(拐弯) + island = (15, 3) + region[island] = True # 孤岛:与走廊不连通 + seed = np.zeros_like(region) + seed[2, 2] = True + + reach = _spread(seed, region) + assert reach[2, 17], "横臂尽头要走通(需要行传播)" + assert reach[17, 17], "竖臂尽头要走通(需要列传播)" + assert not reach[island], "不连通的孤岛绝不能被标记为可达" + + +def test_spread_matches_bruteforce_bfs_on_random_masks(): + """与逐像素 BFS 逐点等价 —— 向量化只是为了快,不能改语义。""" + from collections import deque + + import numpy as np + + from windup_framework.providers.matte import _spread + + def bfs(seed, region): + h, w = region.shape + out = np.zeros_like(region) + q = deque() + for y, x in zip(*np.nonzero(seed & region), strict=True): + out[y, x] = True + q.append((y, x)) + while q: + cy, cx = q.popleft() + for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): + ny, nx = cy + dy, cx + dx + if 0 <= ny < h and 0 <= nx < w and region[ny, nx] and not out[ny, nx]: + out[ny, nx] = True + q.append((ny, nx)) + return out + + rng = np.random.default_rng(7) + for _ in range(25): + h, w = int(rng.integers(3, 30)), int(rng.integers(3, 30)) + region = rng.random((h, w)) < rng.uniform(0.3, 0.9) + seed = rng.random((h, w)) < 0.05 + assert (_spread(seed, region) == bfs(seed, region)).all() From 7245818a66cf257d86854c32ffadc1263206a383 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 11 Aug 2026 15:48:47 +0800 Subject: [PATCH 10/33] =?UTF-8?q?refactor(providers):=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=9E=8B=E5=8F=B7=E8=BF=9B=E9=85=8D=E7=BD=AE=EF=BC=8C=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=BD=A2=E7=8A=B6=E7=95=99=E5=9C=A8=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=87=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 人工评审指出 providers 层硬编码过多。拆开看是三类,处理方式不同: **改进配置**(本次做的):三条能力各自的模型型号。 `AIProviderSettings` 加 `video_model` / `image_model` / `fal_video_model`,默认值即当前 实测在用的型号,部署侧可用 AI_VIDEO_MODEL / AI_IMAGE_MODEL / AI_FAL_VIDEO_MODEL 覆盖。 分成三个字段而不是共用已有的 `model`:三条能力同时在用不同模型,共用一个意味着换其中 一条把另外两条也换了。显式传参仍优先于配置,方便 A/B 对比时不必改环境变量。 **留在代码里**(本次不做,理由写进配置类的注释):哪个模型吃 image_list、哪个吃 input_reference、FAL 队列路径长什么样。这些不是运行参数,是该模型的 API 形状事实,改变 的是请求怎么构造。放进配置会把"填错了会怎样"从部署期推到运行期 —— 字段塞错不会立刻 报错,任务照常 queued,直到生成阶段才 failed,而费用可能已经产生(2026-07-29 实测)。 **暂不处理**:重试次数与字节下限。可配置化,但现在提出去只增加配置面,等真要调再说。 顺带修一个这批测试逮到的真 bug:`FalQueueVideoProvider` 的构造期校验发生在型号解析 **之前**,于是 `model=None`(表示"用配置里的")会被直接拿去查端点表,报"模型 None 不在 表里"—— 走默认路径就构造失败。改成先解析型号再校验。 测试 +5,5 条变异全部杀掉(共用一个字段 / 忽略配置写回硬编码 / 显式传参被配置覆盖 / 配置里补上请求形状字段 / 校验挪回解析之前)。 --- .../src/windup_framework/config/provider.py | 16 ++++- .../src/windup_framework/providers/sufy.py | 18 +++--- backend/tests/test_sufy_video_download.py | 61 +++++++++++++++++++ 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/backend/packages/framework/src/windup_framework/config/provider.py b/backend/packages/framework/src/windup_framework/config/provider.py index 57182ce5..b171f2c4 100644 --- a/backend/packages/framework/src/windup_framework/config/provider.py +++ b/backend/packages/framework/src/windup_framework/config/provider.py @@ -16,11 +16,25 @@ class AIProviderSettings(BaseSettings): provider: str = "openai-compatible" base_url: str = "https://api.openai.com/v1" api_key: str = "" - model: str = "" + model: str = "" # 通用兜底(chat 类调用),下面三个各自专用 timeout: float = 120.0 max_retries: int = 2 chat_completions_path: str = "/chat/completions" + # ── 各能力用哪个模型 ────────────────────────────────────────────────── + # 分成三个字段而不是共用上面那个 ``model``:三条能力同时在用不同模型,共用一个 + # 字段意味着换其中一个就把另外两个也换了。默认值即当前实测在用的型号, + # 部署侧可用 AI_VIDEO_MODEL / AI_IMAGE_MODEL / AI_FAL_VIDEO_MODEL 覆盖。 + # + # **只有型号可配,请求形状不可配**:哪个模型吃 image_list、哪个吃 + # input_reference、FAL 队列路径长什么样,都是该模型的 API 事实而非运行参数, + # 写在 providers.sufy 的映射表里。放进配置会把"填错了会怎样"从部署期推到 + # 运行期 —— 字段塞错不会立刻报错,任务照常 queued,直到生成阶段才 failed, + # 而费用可能已经产生(2026-07-29 实测)。 + video_model: str = "kling-v2-5-turbo" + image_model: str = "gemini-2.5-flash-image" + fal_video_model: str = "kling-v2-5-turbo" + @property def normalized_base_url(self) -> str: return self.base_url.rstrip("/") diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py index a18063b7..7c289e1c 100644 --- a/backend/packages/framework/src/windup_framework/providers/sufy.py +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -81,13 +81,13 @@ class SufyVideoProvider(VideoProvider): def __init__( self, config: AIProviderSettings = settings, - model: str = DEFAULT_VIDEO_MODEL, + model: str | None = None, mode: str = "std", poll_interval: float = 60.0, max_min: int = 30, ) -> None: self._cfg = config - self._model = model + self._model = model or config.video_model self._mode = mode self._poll = poll_interval self._max_min = max_min @@ -512,17 +512,19 @@ def __init__( self, uploader: FirstFrameUploader, config: AIProviderSettings = settings, - model: str = DEFAULT_FAL_VIDEO_MODEL, + model: str | None = None, mode: str = "std", poll_interval: float = 15.0, max_min: int = 30, ) -> None: + # 先把型号定下来再校验:``model=None`` 表示"用配置里的",此时拿 None 去查端点表 + # 会报"模型 None 不在表里",把一个正常的默认路径变成构造期崩溃。 + self._model = model or config.fal_video_model # 构造即校验:未知模型 / 不支持的 mode 在**花钱之前**就炸掉。 - self._path = fal_submit_path(model, mode) - self._endpoint = fal_endpoint(model) + self._path = fal_submit_path(self._model, mode) + self._endpoint = fal_endpoint(self._model) self._uploader = uploader self._cfg = config - self._model = model self._mode = mode self._poll = poll_interval self._max_min = max_min @@ -673,10 +675,10 @@ class SufyImageProvider(ImageProvider): def __init__( self, config: AIProviderSettings = settings, - model: str = DEFAULT_IMAGE_MODEL, + model: str | None = None, ) -> None: self._cfg = config - self._model = model + self._model = model or config.image_model def _client(self) -> httpx.Client: return httpx.Client( diff --git a/backend/tests/test_sufy_video_download.py b/backend/tests/test_sufy_video_download.py index 820c179d..81ef3037 100644 --- a/backend/tests/test_sufy_video_download.py +++ b/backend/tests/test_sufy_video_download.py @@ -357,3 +357,64 @@ def h(request): with pytest.raises(RuntimeError, match=r"/models"): _image_provider(h).gen_image("x", []) + + +# ── 模型型号可配置(2026-08-11 人工评审:providers 层硬编码太多)─────────────── + + +def _cfg(**kw): + from windup_framework.config.provider import AIProviderSettings + + return AIProviderSettings(base_url="https://gw.example.com/v1", api_key="k", **kw) + + +@pytest.mark.parametrize(("cls_name", "field", "value"), [ + ("SufyVideoProvider", "video_model", "kling-v9-test"), + ("FalQueueVideoProvider", "fal_video_model", "veo3.1"), + ("SufyImageProvider", "image_model", "gemini-9-flash-image"), +]) +def test_each_provider_reads_its_own_model_field(cls_name, field, value): + """三条能力同时在用不同模型,所以是三个独立字段而不是共用一个 ``model``。 + + 共用一个的后果是换其中一条把另外两条也换了 —— 这条用例把"各读各的"钉住: + 只设自己那个字段,另外两个保持默认,断言取到的是自己的。 + """ + import windup_framework.providers.sufy as S + + cls = getattr(S, cls_name) + kwargs = {"config": _cfg(**{field: value})} + if cls_name == "FalQueueVideoProvider": + kwargs["uploader"] = _StubUploader() + assert cls(**kwargs)._model == value + + +def test_explicit_model_argument_still_wins_over_config(): + """显式传参优先于配置 —— A/B 对比时不必改环境变量。""" + from windup_framework.providers.sufy import SufyImageProvider + + p = SufyImageProvider(config=_cfg(image_model="from-config"), model="from-arg") + assert p._model == "from-arg" + + +def test_request_shape_is_not_configurable(): + """**只有型号可配,请求形状不可配。** + + 哪个模型吃 image_list、FAL 队列路径长什么样,是该模型的 API 事实而非运行参数。 + 放进配置会把"填错了会怎样"从部署期推到运行期:字段塞错不会立刻报错,任务照常 + queued,直到生成阶段才 failed,而费用可能已经产生(2026-07-29 实测)。 + + 故断言配置类**没有**这类字段 —— 将来有人想加会先撞到这条用例和它的理由。 + """ + from windup_framework.config.provider import AIProviderSettings + + fields = set(AIProviderSettings.model_fields) + for banned in ("image_list_models", "fal_endpoints", "first_frame_field"): + assert banned not in fields, f"{banned} 不该进配置,见本用例 docstring" + assert {"video_model", "image_model", "fal_video_model"} <= fields + + +class _StubUploader: + """FalQueueVideoProvider 的必需构造参数(无默认值,见 FirstFrameUploader)。""" + + def upload(self, frame: bytes, content_type: str) -> str: + return "https://cdn.example.com/first.jpg" From 9d9f943fb7e0b8599961eb81b03cdba2386b2f14 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 11 Aug 2026 17:30:24 +0800 Subject: [PATCH 11/33] =?UTF-8?q?refactor(providers):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E4=BB=8E=E6=9C=AA=E7=9C=9F=E5=AE=9E=E8=B0=83=E7=94=A8=E8=BF=87?= =?UTF-8?q?=E7=9A=84=20FAL=20=E9=98=9F=E5=88=97=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审质疑「为什么还需要一层不该理解业务的东西」(指 FirstFrameUploader)。查证后我认同, 但比他说的更彻底:**整个 FAL 队列面从未被真实调用过** —— app / ai_engine 里零引用, 产品链路走不到它,唯一的"引用"是 interfaces.py 里一句 docstring 指路。 删掉的理由与 GenRoute 只列有实现的路线是同一条,也是我在这批 PR 里反复引用的判据: 没有消费方的代码等于死代码,它让调用方以为该能力已具备。我一边用这条原则删掉 ActionSpec.fps / loop、一边留着 412 行未验证的 provider,是自相矛盾的。 删除:FalQueueVideoProvider / FirstFrameUploader / PreUploadedFirstFrame / FAL_I2V_ENDPOINTS 与端点映射 / 三个 FAL 专用异常 / config.fal_video_model / 28 条 FAL 测试。sufy.py 从 740 行降到 343 行。 保留一段注释记下两个实测挣来的事实,避免将来重新摸索:FAL 面只吃公网 URL 不吃 base64 (塞 base64 会 queued 之后在生成阶段才 failed,费用可能已产生);鉴权头是 `Authorization: Key`,路径与 /v1 平级。 顺带把 VideoProvider 的 docstring 改成正面依据:**入参恒为 bytes**,因为 ai_engine 必须 持有 bytes —— master_check 预检、master_prep 预处理、像素化锁色板全都读母版像素;改传 URL 的话 ai_engine 还得自己下载回来。某厂商只吃 URL 属该 provider 自己的适配问题, 在 provider 内部转换,不把差异漏给上层。 代价如实说明:veo / seedance 只在 FAL 面,而实测 veo 的走路步态比 kling 更自然。真要接 时连同一次真实调用一起加回,归档里有完整的接入记录,重写成本不高。 --- .../src/windup_framework/config/provider.py | 3 +- .../windup_framework/providers/__init__.py | 6 - .../windup_framework/providers/interfaces.py | 27 +- .../src/windup_framework/providers/sufy.py | 440 +---------------- .../tests/test_fal_queue_video_provider.py | 452 ------------------ backend/tests/test_sufy_video_download.py | 17 +- 6 files changed, 38 insertions(+), 907 deletions(-) delete mode 100644 backend/tests/test_fal_queue_video_provider.py diff --git a/backend/packages/framework/src/windup_framework/config/provider.py b/backend/packages/framework/src/windup_framework/config/provider.py index b171f2c4..faeede24 100644 --- a/backend/packages/framework/src/windup_framework/config/provider.py +++ b/backend/packages/framework/src/windup_framework/config/provider.py @@ -24,7 +24,7 @@ class AIProviderSettings(BaseSettings): # ── 各能力用哪个模型 ────────────────────────────────────────────────── # 分成三个字段而不是共用上面那个 ``model``:三条能力同时在用不同模型,共用一个 # 字段意味着换其中一个就把另外两个也换了。默认值即当前实测在用的型号, - # 部署侧可用 AI_VIDEO_MODEL / AI_IMAGE_MODEL / AI_FAL_VIDEO_MODEL 覆盖。 + # 部署侧可用 AI_VIDEO_MODEL / AI_IMAGE_MODEL 覆盖。 # # **只有型号可配,请求形状不可配**:哪个模型吃 image_list、哪个吃 # input_reference、FAL 队列路径长什么样,都是该模型的 API 事实而非运行参数, @@ -33,7 +33,6 @@ class AIProviderSettings(BaseSettings): # 而费用可能已经产生(2026-07-29 实测)。 video_model: str = "kling-v2-5-turbo" image_model: str = "gemini-2.5-flash-image" - fal_video_model: str = "kling-v2-5-turbo" @property def normalized_base_url(self) -> str: diff --git a/backend/packages/framework/src/windup_framework/providers/__init__.py b/backend/packages/framework/src/windup_framework/providers/__init__.py index 87e2121e..fd1f448e 100644 --- a/backend/packages/framework/src/windup_framework/providers/__init__.py +++ b/backend/packages/framework/src/windup_framework/providers/__init__.py @@ -4,15 +4,12 @@ from windup_framework.providers.chat import create_chat_model from windup_framework.providers.image import create_image_client from windup_framework.providers.interfaces import ( - FirstFrameUploader, ImageProvider, MatteProvider, VideoProvider, ) from windup_framework.providers.matte import OnnxU2NetMatteProvider from windup_framework.providers.sufy import ( - FalQueueVideoProvider, - PreUploadedFirstFrame, SufyImageProvider, SufyVideoProvider, ) @@ -27,12 +24,9 @@ "ImageProvider", "VideoProvider", "MatteProvider", - "FirstFrameUploader", # 实现 "SufyVideoProvider", # FAL 队列面的 i2v(现役接口形态);首帧要公网 URL,故与 uploader 成对出现 - "FalQueueVideoProvider", - "PreUploadedFirstFrame", "SufyImageProvider", "OnnxU2NetMatteProvider", ] diff --git a/backend/packages/framework/src/windup_framework/providers/interfaces.py b/backend/packages/framework/src/windup_framework/providers/interfaces.py index fa353b3d..4addf0f0 100644 --- a/backend/packages/framework/src/windup_framework/providers/interfaces.py +++ b/backend/packages/framework/src/windup_framework/providers/interfaces.py @@ -22,11 +22,13 @@ def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: ... class VideoProvider(Protocol): """首帧图 + 动作 prompt → 视频(i2v,步态位移动作用)。 - **入参恒为 bytes,不是 URL** —— 上游(strategy)手里只有母版 bytes,让每个调用点 - 自己想办法弄出一个公网 URL 会把"对象存储"这件事扩散到整条管线。有的供应商接口 - 只吃公网 URL(FAL 队列面全部如此),那是**该 provider 自己的适配问题**:它在 - 构造时接一个 :class:`FirstFrameUploader`,在 provider 内部把 bytes 换成 URL。 - 见 :class:`~.sufy.FalQueueVideoProvider`。 + **入参恒为 bytes,不是 URL。** 上游(ai_engine.strategy)手里只有母版 bytes,而且它 + 必须有 bytes —— ``master_check`` 预检、``master_prep`` 预处理、像素化锁色板全都读 + 母版**像素**。让调用点改传 URL 的话,ai_engine 还得自己下载回 bytes 才能干活。 + + 某些供应商的接口只吃公网 URL。那属于**该 provider 自己的适配问题**:在 provider + 内部完成 bytes → URL 的转换(需要一个上传能力时由组装层注入),而不是把这个差异 + 漏给上层。这样"用哪个厂商"不会改变 ai_engine 的一行代码。 """ def i2v( @@ -34,21 +36,6 @@ def i2v( ) -> bytes: ... -@runtime_checkable -class FirstFrameUploader(Protocol): - """首帧 bytes → **公网可取的 URL**(给只吃 URL 的视频接口用)。 - - 为什么是一个 port 而不是直接在 provider 里写上传:framework 里"对象存储"是另一 - 条独立的线(见 ``windup_framework.storage`` 与依赖里的 ``qiniu``),由组装层决定 - 用哪个桶、什么有效期、要不要复用已有的图。provider 只声明"我需要一个 URL"。 - - 实现方必须保证:返回的 URL 对**供应商的服务器**可取(不是只对内网/本机可取), - 且在整个生成周期内有效(i2v 任务排队 + 生成常见数分钟)。 - """ - - def upload(self, frame: bytes, content_type: str) -> str: ... - - @runtime_checkable class MatteProvider(Protocol): """主体抠图(rembg / u2net)—— 按主体抠,不抠颜色(浅色角色撞背景会抠穿)。""" diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py index 7c289e1c..bf37b4c7 100644 --- a/backend/packages/framework/src/windup_framework/providers/sufy.py +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -1,27 +1,24 @@ """Provider 接口的 SUFY / qnaigc(Modelink 网关)同步实现。 -网关上挂着**两套互不兼容的视频接口面**,本模块两条都实现、并存不替换: +本模块实现三个 provider:视频(i2v)、图像(文生图 / 图生图)、以及它们共用的下载与首帧 +处理。抠图另在 :mod:`.matte`。 -1. OpenAI 风格(:class:`SufyVideoProvider`)——首帧走 base64 dataURI:: +视频走 OpenAI 风格面(:class:`SufyVideoProvider`),首帧是 base64 dataURI:: - POST /v1/videos {model, prompt, size, seconds, mode, input_reference} - 轮询 GET /v1/videos/{id} → status==completed → task_result.videos[0].url → 下载 mp4 + POST /v1/videos {model, prompt, size, seconds, mode, input_reference} + 轮询 GET /v1/videos/{id} → status==completed → task_result.videos[0].url → 下载 mp4 - 2026-07-27 对 kling-v2-5-turbo 端到端实测到 completed。留着是因为没有实测证据说它 - 坏了,sora 系可能仍只在这一面。 +2026-07-27 对 kling-v2-5-turbo 端到端实测到 completed。 -2. FAL 队列(:class:`FalQueueVideoProvider`)——首帧走**公网 URL**:: +图像走 OpenAI 兼容的 ``/chat/completions``(:class:`SufyImageProvider`),参考图以 data URI +塞进 ``content`` 数组 —— 与视频的提交-轮询-下载三段式完全不同的调用形状。 - POST /queue/{厂商}/{型号}/[{mode}/]image-to-video {..., image_url|start_image_url} - 轮询 GET /queue/{家族}/requests/{request_id}/status → COMPLETED → result.video.url +**网关上还有另一套 FAL 队列面**(veo / seedance / vidu 只在那一面)。曾实现过,但因为 +从未被真实调用过而移除,见本文件中段那条注释里记下的两个实测事实。 - 2026-08-07 拉网关 OpenAPI spec 逐个核对:平台现有 69 个 POST 视频端点,其中 22 个 - 图生视频**全部**在这一面,全部要 URL 形态的首帧字段,没有一个吃 dataURI。 - -两面鉴权也不同(spec 明写):FAL 面 ``Authorization: Key {api_key}``,OpenAI 面 ``Bearer``。 - -key / base_url 由 ``AIProviderSettings`` 注入,provider 内不读 env。 -重依赖(PIL)惰性导入,保证模块导入零成本。 +型号与 key / base_url 均由 ``AIProviderSettings`` 注入,provider 内不读 env;哪个模型吃 +什么请求字段属该模型的 API 事实,写在代码里而不是配置里(填错只会在生成阶段才 failed, +而费用可能已产生)。重依赖(PIL)惰性导入,保证模块导入零成本。 """ from __future__ import annotations @@ -31,14 +28,12 @@ import logging import re import time -from collections.abc import Mapping -from dataclasses import dataclass, field import httpx from windup_framework.config.provider import AIProviderSettings, settings -from .interfaces import FirstFrameUploader, ImageProvider, VideoProvider +from .interfaces import ImageProvider, VideoProvider logger = logging.getLogger("windup.providers.sufy") @@ -248,401 +243,18 @@ def _download(client: httpx.Client, url: str, tries: int = 3) -> bytes: # 其余各家的档位枚举各不相同。 -class UnknownVideoModelError(RuntimeError): - """模型不在端点表里 —— 不猜路径,直接拒。 - - 猜错的代价不对称:猜出一条不存在的路径只是 404(便宜),猜出一条**存在但语义不同** - 的路径(如把 image-to-video 猜成 reference-to-video)会正常出片、正常计费, - 产出却与预期不符。故这里只认表,不做前缀匹配、不做拼接兜底。 - """ - - -class UnsupportedVideoOptionError(RuntimeError): - """该模型不支持这个 mode / 时长 / 画幅 —— 提交前就拒,别等网关 400。""" - - -class FirstFrameNotPublicError(RuntimeError): - """uploader 没给出 http(s) URL —— 首帧供应商取不到。""" - - -class VideoJobFailedError(RuntimeError): - """FAL 任务失败。 - - 含一种伪装成功:spec 明写「任务失败时后端也返回 COMPLETED,通过 detail 字段区分」。 - 只看 status 会把失败当成功,然后在"取不到视频 URL"处报一个莫名其妙的错。 - """ - - -class VideoJobTimeoutError(RuntimeError): - """轮询预算耗尽仍未出片(任务可能还在跑,费用可能已产生)。""" - - -@dataclass(frozen=True) -class FalI2VEndpoint: - """一个模型在 FAL 队列面上的调用形状。字段全部取自网关 OpenAPI spec。""" - - submit_path: str # 含 {mode} 则该模型必须给 mode - image_field: str # image_url / start_image_url - queue_base: str # 轮询与取结果的前缀,与 submit_path 不同 - seconds: frozenset[int] # 允许的时长 - modes: frozenset[str] = frozenset() # 空 = 路径里没有 {mode} - duration_style: str = "str" # str -> "5" | str_s -> "8s" | int -> 5 - resolution_field: str | None = None # None = 该模型没有分辨率档位,画幅跟随首帧 - resolutions: Mapping[int, str] = field(default_factory=dict) # 首帧高度 → 档位枚举 - audio_field: str | None = None # 有则显式关掉:序列帧不要声音,别平白多花钱 - - -_KLING_QUEUE = "/queue/fal-ai/kling-video" -_KLING_3_SECONDS = frozenset(range(3, 16)) - -# 键是**本仓自己的模型名**(与 ``AIProviderSettings.model`` 对齐)。FAL 面的 body 里 -# 没有 model 字段 —— 型号是路径的一部分,这也是"必须查表"的根本原因。 -FAL_I2V_ENDPOINTS: Mapping[str, FalI2VEndpoint] = { - "kling-v3-omni": FalI2VEndpoint( - submit_path=f"{_KLING_QUEUE}/o3/{{mode}}/image-to-video", - image_field="image_url", - queue_base=_KLING_QUEUE, - seconds=_KLING_3_SECONDS, - modes=frozenset({"standard", "std", "pro", "4k"}), - audio_field="generate_audio", - ), - "kling-v3": FalI2VEndpoint( - submit_path=f"{_KLING_QUEUE}/v3/{{mode}}/image-to-video", - image_field="start_image_url", # 与同族 o3 的 image_url 不同,别顺手写成一样 - queue_base=_KLING_QUEUE, - seconds=_KLING_3_SECONDS, - modes=frozenset({"standard", "std", "pro", "4k"}), - audio_field="generate_audio", # spec 默认 true - ), - "kling-v3-turbo": FalI2VEndpoint( - submit_path=f"{_KLING_QUEUE}/v3/turbo/{{mode}}/image-to-video", - image_field="image_url", - queue_base=_KLING_QUEUE, - seconds=_KLING_3_SECONDS, - modes=frozenset({"standard", "pro"}), # 注意没有 "std" - ), - "kling-v2-6": FalI2VEndpoint( - submit_path=f"{_KLING_QUEUE}/v2.6/{{mode}}/image-to-video", - image_field="start_image_url", - queue_base=_KLING_QUEUE, - seconds=frozenset({5, 10}), - modes=frozenset({"pro"}), # 只有 pro - audio_field="generate_audio", # spec 默认 true - ), - "kling-v2-5-turbo": FalI2VEndpoint( - submit_path=f"{_KLING_QUEUE}/v2.5-turbo/{{mode}}/image-to-video", - image_field="image_url", - queue_base=_KLING_QUEUE, - seconds=frozenset({5, 10}), - modes=frozenset({"standard", "std", "pro"}), - ), - "kling-video-o1": FalI2VEndpoint( - submit_path=f"{_KLING_QUEUE}/o1/{{mode}}/image-to-video", - image_field="start_image_url", - queue_base=_KLING_QUEUE, - seconds=frozenset(range(3, 11)), - modes=frozenset({"standard", "std", "pro"}), - ), - "veo3.1": FalI2VEndpoint( - submit_path="/queue/fal-ai/veo3.1/image-to-video", - image_field="image_url", - queue_base="/queue/fal-ai/veo3.1", - seconds=frozenset({4, 6, 8}), - duration_style="str_s", # 只有 veo 带 "s" 后缀 - resolution_field="resolution", - resolutions={720: "720p", 1080: "1080p", 2160: "4k"}, - audio_field="generate_audio", # spec 默认 true - ), - "seedance-2.0": FalI2VEndpoint( - submit_path="/queue/bytedance/seedance-2.0/image-to-video", - image_field="image_url", - queue_base="/queue/bytedance/seedance-2.0", - seconds=frozenset(range(4, 16)), - resolution_field="resolution", - resolutions={480: "480p", 720: "720p", 1080: "1080p", 2160: "4k"}, - audio_field="generate_audio", - ), - "minimax-h3": FalI2VEndpoint( - submit_path="/queue/minimax/h3/image-to-video", - image_field="image_url", - queue_base="/queue/minimax/h3", - seconds=frozenset(range(5, 16)), - duration_style="int", - resolution_field="resolution", - # 只有 768P / 2K 两档。720 高的首帧没有对应档位,此时**报错而不是就近选 768P**: - # 悄悄换档 = 出片尺寸与调用方要的不一致,而序列帧下游是按尺寸对齐的。 - resolutions={768: "768P"}, - ), - "vidu-q3-pro": FalI2VEndpoint( - submit_path="/queue/fal-ai/vidu/q3/image-to-video/pro", - image_field="image_url", - queue_base="/queue/fal-ai/vidu", # 家族级前缀,不含 q3/pro - seconds=frozenset(range(1, 17)), - duration_style="int", - resolution_field="resolution", - resolutions={540: "540p", 720: "720p", 1080: "1080p"}, - audio_field="audio", # q3 默认 true - ), -} - -DEFAULT_FAL_VIDEO_MODEL = "kling-v2-5-turbo" - - -def fal_endpoint(model: str) -> FalI2VEndpoint: - """查表取端点定义;查不到就炸,绝不猜。""" - try: - return FAL_I2V_ENDPOINTS[model] - except KeyError: - known = ", ".join(sorted(FAL_I2V_ENDPOINTS)) - raise UnknownVideoModelError( - f"模型 {model!r} 不在 FAL 图生视频端点表里。已登记: {known}。" - "新增模型请去网关 OpenAPI spec 抄提交路径 / 首帧字段名 / 轮询前缀三项后登记,不要拼路径。" - ) from None - - -def fal_submit_path(model: str, mode: str) -> str: - """拼出提交路径(唯一允许的"拼接"就是把表里的 {mode} 填上)。""" - endpoint = fal_endpoint(model) - if not endpoint.modes: - return endpoint.submit_path - if mode not in endpoint.modes: - raise UnsupportedVideoOptionError( - f"模型 {model} 不支持 mode={mode!r},可选: {sorted(endpoint.modes)}" - ) - return endpoint.submit_path.format(mode=mode) - - -def assert_i2v_options(model: str, seconds: int, size: str) -> None: - """把"这个模型收不收这些参数"验完。纯计算,故可在**上传首帧之前**先调。""" - endpoint = fal_endpoint(model) - if seconds not in endpoint.seconds: - raise UnsupportedVideoOptionError( - f"模型 {model} 不支持 {seconds} 秒,可选: {sorted(endpoint.seconds)}" - ) - if endpoint.resolution_field: - _fal_resolution(model, endpoint, size) - - -def fal_i2v_body(model: str, prompt: str, image_url: str, seconds: int, size: str) -> dict: - """按模型形态组装请求体。任何一项不被该模型支持都当场炸,不做就近替换。""" - assert_i2v_options(model, seconds, size) - endpoint = fal_endpoint(model) - # 10 个端点的时长字段都叫 duration,只是取值形态不同。 - body: dict = { - endpoint.image_field: image_url, - "prompt": prompt, - "duration": _fal_duration(model, endpoint, seconds), - } - if endpoint.resolution_field: - body[endpoint.resolution_field] = _fal_resolution(model, endpoint, size) - if endpoint.audio_field: - # 序列帧不要声音:多数端点默认 true,不显式关掉等于白付音轨的钱和时间。 - body[endpoint.audio_field] = False - return body - - -def _fal_duration(model: str, endpoint: FalI2VEndpoint, seconds: int) -> str | int: - if endpoint.duration_style == "int": - return int(seconds) - if endpoint.duration_style == "str_s": - return f"{seconds}s" - if endpoint.duration_style == "str": - return str(seconds) - raise UnsupportedVideoOptionError( - f"模型 {model} 的 duration_style 登记有误: {endpoint.duration_style!r}" - ) - - -def _fal_resolution(model: str, endpoint: FalI2VEndpoint, size: str) -> str: - try: - height = int(size.split("x")[1]) - except (IndexError, ValueError): - raise UnsupportedVideoOptionError(f"size 形如 1280x720,收到 {size!r}") from None - try: - return endpoint.resolutions[height] - except KeyError: - raise UnsupportedVideoOptionError( - f"模型 {model} 没有 {size} 对应的分辨率档位,支持的高度: {sorted(endpoint.resolutions)}" - ) from None - - -def _api_root(base_url: str) -> str: - """把 OpenAI 兼容面的 base_url 退回网关根。 - - 配置里的 ``AI_BASE_URL`` 指向 OpenAI 面(``.../v1``),而 FAL 的 ``/queue/...`` 与 - ``/v1/...`` 是**平级**的(spec 的 servers 就是裸域名)。直接拿 base_url 拼会得到 - ``/v1/queue/...`` → 404。 - """ - root = base_url.rstrip("/") - return root[: -len("/v1")] if root.endswith("/v1") else root - - -class PreUploadedFirstFrame(FirstFrameUploader): - """首帧已经在公网上时的零成本 uploader(不传任何东西,直接返回该 URL)。 - - 典型场景:server 侧的母版本来就存在 ``Character.reference_image_url``,重新上传一份 - 纯属浪费。 - - **代价写在这里,别踩**:走这条路等于跳过 :func:`_fit_first_frame` 的补边, - ``i2v(size=...)`` 对 kling 系就失效了(kling 没有分辨率字段,成片画幅跟随首帧)。 - 要控制成片画幅,请给一个真正会上传 bytes 的 uploader。 - """ - - def __init__(self, url: str) -> None: - if not url.startswith(("http://", "https://")): - raise FirstFrameNotPublicError(f"首帧 URL 必须是 http(s),收到 {url!r}") - self._url = url - - def upload(self, frame: bytes, content_type: str) -> str: - """两个入参是 port 契约的一部分,本实现用不上(图已经在公网)。""" - return self._url - - -class FalQueueVideoProvider(VideoProvider): - """FAL 队列面的 i2v。首帧 bytes → 经 uploader 换成公网 URL → 队列任务 → mp4 bytes。 - - 与 :class:`SufyVideoProvider` 并存:那条是 OpenAI 风格 ``/v1/videos``(首帧走 - dataURI),两套接口面在网关上同时存在,路径 / 鉴权 / 首帧形态全都不同。 - - ``uploader`` 是**必填**且无默认值 —— 构造不出一个"没有上传能力的 FAL provider", - 免得跑到线上才发现首帧送不出去(那时任务已经提交、钱已经花了)。 - """ - - def __init__( - self, - uploader: FirstFrameUploader, - config: AIProviderSettings = settings, - model: str | None = None, - mode: str = "std", - poll_interval: float = 15.0, - max_min: int = 30, - ) -> None: - # 先把型号定下来再校验:``model=None`` 表示"用配置里的",此时拿 None 去查端点表 - # 会报"模型 None 不在表里",把一个正常的默认路径变成构造期崩溃。 - self._model = model or config.fal_video_model - # 构造即校验:未知模型 / 不支持的 mode 在**花钱之前**就炸掉。 - self._path = fal_submit_path(self._model, mode) - self._endpoint = fal_endpoint(self._model) - self._uploader = uploader - self._cfg = config - self._mode = mode - self._poll = poll_interval - self._max_min = max_min - - def _client(self) -> httpx.Client: - return httpx.Client( - base_url=_api_root(self._cfg.normalized_base_url), - # FAL 面是 ``Key``,不是 ``Bearer``(spec 的 securitySchemes 里两套并列写明)。 - headers={"Authorization": f"Key {self._cfg.api_key}"}, - timeout=self._cfg.timeout, - ) - - def i2v( - self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720" - ) -> bytes: - # 先验参数再上传:上传首帧通常要花钱/占带宽,不该为一个必然被拒的请求先传图。 - assert_i2v_options(self._model, seconds, size) - body = fal_i2v_body(self._model, prompt, self._upload(first_frame, size), seconds, size) - with self._client() as client: - request_id = _fal_submit(client, self._path, body) - url = _await_fal_video_url( - client, self._endpoint, request_id, self._poll, self._max_min - ) - # 重试与长度校验是 2026-08-05 实测挣来的,不为"看起来更干净"去动它。 - # 但凭证不跟着走:成品 URL 多是 CDN 绝对地址,跨源时 _download 会摘掉 - # Authorization(见 _download_request —— 原来那句"用同一个 client 带鉴权头取" - # 就是 2026-08-10 机器审报的 key 泄漏)。 - return _download(client, url) - - def _upload(self, first_frame: bytes, size: str) -> str: - url = self._uploader.upload(_fit_first_frame(first_frame, size), "image/jpeg") - if not isinstance(url, str) or not url.startswith(("http://", "https://")): - # dataURI / 本地路径在这一面必然产不出正确结果:要么被网关 400,要么更糟 —— - # 被当成"没有首帧"跑成文生视频,照样计费。宁可在提交前炸。 - raise FirstFrameNotPublicError( - f"uploader 必须返回 http(s) 公网 URL(供应商服务器要能取到),收到 {url!r}" - ) - return url - - -def _fal_submit(client: httpx.Client, path: str, body: dict) -> str: - """提交任务,拿 request_id。被拒时把网关的 detail.msg 带出来(否则只剩一个 400)。""" - try: - payload = client.post(path, json=body).raise_for_status().json() - except httpx.HTTPStatusError as exc: - raise VideoJobFailedError( - f"i2v 提交被拒(HTTP {exc.response.status_code},POST {path}): {_fal_error_text(exc.response)}" - ) from exc - request_id = payload.get("request_id") - if not request_id: - raise VideoJobFailedError(f"i2v 提交返回里没有 request_id: {payload}") - return str(request_id) - - -def _fal_error_text(response: httpx.Response) -> str: - try: - detail = response.json().get("detail") - except ValueError: - return response.text[:200] - if isinstance(detail, dict): - return str(detail.get("msg") or detail) - return str(detail) - - -def _await_fal_video_url( - client: httpx.Client, - endpoint: FalI2VEndpoint, - request_id: str, - poll_interval: float, - max_min: int, -) -> str: - """轮询到出片,返回成品视频 URL。任何非成功终态都抛错,绝不返回空。 - - 三处踩点: - - 进行中的状态是 HTTP 202,``raise_for_status`` 不会拦,得看 status 字段。 - - spec 明写「任务失败时后端也返回 COMPLETED,通过 detail 字段区分成功/失败」, - 所以 COMPLETED 还要再看 detail —— 只认 status 会把失败当成功。 - - 认不出的 status 一律当失败,不要 continue:那会一直转到超时,把一个"协议变了" - 的问题伪装成"生成太慢"。 - """ - status_path = f"{endpoint.queue_base}/requests/{request_id}/status" - for _ in range(max(1, int(max_min * 60 // poll_interval))): - time.sleep(poll_interval) - state = client.get(status_path).raise_for_status().json() - status = str(state.get("status") or "") - if status in ("IN_QUEUE", "IN_PROGRESS"): - continue - if status == "FAILED": - raise VideoJobFailedError(f"i2v 任务失败({request_id}): {state.get('detail')}") - if status != "COMPLETED": - raise VideoJobFailedError(f"i2v 任务返回未知状态 {status!r}({request_id}): {state}") - if state.get("detail"): - raise VideoJobFailedError( - f"i2v 任务 COMPLETED 但带 detail = 实为失败({request_id}): {state.get('detail')}" - ) - url = ((state.get("result") or {}).get("video") or {}).get("url") - return url if url else _fal_result_url(client, endpoint, request_id) - raise VideoJobTimeoutError( - f"i2v 轮询 {max_min} 分钟仍未出片({request_id});任务可能仍在跑,费用可能已产生" - ) - - -def _fal_result_url(client: httpx.Client, endpoint: FalI2VEndpoint, request_id: str) -> str: - """COMPLETED 但状态响应里没带 URL 时,按 fal 协议再取一次结果。 - - 不是兜底降级,是协议本身就有的第二步(提交响应里的 ``response_url`` 指的就是它): - 各家 status 响应是否内联 result 并不一致。此时**视频已生成、费用已产生**, - 为少一次 GET 而丢掉整单不划算。取不到才炸。 - """ - path = f"{endpoint.queue_base}/requests/{request_id}" - try: - payload = client.get(path).raise_for_status().json() - except httpx.HTTPError as exc: - raise VideoJobFailedError(f"i2v 已完成但取结果失败({request_id}): {exc}") from exc - url = (payload.get("video") or {}).get("url") - if not url: - raise VideoJobFailedError(f"i2v 已完成但结果里没有视频 URL({request_id}): {payload}") - return str(url) +# ── FAL 队列面(veo / seedance / vidu)已移除 ──────────────────────────────── +# +# 曾有一整套 FalQueueVideoProvider + FirstFrameUploader + 端点映射表(412 行、28 条 +# 测试)。删掉的理由与 GenRoute 只列有实现的路线是同一条:**它从未被真实调用过** +# —— app / ai_engine 里零引用,产品链路走不到,而"代码在仓里"会让人以为该能力已具备。 +# +# 真要接 veo / seedance 时连同一次真实调用一起加回。届时的两个已知事实(实测挣得, +# 别再摸索一遍): +# 1. FAL 面只吃**公网 URL**,不吃 base64;塞 base64 会 status=queued 之后在生成阶段 +# 才 failed,费用可能已经产生。 +# 2. 鉴权头是 `Authorization: Key `,不是 `Bearer`;路径与 /v1 平级,不是它的子路径。 +# 归档实测记录见项目参考资料(图生视频 API 实测文档)。 DEFAULT_IMAGE_MODEL = "gemini-2.5-flash-image" diff --git a/backend/tests/test_fal_queue_video_provider.py b/backend/tests/test_fal_queue_video_provider.py deleted file mode 100644 index 05e353b7..00000000 --- a/backend/tests/test_fal_queue_video_provider.py +++ /dev/null @@ -1,452 +0,0 @@ -"""FAL 队列面 i2v 的回归测试(全程不联网:httpx.MockTransport + monkeypatch)。 - -护住的是三类"花了钱才发现"的错: - 1. 端点表写错 —— 提交路径 / 首帧字段名 / 轮询前缀三项各家都不同,猜不出来; - 2. 失败被当成成功 —— spec 明写失败也可能返回 COMPLETED,只看 status 会漏; - 3. 视频已生成却把整单丢掉 —— 下载重试与长度校验必须仍然生效。 -""" - -import io -import json - -import httpx -import pytest - -from windup_framework.config.provider import AIProviderSettings -from windup_framework.providers.interfaces import VideoProvider -from windup_framework.providers.sufy import ( - FAL_I2V_ENDPOINTS, - FalQueueVideoProvider, - FirstFrameNotPublicError, - PreUploadedFirstFrame, - UnknownVideoModelError, - UnsupportedVideoOptionError, - VideoJobFailedError, - VideoJobTimeoutError, - _api_root, - _await_fal_video_url, - fal_endpoint, - fal_i2v_body, - fal_submit_path, -) - -VIDEO = b"\x00\x01mp4-bytes" * 64 -FRAME_URL = "https://cdn.invalid/master.jpg" -VIDEO_URL = "https://cdn.invalid/out.mp4" - -# 逐项抄自网关 OpenAPI spec(2026-08-07 下载的那批)。 -# 元组 = (提交路径, 首帧字段名, 轮询/取结果前缀)。轮询前缀**不是**提交路径 + /requests: -# kling 六个型号共用一个家族级前缀,vidu 也把 q3/pro 段去掉了。 -EXPECTED_ENDPOINTS = { - "kling-v3-omni": ( - "/queue/fal-ai/kling-video/o3/{mode}/image-to-video", - "image_url", - "/queue/fal-ai/kling-video", - ), - "kling-v3": ( - "/queue/fal-ai/kling-video/v3/{mode}/image-to-video", - "start_image_url", - "/queue/fal-ai/kling-video", - ), - "kling-v3-turbo": ( - "/queue/fal-ai/kling-video/v3/turbo/{mode}/image-to-video", - "image_url", - "/queue/fal-ai/kling-video", - ), - "kling-v2-6": ( - "/queue/fal-ai/kling-video/v2.6/{mode}/image-to-video", - "start_image_url", - "/queue/fal-ai/kling-video", - ), - "kling-v2-5-turbo": ( - "/queue/fal-ai/kling-video/v2.5-turbo/{mode}/image-to-video", - "image_url", - "/queue/fal-ai/kling-video", - ), - "kling-video-o1": ( - "/queue/fal-ai/kling-video/o1/{mode}/image-to-video", - "start_image_url", - "/queue/fal-ai/kling-video", - ), - "veo3.1": ( - "/queue/fal-ai/veo3.1/image-to-video", - "image_url", - "/queue/fal-ai/veo3.1", - ), - "seedance-2.0": ( - "/queue/bytedance/seedance-2.0/image-to-video", - "image_url", - "/queue/bytedance/seedance-2.0", - ), - "minimax-h3": ( - "/queue/minimax/h3/image-to-video", - "image_url", - "/queue/minimax/h3", - ), - "vidu-q3-pro": ( - "/queue/fal-ai/vidu/q3/image-to-video/pro", - "image_url", - "/queue/fal-ai/vidu", - ), -} - -COMPLETED = {"status": "COMPLETED", "detail": None, "result": {"video": {"url": VIDEO_URL}}} - - -def _png(width: int = 900, height: int = 500) -> bytes: - """真图,不是假 bytes —— 首帧补边那一步会真的解码它。""" - from PIL import Image - - buf = io.BytesIO() - Image.new("RGB", (width, height), (30, 60, 90)).save(buf, "PNG") - return buf.getvalue() - - -def _config() -> AIProviderSettings: - # base_url 故意带 /v1:FAL 面在网关根,provider 必须自己退回去。 - return AIProviderSettings(base_url="https://gw.invalid/v1", api_key="test-key") - - -def _install_transport(monkeypatch, handler) -> None: - """让 provider 自己造的 client 走 MockTransport,同时保留它设的 base_url / 鉴权头。""" - real_client = httpx.Client - - def factory(**kwargs): - return real_client(transport=httpx.MockTransport(handler), **kwargs) - - monkeypatch.setattr("windup_framework.providers.sufy.httpx.Client", factory) - monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) - - -class _Uploader: - """记录被上传的首帧,返回一个固定的公网 URL。""" - - def __init__(self, url: str = FRAME_URL) -> None: - self.url = url - self.uploaded: list[tuple[bytes, str]] = [] - - def upload(self, frame: bytes, content_type: str) -> str: - self.uploaded.append((frame, content_type)) - return self.url - - -def _gateway(calls: list, *, states: list[dict], result: dict | None = None): - """一个最小的 FAL 网关:提交给 request_id,状态按 states 顺序吐,视频 URL 给 bytes。""" - - def handler(request: httpx.Request) -> httpx.Response: - calls.append(request) - if request.method == "POST": - return httpx.Response(200, json={"request_id": "req-1", "status": "IN_QUEUE"}) - if request.url.path.endswith("/status"): - state = states[min(len(calls) - 2, len(states) - 1)] - in_flight = state.get("status") in ("IN_QUEUE", "IN_PROGRESS") - return httpx.Response(202 if in_flight else 200, json=state) - if request.url.path.endswith("/requests/req-1"): - return httpx.Response(200, json=result or {}) - return httpx.Response(200, content=VIDEO) - - return handler - - -def _provider(monkeypatch, handler, model: str = "kling-v2-5-turbo", mode: str = "std"): - _install_transport(monkeypatch, handler) - return FalQueueVideoProvider(_Uploader(), config=_config(), model=model, mode=mode) - - -# ── 端点表:三项各家都不同,只能查表 ──────────────────────────────────────── - - -@pytest.mark.parametrize("model", sorted(EXPECTED_ENDPOINTS)) -def test_each_model_resolves_to_the_path_and_image_field_in_the_spec(model): - """每个模型解析出 spec 里的提交路径、首帧字段名与轮询前缀。""" - submit_path, image_field, queue_base = EXPECTED_ENDPOINTS[model] - endpoint = fal_endpoint(model) - - assert endpoint.submit_path == submit_path - assert endpoint.image_field == image_field - assert endpoint.queue_base == queue_base - # 字段名要真的落到请求体上,而不是只写在表里。 - # 时长 / 画幅取该模型自己支持的值:各家能接的取值本就不同(veo 没有 5 秒, - # minimax 没有 720 档),用一组固定值反而会把这个测试变成时长测试。 - seconds = min(endpoint.seconds) - size = f"1280x{min(endpoint.resolutions)}" if endpoint.resolutions else "1280x720" - assert image_field in fal_i2v_body(model, "walk", FRAME_URL, seconds, size) - - -def test_table_holds_only_models_checked_against_the_spec(): - """新增模型必须同时补 EXPECTED_ENDPOINTS,逼作者回 spec 抄那三项。""" - assert set(FAL_I2V_ENDPOINTS) == set(EXPECTED_ENDPOINTS) - - -def test_same_family_different_generation_uses_different_image_field(): - """o3 / v2.5-turbo 是 image_url,v3 / v2.6 / o1 是 start_image_url —— 最容易顺手写错的一处。""" - assert fal_endpoint("kling-v3-omni").image_field == "image_url" - assert fal_endpoint("kling-v3").image_field == "start_image_url" - assert fal_endpoint("kling-video-o1").image_field == "start_image_url" - - -def test_unknown_model_raises_instead_of_guessing_a_path(): - with pytest.raises(UnknownVideoModelError, match="不在 FAL 图生视频端点表里"): - fal_endpoint("kling-v9-imaginary") - # 前缀像、但没登记的一样要炸(别退化成前缀匹配) - with pytest.raises(UnknownVideoModelError): - fal_submit_path("kling-v3-omni-pro", "std") - - -def test_unsupported_mode_raises_before_submitting(): - """v2.6 只有 pro;v3-turbo 只有 standard/pro(没有 std)。""" - with pytest.raises(UnsupportedVideoOptionError, match="mode"): - fal_submit_path("kling-v2-6", "std") - with pytest.raises(UnsupportedVideoOptionError, match="mode"): - fal_submit_path("kling-v3-turbo", "std") - assert fal_submit_path("kling-v2-6", "pro").endswith("/v2.6/pro/image-to-video") - - -def test_paths_without_a_mode_segment_ignore_mode(): - assert fal_submit_path("veo3.1", "std") == "/queue/fal-ai/veo3.1/image-to-video" - assert fal_submit_path("minimax-h3", "pro") == "/queue/minimax/h3/image-to-video" - - -# ── 请求体形态:时长三种写法、分辨率档位不做就近替换 ──────────────────────── - - -def test_duration_is_rendered_in_each_vendors_own_shape(): - assert fal_i2v_body("kling-v2-5-turbo", "p", FRAME_URL, 5, "1280x720")["duration"] == "5" - assert fal_i2v_body("veo3.1", "p", FRAME_URL, 8, "1280x720")["duration"] == "8s" - assert fal_i2v_body("minimax-h3", "p", FRAME_URL, 5, "1024x768")["duration"] == 5 - - -def test_unsupported_duration_raises(): - """v2.5-turbo 只有 5 / 10 秒。""" - with pytest.raises(UnsupportedVideoOptionError, match="秒"): - fal_i2v_body("kling-v2-5-turbo", "p", FRAME_URL, 7, "1280x720") - - -def test_models_without_a_resolution_knob_do_not_send_one(): - """kling 系没有 resolution 字段,画幅跟随首帧;硬塞会被网关 400。""" - assert "resolution" not in fal_i2v_body("kling-v3-omni", "p", FRAME_URL, 5, "1280x720") - - -def test_resolution_without_a_matching_tier_raises_instead_of_snapping(): - """minimax 只有 768P / 2K。悄悄把 720 换成 768P = 出片尺寸与调用方要的不一致。""" - assert fal_i2v_body("minimax-h3", "p", FRAME_URL, 5, "1024x768")["resolution"] == "768P" - with pytest.raises(UnsupportedVideoOptionError, match="分辨率档位"): - fal_i2v_body("minimax-h3", "p", FRAME_URL, 5, "1280x720") - - -def test_audio_is_switched_off_where_the_model_has_the_flag(): - """多数端点 generate_audio 默认 true;序列帧不要声音,不关等于白花钱。""" - assert fal_i2v_body("kling-v3", "p", FRAME_URL, 5, "1280x720")["generate_audio"] is False - assert fal_i2v_body("vidu-q3-pro", "p", FRAME_URL, 5, "1280x720")["audio"] is False - - -def test_base_url_v1_suffix_is_stripped_back_to_the_gateway_root(): - """/queue 与 /v1 平级,拿 base_url 直接拼会得到 /v1/queue/... → 404。""" - assert _api_root("https://gw.invalid/v1") == "https://gw.invalid" - assert _api_root("https://gw.invalid/v1/") == "https://gw.invalid" - assert _api_root("https://gw.invalid") == "https://gw.invalid" - - -# ── 端到端(mock):提交 → 轮询 → 下载 ────────────────────────────────────── - - -def test_end_to_end_hits_the_right_paths(monkeypatch): - calls: list[httpx.Request] = [] - provider = _provider( - monkeypatch, - _gateway(calls, states=[{"status": "IN_PROGRESS"}, COMPLETED]), - model="kling-v3", - mode="pro", - ) - - assert provider.i2v(_png(), "walk cycle", seconds=5, size="1280x720") == VIDEO - - submit, first_poll, second_poll, download = calls - assert submit.method == "POST" - assert submit.url.path == "/queue/fal-ai/kling-video/v3/pro/image-to-video" - # FAL 面是 Key 不是 Bearer(spec 的 securitySchemes 两套并列写明) - assert submit.headers["authorization"] == "Key test-key" - # 轮询打在家族级前缀上,不是提交路径 + /requests - assert first_poll.url.path == "/queue/fal-ai/kling-video/requests/req-1/status" - assert second_poll.url.path == first_poll.url.path - assert str(download.url) == VIDEO_URL - # 成品 URL 在 CDN 域名下(gw.invalid → cdn.invalid),这一跳不能带 API key。 - # 端到端这一层单独断言:_download 的单测再全,也管不住调用方哪天又把凭证塞回来。 - assert "authorization" not in download.headers, "API key 被发给了 CDN(PR #179 P1)" - assert download.url.host != submit.url.host - - -def test_first_frame_is_padded_then_uploaded_and_enters_the_body_as_a_url(monkeypatch): - from PIL import Image - - calls: list[httpx.Request] = [] - _install_transport(monkeypatch, _gateway(calls, states=[COMPLETED])) - uploader = _Uploader() - provider = FalQueueVideoProvider(uploader, config=_config(), model="kling-v2-5-turbo") - - provider.i2v(_png(900, 500), "walk", seconds=5, size="1280x720") - - frame, content_type = uploader.uploaded[0] - assert content_type == "image/jpeg" - assert Image.open(io.BytesIO(frame)).size == (1280, 720) # 补边到目标画幅 - assert json.loads(calls[0].content)["image_url"] == FRAME_URL - - -def test_no_request_is_sent_when_the_uploader_gives_no_public_url(monkeypatch): - """dataURI / 本地路径在这一面产不出正确结果,必须在**提交之前**炸。""" - - def handler(request: httpx.Request) -> httpx.Response: - raise AssertionError(f"不该发出任何请求: {request.url}") - - _install_transport(monkeypatch, handler) - provider = FalQueueVideoProvider(_Uploader("data:image/jpeg;base64,AAAA"), config=_config()) - - with pytest.raises(FirstFrameNotPublicError, match="http"): - provider.i2v(_png(), "walk") - - -def test_unsupported_options_are_rejected_before_the_frame_is_uploaded(monkeypatch): - """上传首帧要花钱/占带宽,不该为一个必然被拒的请求先传图。""" - - def handler(request: httpx.Request) -> httpx.Response: - raise AssertionError(f"不该发出任何请求: {request.url}") - - _install_transport(monkeypatch, handler) - uploader = _Uploader() - provider = FalQueueVideoProvider(uploader, config=_config(), model="kling-v2-5-turbo") - - with pytest.raises(UnsupportedVideoOptionError, match="秒"): - provider.i2v(_png(), "walk", seconds=7) - assert uploader.uploaded == [] - - -def test_unknown_model_and_bad_mode_are_rejected_at_construction(): - """炸在构造,而不是等到 i2v 真去提交任务。""" - with pytest.raises(UnknownVideoModelError): - FalQueueVideoProvider(_Uploader(), config=_config(), model="nope") - with pytest.raises(UnsupportedVideoOptionError): - FalQueueVideoProvider(_Uploader(), config=_config(), model="kling-v2-6", mode="std") - - -def test_satisfies_the_video_provider_contract(): - assert isinstance(FalQueueVideoProvider(_Uploader(), config=_config()), VideoProvider) - - -# ── 轮询的失败面:任何非成功终态都要炸 ────────────────────────────────────── - - -def _poll(states: list[dict], monkeypatch, *, result: dict | None = None, max_min: int = 30): - monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) - seen = {"n": 0} - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("/status"): - state = states[min(seen["n"], len(states) - 1)] - seen["n"] += 1 - return httpx.Response(200, json=state) - return httpx.Response(200, json=result or {}) - - client = httpx.Client(transport=httpx.MockTransport(handler), base_url="https://gw.invalid") - with client: - return _await_fal_video_url(client, fal_endpoint("kling-v3"), "req-1", 1.0, max_min) - - -def test_failed_status_raises(monkeypatch): - with pytest.raises(VideoJobFailedError, match="任务失败"): - _poll([{"status": "FAILED", "detail": {"msg": "内容审核不通过"}}], monkeypatch) - - -def test_completed_with_detail_is_a_disguised_failure(monkeypatch): - """spec 明写:失败时后端也返回 COMPLETED,靠 detail 区分。只看 status 会当成功。""" - with pytest.raises(VideoJobFailedError, match="实为失败"): - _poll( - [{"status": "COMPLETED", "detail": {"msg": "upstream error"}, "result": {}}], - monkeypatch, - ) - - -def test_unrecognised_status_is_treated_as_failure(monkeypatch): - """continue 下去会把"协议变了"伪装成"生成太慢",转满预算才报超时。""" - with pytest.raises(VideoJobFailedError, match="未知状态"): - _poll([{"status": "SUCCEEDED"}], monkeypatch) - - -def test_timeout_raises_instead_of_returning_nothing(monkeypatch): - with pytest.raises(VideoJobTimeoutError, match="仍未出片"): - _poll([{"status": "IN_PROGRESS"}], monkeypatch, max_min=1) - - -def test_completed_without_inline_url_falls_back_to_the_result_endpoint(monkeypatch): - """视频已生成、费用已产生,不为省一次 GET 丢整单;取不到才炸。""" - states = [{"status": "COMPLETED", "detail": None, "result": {}}] - assert _poll(states, monkeypatch, result={"video": {"url": VIDEO_URL}}) == VIDEO_URL - - with pytest.raises(VideoJobFailedError, match="没有视频 URL"): - _poll(states, monkeypatch, result={}) - - -# ── 下载重试:视频已生成、费用已产生,断一次不能整单作废 ──────────────────── - - -def test_download_retry_still_applies_on_the_fal_route(monkeypatch): - downloads = {"n": 0} - - def handler(request: httpx.Request) -> httpx.Response: - if request.method == "POST": - return httpx.Response(200, json={"request_id": "req-1"}) - if request.url.path.endswith("/status"): - return httpx.Response(200, json=COMPLETED) - downloads["n"] += 1 - if downloads["n"] == 1: - raise httpx.RemoteProtocolError( - "peer closed connection without sending complete message body", request=request - ) - return httpx.Response(200, content=VIDEO) - - provider = _provider(monkeypatch, handler) - assert provider.i2v(_png(), "walk") == VIDEO - assert downloads["n"] == 2 - - -def test_truncated_download_is_still_caught_by_the_length_check(monkeypatch): - def handler(request: httpx.Request) -> httpx.Response: - if request.method == "POST": - return httpx.Response(200, json={"request_id": "req-1"}) - if request.url.path.endswith("/status"): - return httpx.Response(200, json=COMPLETED) - return httpx.Response(200, content=VIDEO[:10], headers={"content-length": str(len(VIDEO))}) - - provider = _provider(monkeypatch, handler) - with pytest.raises(RuntimeError, match="已重试 3 次"): - provider.i2v(_png(), "walk") - - -# ── 提交被拒:把网关给的原因带出来 ────────────────────────────────────────── - - -def test_rejected_submit_surfaces_the_gateway_reason(monkeypatch): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(400, json={"detail": {"msg": "image_url is required"}}) - - provider = _provider(monkeypatch, handler) - with pytest.raises(VideoJobFailedError, match="image_url is required"): - provider.i2v(_png(), "walk") - - -def test_submit_without_request_id_raises(monkeypatch): - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"status": "IN_QUEUE"}) - - provider = _provider(monkeypatch, handler) - with pytest.raises(VideoJobFailedError, match="request_id"): - provider.i2v(_png(), "walk") - - -# ── 已在公网的首帧:零成本 uploader ──────────────────────────────────────── - - -def test_pre_uploaded_first_frame_returns_the_url_as_is(): - uploader = PreUploadedFirstFrame(FRAME_URL) - assert uploader.upload(b"ignored", "image/jpeg") == FRAME_URL - with pytest.raises(FirstFrameNotPublicError): - PreUploadedFirstFrame("/tmp/local.png") diff --git a/backend/tests/test_sufy_video_download.py b/backend/tests/test_sufy_video_download.py index 81ef3037..bf3c013a 100644 --- a/backend/tests/test_sufy_video_download.py +++ b/backend/tests/test_sufy_video_download.py @@ -370,7 +370,6 @@ def _cfg(**kw): @pytest.mark.parametrize(("cls_name", "field", "value"), [ ("SufyVideoProvider", "video_model", "kling-v9-test"), - ("FalQueueVideoProvider", "fal_video_model", "veo3.1"), ("SufyImageProvider", "image_model", "gemini-9-flash-image"), ]) def test_each_provider_reads_its_own_model_field(cls_name, field, value): @@ -382,10 +381,7 @@ def test_each_provider_reads_its_own_model_field(cls_name, field, value): import windup_framework.providers.sufy as S cls = getattr(S, cls_name) - kwargs = {"config": _cfg(**{field: value})} - if cls_name == "FalQueueVideoProvider": - kwargs["uploader"] = _StubUploader() - assert cls(**kwargs)._model == value + assert cls(config=_cfg(**{field: value}))._model == value def test_explicit_model_argument_still_wins_over_config(): @@ -399,6 +395,8 @@ def test_explicit_model_argument_still_wins_over_config(): def test_request_shape_is_not_configurable(): """**只有型号可配,请求形状不可配。** + (FAL 队列面已随「从未真实调用过」一并移除,故这里只剩两个型号字段。) + 哪个模型吃 image_list、FAL 队列路径长什么样,是该模型的 API 事实而非运行参数。 放进配置会把"填错了会怎样"从部署期推到运行期:字段塞错不会立刻报错,任务照常 queued,直到生成阶段才 failed,而费用可能已经产生(2026-07-29 实测)。 @@ -410,11 +408,4 @@ def test_request_shape_is_not_configurable(): fields = set(AIProviderSettings.model_fields) for banned in ("image_list_models", "fal_endpoints", "first_frame_field"): assert banned not in fields, f"{banned} 不该进配置,见本用例 docstring" - assert {"video_model", "image_model", "fal_video_model"} <= fields - - -class _StubUploader: - """FalQueueVideoProvider 的必需构造参数(无默认值,见 FirstFrameUploader)。""" - - def upload(self, frame: bytes, content_type: str) -> str: - return "https://cdn.example.com/first.jpg" + assert {"video_model", "image_model"} <= fields From bb4a5aae49990774ca2b998fe4a6518c65a6a06e Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 11 Aug 2026 17:59:43 +0800 Subject: [PATCH 12/33] =?UTF-8?q?test(providers):=20=E8=A1=A5=20i2v=20?= =?UTF-8?q?=E4=B8=BB=E6=B5=81=E7=A8=8B=E4=B8=8E=20cutout=20=E8=A3=85?= =?UTF-8?q?=E9=85=8D=E7=9A=84=E8=A6=86=E7=9B=96=EF=BC=8C=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E9=99=A4=E9=9B=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 的 codecov/patch 报红,查证后是真缺口:`SufyVideoProvider.i2v` —— **产品唯一的付费 路径** —— 一条测试都没有。sufy.py 覆盖率 72%,未覆盖的正是提交/轮询/下载三段式与首帧 处理。matte.py 的 `cutout` 装配顺序同样零覆盖。 补 sufy 7 条(sufy.py 72% → 99%): - 完整付费路径:提交拿 job id → 轮询到 completed → 下载 mp4 - 首帧必须是 JPEG data URI。PNG base64 会让任务 status=failed(VENDOR_FAILED, 2026-07-22 实测,33s fail-fast)—— 这条错在提交之后才报,本地看不出来 - 首帧按目标画布**补边不拉伸**:拉伸会改角色比例,而母版比例是角色一致性的一部分 - failed / cancelled 立刻抛,不把剩余轮询预算耗完(钱已经花了,尽快暴露原因更有用) - 轮询预算用尽抛错而不返回空 bytes(空 bytes 会被当视频送进抽帧,报"无可解码帧", 真正的原因被埋掉) - 首帧字段按模型选(塞错字段任务照常 queued,直到生成阶段才 failed,费用可能已产生) 补 matte 3 条(matte.py 71% → 93%):cutout 输出 RGBA、**RGB 通道不被改动**(改了会让 后续像素化锁色板取到被改过的颜色)、清理与填洞的**调用顺序**(反过来会把刚填上的像素 又清掉,且不报错)。真实推理需要 4.7MB onnx 权重,CI 里下不到也不该下,故用假 session 只覆盖装配逻辑。 顺带修一个测试逮到的真 bug:`poll_interval=0` 会在 `max_min * 60 // poll` 处除零,报 ZeroDivisionError,读的人完全看不出是配错了参数。改为构造期拒绝非正数。 9 条变异全部杀掉。其中"补边不拉伸"第一版是摆设 —— 纯色图拉伸后对称两点颜色照样相同, M3 存活;改成在源图里放一个偏心方块、量它在成品里的宽高比(补边≈1.0,拉伸≈2.67) 才真能杀掉。 另记一个操作教训:变异测试期间用 `git checkout -- ` 还原,会把同文件里**尚未提交** 的改动一起丢掉(守卫被静默还原,表现为"还原后测试仍红")。变异 harness 一律用脚本内的 文本备份还原,并在结束时校验 sha256。 --- .../src/windup_framework/providers/sufy.py | 5 + backend/tests/test_matte_provider.py | 88 +++++++++ backend/tests/test_sufy_video_download.py | 185 ++++++++++++++++++ 3 files changed, 278 insertions(+) diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py index bf37b4c7..31ec80f7 100644 --- a/backend/packages/framework/src/windup_framework/providers/sufy.py +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -81,6 +81,11 @@ def __init__( poll_interval: float = 60.0, max_min: int = 30, ) -> None: + # 轮询间隔必须 > 0:下面用 `max_min * 60 // poll` 算预算次数,传 0 直接除零 + # (2026-08-11 补 i2v 主流程测试时逮到)。0 的语义本身也不成立 —— 那是忙等, + # 会把网关打满。测试要跑快就把 time.sleep 打桩掉,别把间隔设成 0。 + if poll_interval <= 0: + raise ValueError(f"poll_interval 必须为正数,收到 {poll_interval}") self._cfg = config self._model = model or config.video_model self._mode = mode diff --git a/backend/tests/test_matte_provider.py b/backend/tests/test_matte_provider.py index 3e507759..20837f8c 100644 --- a/backend/tests/test_matte_provider.py +++ b/backend/tests/test_matte_provider.py @@ -312,3 +312,91 @@ def bfs(seed, region): region = rng.random((h, w)) < rng.uniform(0.3, 0.9) seed = rng.random((h, w)) < 0.05 assert (_spread(seed, region) == bfs(seed, region)).all() + + +# ── cutout 的装配顺序(不碰真模型)───────────────────────────────────────── +# +# 真实推理需要 4.7MB 的 onnx 权重,CI 里既下不到也不该下。但 cutout 本身的**装配顺序** +# 是有语义的,可以用一个假 session 覆盖: +# 预测 mask → 乘键控清理系数 → 填封闭空洞 → 合成 RGBA +# 顺序错了会静默出错结果:先填洞再清理,会把刚填上的像素又清掉。 + + +class _FakeSession: + """假 onnxruntime session:返回一个中间为主体的 mask。""" + + class _In: + name = "input" + + def get_inputs(self): + return [self._In()] + + def run(self, _out, feed): + import numpy as np + + t = next(iter(feed.values())) + h, w = t.shape[2], t.shape[3] + m = np.zeros((1, 1, h, w), dtype="float32") + m[:, :, h // 4 : h * 3 // 4, w // 4 : w * 3 // 4] = 1.0 + return [m] + + +def _provider_with_fake_session(monkeypatch): + from windup_framework.providers.matte import OnnxU2NetMatteProvider + + p = OnnxU2NetMatteProvider() + monkeypatch.setattr(p, "_get_session", lambda: _FakeSession()) + return p + + +def _png(w=64, h=64, color=(220, 220, 220)): + import io + + from PIL import Image + + buf = io.BytesIO() + Image.new("RGB", (w, h), color).save(buf, "PNG") + return buf.getvalue() + + +def test_cutout_outputs_rgba_png_with_alpha(monkeypatch): + import io + + from PIL import Image + + out = _provider_with_fake_session(monkeypatch).cutout(_png()) + im = Image.open(io.BytesIO(out)) + assert im.format == "PNG" and im.mode == "RGBA" + assert im.size == (64, 64) + + +def test_cutout_keeps_rgb_untouched(monkeypatch): + """抠图只动 alpha。改 RGB 会让后续像素化锁色板取到被改过的颜色。""" + import io + + import numpy as np + from PIL import Image + + src = _png(color=(31, 41, 59)) + out = _provider_with_fake_session(monkeypatch).cutout(src) + a = np.asarray(Image.open(io.BytesIO(out))) + b = np.asarray(Image.open(io.BytesIO(src)).convert("RGB")) + assert np.array_equal(a[:, :, :3], b), "RGB 通道被改了" + + +def test_cutout_applies_flat_bg_cleanup_before_filling_holes(monkeypatch): + """顺序:清理 → 填洞。反过来会把刚填上的像素又清掉,且不报错。 + + 用调用顺序断言而不是像素结果 —— 结果层面两种顺序在简单图上可能相同, + 那样的用例杀不掉顺序颠倒这个变异。 + """ + import windup_framework.providers.matte as M + + order: list[str] = [] + real_pen, real_fill = M._flat_bg_penalty, M._fill_enclosed_holes + monkeypatch.setattr(M, "_flat_bg_penalty", + lambda rgb: (order.append("clean"), real_pen(rgb))[1]) + monkeypatch.setattr(M, "_fill_enclosed_holes", + lambda a, rgb: (order.append("fill"), real_fill(a, rgb))[1]) + _provider_with_fake_session(monkeypatch).cutout(_png()) + assert order == ["clean", "fill"], order diff --git a/backend/tests/test_sufy_video_download.py b/backend/tests/test_sufy_video_download.py index bf3c013a..907094f9 100644 --- a/backend/tests/test_sufy_video_download.py +++ b/backend/tests/test_sufy_video_download.py @@ -9,6 +9,8 @@ 见 ``providers.sufy._download_request`` 的 docstring。 """ +import json + import httpx import pytest @@ -409,3 +411,186 @@ def test_request_shape_is_not_configurable(): for banned in ("image_list_models", "fal_endpoints", "first_frame_field"): assert banned not in fields, f"{banned} 不该进配置,见本用例 docstring" assert {"video_model", "image_model"} <= fields + + +# ── i2v 主流程(付费路径,此前零覆盖)───────────────────────────────────────── + + +def _jpeg_first_frame(w: int = 200, h: int = 300) -> bytes: + """一张竖长的图,用来验首帧被按目标画布补边而不是拉伸。""" + import io as _io + + from PIL import Image as _Image + + buf = _io.BytesIO() + _Image.new("RGB", (w, h), (40, 80, 160)).save(buf, "PNG") + return buf.getvalue() + + +@pytest.fixture(autouse=True) +def _no_sleep(monkeypatch): + """轮询里的 time.sleep 打桩 —— 用例不该真等。""" + import windup_framework.providers.sufy as _S + + monkeypatch.setattr(_S.time, "sleep", lambda *_: None) + + +def _video_provider(handler, **kw): + import httpx as _httpx + + from windup_framework.config.provider import AIProviderSettings + from windup_framework.providers.sufy import SufyVideoProvider + + p = SufyVideoProvider( + config=AIProviderSettings(base_url="https://gw.example.com/v1", api_key="k"), + # 轮询预算 = max_min * 60 // poll。poll 取大值让预算只有几次, + # 再把 time.sleep 打桩掉,用例就既快又不空转(第一版 poll=0.001 配 + # max_min=1 会真轮询 6 万次,单文件跑了 96 秒)。 + poll_interval=30.0, + **kw, + ) + client = _httpx.Client( + base_url="https://gw.example.com/v1", + headers={"Authorization": "Bearer k"}, + transport=_httpx.MockTransport(handler), + ) + p._client = lambda: client + return p + + +def _i2v_handler(seen: dict, *, statuses=("completed",), video=b"MP4DATA" * 200): + """提交 → 轮询 → 下载 三段式的假网关。""" + import httpx as _httpx + + calls = {"n": 0} + + def h(request): + path = request.url.path + if request.method == "POST" and path.endswith("/videos"): + seen["body"] = json.loads(request.content) + return _httpx.Response(200, json={"id": "job-1"}) + if request.method == "GET" and "/videos/" in path: + i = min(calls["n"], len(statuses) - 1) + calls["n"] += 1 + st = statuses[i] + if st == "completed": + return _httpx.Response(200, json={ + "status": "completed", + "task_result": {"videos": [{"url": "https://gw.example.com/out.mp4"}]}, + }) + return _httpx.Response(200, json={"status": st, "error": "boom"}) + seen["download_headers"] = dict(request.headers) + return _httpx.Response(200, content=video, + headers={"Content-Length": str(len(video))}) + + return h + + +def test_i2v_submits_polls_and_downloads(): + """一条完整的付费路径:提交拿 job id → 轮询到 completed → 下载 mp4。""" + seen: dict = {} + data = _video_provider(_i2v_handler(seen)).i2v(_jpeg_first_frame(), "walk right") + assert data.startswith(b"MP4DATA") + body = seen["body"] + assert body["prompt"] == "walk right" + assert body["seconds"] == "5" and isinstance(body["seconds"], str), "seconds 必须是字符串" + assert body["mode"] == "std" + + +def test_first_frame_goes_as_a_jpeg_data_uri(): + """PNG base64 会让任务 status=failed(VENDOR_FAILED,2026-07-22 实测,33s fail-fast)。 + 首帧必须转 JPEG —— 这条错在提交后才报,本地看不出来。 + """ + seen: dict = {} + _video_provider(_i2v_handler(seen)).i2v(_jpeg_first_frame(), "x") + uri = seen["body"]["input_reference"] + assert uri.startswith("data:image/jpeg;base64,"), uri[:40] + + import base64 as _b64 + import io as _io + + from PIL import Image as _Image + + im = _Image.open(_io.BytesIO(_b64.b64decode(uri.split(",", 1)[1]))) + assert im.format == "JPEG" + + +def test_first_frame_is_padded_to_the_target_canvas_not_stretched(): + """按目标画布补边、不拉伸:拉伸会让角色比例变形,而母版比例是角色一致性的一部分。""" + import base64 as _b64 + import io as _io + + from PIL import Image as _Image + + # 源图放一个偏心的亮块:拉伸会把它拉宽,补边会保持它的宽高比。 + # 只看"对称两点颜色相同"是无效判据 —— 纯色图拉伸后照样相同 + # (2026-08-11 变异测试逮到第一版正是如此,M3 存活)。 + buf = _io.BytesIO() + src = _Image.new("RGB", (200, 300), (40, 80, 160)) + src.paste((250, 250, 250), (80, 100, 120, 140)) # 40x40 的方块 + src.save(buf, "PNG") + + seen: dict = {} + _video_provider(_i2v_handler(seen)).i2v(buf.getvalue(), "x", size="1280x720") + im = _Image.open(_io.BytesIO(_b64.b64decode(seen["body"]["input_reference"].split(",", 1)[1]))) + assert im.size == (1280, 720), "首帧应铺满目标画布" + + # 量那个方块在成品里的宽高比。补边:源 40x40 等比缩放后仍是 1:1。 + # 拉伸:横向被拉 1280/200=6.4 倍、纵向 720/300=2.4 倍,比例变成 ~2.67:1。 + import numpy as _np + + a = _np.asarray(im.convert("L")) + ys, xs = _np.where(a > 200) + ratio = (xs.max() - xs.min() + 1) / (ys.max() - ys.min() + 1) + assert 0.8 < ratio < 1.25, f"方块宽高比 {ratio:.2f},说明被拉伸了(补边应≈1.0)" + + +@pytest.mark.parametrize("bad", ["failed", "cancelled"]) +def test_terminal_failure_raises_instead_of_polling_to_timeout(bad): + """网关报 failed/cancelled 要立刻抛,别把剩下的轮询次数耗完 —— 钱已经花了, + 尽快把原因暴露给上层比多等几分钟有用。 + """ + with pytest.raises(RuntimeError, match=bad): + _video_provider(_i2v_handler({}, statuses=(bad,))).i2v(_jpeg_first_frame(), "x") + + +def test_never_completing_job_raises_after_the_poll_budget(): + """轮询预算用尽仍未 completed → 抛错,不返回空 bytes。 + + 返回空 bytes 的话上游会把它当成一段视频送进抽帧,报"视频无可解码帧", + 真正的原因(超时)就被埋掉了。 + """ + p = _video_provider(_i2v_handler({}, statuses=("in_progress",)), max_min=1) # 预算 2 次 + with pytest.raises(RuntimeError, match="未取得视频 URL"): + p.i2v(_jpeg_first_frame(), "x") + + +def test_image_list_models_use_a_different_first_frame_field(): + """字段按模型选。塞错字段不会立刻报错 —— 任务 status=queued 正常返回, + 直到生成阶段才 failed "model is not supported",而费用可能已经产生 + (2026-07-29 实测)。 + """ + from windup_framework.providers.sufy import _IMAGE_LIST_MODELS + + seen: dict = {} + p = _video_provider(_i2v_handler(seen), model=_IMAGE_LIST_MODELS[0], mode="pro") + p.i2v(_jpeg_first_frame(), "x") + assert "image_list" in seen["body"] and "input_reference" not in seen["body"] + assert not seen["body"]["image_list"][0]["image"].startswith("data:"), \ + "image_list 要裸 base64,不带 data URI 前缀" + + +def test_non_positive_poll_interval_is_rejected_at_construction(): + """轮询间隔 <= 0 在构造时就拒。 + + 此前会活到 i2v 里 `max_min * 60 // poll` 那一步除零 —— 报 ZeroDivisionError, + 读的人完全看不出是配错了参数(2026-08-11 补 i2v 主流程测试时逮到)。 + 0 的语义本身也不成立:那是忙等,会把网关打满。 + """ + from windup_framework.config.provider import AIProviderSettings + from windup_framework.providers.sufy import SufyVideoProvider + + cfg = AIProviderSettings(base_url="https://gw.example.com/v1", api_key="k") + for bad in (0, -1, -0.5): + with pytest.raises(ValueError, match="poll_interval"): + SufyVideoProvider(config=cfg, poll_interval=bad) From 62aa9b945773edb8a43f15101c3886ac8d129ca3 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Fri, 7 Aug 2026 16:46:32 +0800 Subject: [PATCH 13/33] =?UTF-8?q?feat(ai=5Fengine):=20=E5=87=BA=E5=B8=A7?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=AE=B1=E2=80=94=E2=80=94=E6=8A=BD=E5=B8=A7?= =?UTF-8?q?=20/=20=E9=80=89=E5=B8=A7=20/=20=E5=90=8E=E5=A4=84=E7=90=86=20/?= =?UTF-8?q?=20=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视频路线的纯计算层,零 windup 依赖(只用 PIL + numpy),可独立测试。 slicing/ 视频 → 帧序列 extract 解码;loop 循环类动作抽单步态周期;oneshot 一次性动作裁区间; quality 帧质量诊断(死帧 / 糊帧判据,只作诊断不进选帧,理由见 loop docstring) postprocess/ 帧 → 交付级序列帧 pixelate 母版是像素画时吸附母版网格 + 锁母版色板,否则通用量化 pack 脚线对齐 / sprite sheet / GIF rootmotion 逐帧时长(关键帧加长定格,等时长会让动作发飘) prompt/ + master_prep.py 按动作类型选提示词、按动作预处理母版 两处实测挣得的修复一并带上: 1) 画布横向裁切(postprocess/pack.py) align_bottom_center 的三条缩放分支只按高度定标,是"主体是纵向长条"的人形先验。 横向长条主体按同一系数缩放后宽度超出 cell,被 alpha_composite 以负 dest 静默丢像素, PIL 不报错。裁切悬崖 w/h ≈ 1.61;实测狐狸母版 w/h=1.78 丢 27px(鼻尖+尾尖), w/h=2.0 只剩 79.9% 内容。加宽度兜底 fill_w=0.96;人形 w/h 0.3–1.1 时该约束恒不生效, 产物逐像素不变。 2) 步态周期误检(slicing/loop.py)三个坑,四段真 i2v 视频实测 a. 角色整体平移让 d(p) 单调上升,argmin 滑到搜索窗边界交出假周期。 实测骷髅走路:不消平移时曲线 40/56 段在上升,只剩 22/42/52 三个浅坑,argmin=22; 加 _deskew 消平移后整条曲线只剩一个局部极小,正是真周期 56(凹陷深度 2.68)。 b. 搜索窗上界 n//2 把真周期挡在窗外(待机真周期 62 > pmax 60)。改为 total*0.6。 c. 谐波:22 接近真周期的一半,半周期闭环 = 末帧接回首帧时左右腿瞬间互换。 改为在基周期整数倍里按归一化接缝复选,优先最小倍数。 测不到可信凹陷(prominence < 0.25)时判"无周期",退化成全片均匀取、不硬闭环—— 实测骑士待机只有 31 帧,旧算法曲线单调、argmin 落在搜索窗下界 6 交出边界假值。 实测对照(n=16,接缝 = 末→首差 ÷ 组内相邻差均值,越接近 1 越闭合) 骷髅走路 3.07→1.96 | 骑士走路 1.29→0.87 | 骑士待机 9.31→1.39 | 骑士奔跑 1.77→0.81 待机那条最直观:旧算法写出的 GIF 只有 6 帧——16 帧里 10 帧逐像素重复,被 PIL 自动去重。 消融:改善全部来自 _deskew + 谐波复选。追加的"死帧避让 + 冻结裁剪"两个样本无变化、 两个变差(奔跑接缝 0.81→2.00),已回退,quality 只留作诊断。 --- backend/packages/ai_engine/pyproject.toml | 2 + .../src/windup_ai_engine/master_prep.py | 82 +++ .../windup_ai_engine/postprocess/__init__.py | 28 + .../src/windup_ai_engine/postprocess/pack.py | 105 +++ .../windup_ai_engine/postprocess/pixelate.py | 252 +++++++ .../postprocess/rootmotion.py | 69 ++ .../src/windup_ai_engine/prompt/__init__.py | 15 + .../src/windup_ai_engine/prompt/actions.py | 83 +++ .../src/windup_ai_engine/prompt/jump.py | 63 ++ .../src/windup_ai_engine/prompt/walk.py | 57 ++ .../src/windup_ai_engine/slicing/__init__.py | 27 + .../src/windup_ai_engine/slicing/extract.py | 62 ++ .../src/windup_ai_engine/slicing/loop.py | 148 ++++ .../src/windup_ai_engine/slicing/oneshot.py | 189 +++++ .../src/windup_ai_engine/slicing/quality.py | 108 +++ backend/tests/test_loop.py | 46 ++ backend/tests/test_oneshot.py | 68 ++ backend/tests/test_pixelate.py | 103 +++ backend/uv.lock | 664 ++---------------- 19 files changed, 1548 insertions(+), 623 deletions(-) create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/master_prep.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py create mode 100644 backend/tests/test_loop.py create mode 100644 backend/tests/test_oneshot.py create mode 100644 backend/tests/test_pixelate.py diff --git a/backend/packages/ai_engine/pyproject.toml b/backend/packages/ai_engine/pyproject.toml index bb279425..84492ec8 100644 --- a/backend/packages/ai_engine/pyproject.toml +++ b/backend/packages/ai_engine/pyproject.toml @@ -10,6 +10,8 @@ dependencies = [ "langchain-core>=0.3", "pillow>=10.4", "numpy>=1.26", + "imageio>=2.36", + "av>=14.0", # imageio pyav 后端(视频抽帧) # "rembg", # 抠图(按需启用) ] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py new file mode 100644 index 00000000..ed2652e3 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py @@ -0,0 +1,82 @@ +"""母版规格与预处理:每个动作需要什么样的母版。 + +**核心规律(三次实测验证,写死为契约):母版姿态决定动作,提示词只能微调。** + - walk:母版**朝侧向**才不转身;正面母版配侧走词 → 模型靠转身调和图文矛盾。 + - jump:母版**顶部留白**才不被视频画面裁掉。 + - attack:必须给**极限蓄力母版**(武器已拉到身后腰际)。用站立母版时,即使提示词写死 + "武器不过头顶 / 不转身 / 只做一次",模型仍会抡过头顶、转到背面、劈两次 —— 强动作 + 先验压不住;换蓄力母版后模型只能"接着往前挥",没有再抡起的空间。 + + +实测教训:母版里角色居中、占 ~70% 画面高时,i2v 跳跃会让角色**头顶顶出视频画面上沿** +被裁掉(生成本身没错,是构图没留够空间)。规则同 MasterSpec 的"运动方向多留白": + - jump:向上运动 → 顶部补空间,角色坐低 + - dash / walk / run:向右位移 → 前进方向多留白(由母版生成时构图保证,此处不改) + +纯 PIL,零 API。背景色取母版四角中位色,补出来的边与母版底色一致。 +""" + +from __future__ import annotations + +import io + +import numpy as np +from PIL import Image + +__all__ = ["add_headroom", "prepare_master", "MASTER_POSES"] + +# 各动作所需的母版姿态(生成专用母版时的姿势描述)。空=可直接用中性站立母版。 +MASTER_POSES = { + "walk": "", # 中性站立即可,但必须朝侧向 + "run": "", + "idle": "", + # jump:与 attack 同理——重甲带剑角色的"跳跃"强动作先验压不住(站立母版会让模型摆 + # 造型、只举剑不腾空,实测)。给**极限蓄力半蹲母版**,模型只能"接着往上蹬"。顶部留白 + # 由 prepare_master(add_headroom)保证。 + "jump": ( + "deep crouch coiled to spring straight upward: the knees bent low and the hips sunk down, " + "both arms drawn back behind the body, the weight loaded onto both legs at the very moment " + "before springing straight up, the weapon kept in a fixed grip; " + "leave generous empty space above the head" + ), + "attack": ( + "extreme wind-up stance for a horizontal slash: the weapon drawn far BACK behind the body " + "at WAIST height, the torso twisted back and coiled, weight fully loaded on the back leg, " + "both arms low and pulled back, the weapon staying BELOW the shoulders; " + "leave generous empty space on the swing side" + ), +} + + +def _bg_color(img: Image.Image) -> tuple[int, int, int]: + """取四角中位色当背景色(母版通常是纯色底)。""" + rgb = np.asarray(img.convert("RGB")) + corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) + return tuple(int(v) for v in np.median(corners, axis=0)) + + +def add_headroom(master: bytes, ratio: float = 0.6) -> bytes: + """在母版上方补空间,让角色坐到画面下部,给腾空留出余量。 + + Args: + master: 母版图 bytes。 + ratio: 处理后角色所占的画面高度比例(越小头顶空间越多)。0.6 表示角色高度 + 约占新画面的 60%,上方留约 40%。 + """ + if not 0.1 < ratio < 1.0: + raise ValueError("ratio 需在 (0.1, 1.0) 之间") + img = Image.open(io.BytesIO(master)).convert("RGB") + new_h = max(img.height + 1, int(round(img.height / ratio))) + canvas = Image.new("RGB", (img.width, new_h), _bg_color(img)) + canvas.paste(img, (0, new_h - img.height)) # 原图贴底,空间加在顶部 + buf = io.BytesIO() + canvas.save(buf, "PNG") + return buf.getvalue() + + +def prepare_master(master: bytes, action: str) -> bytes: + """按动作类型预处理母版;不需要处理的动作原样返回。""" + if action in ("jump", "attack"): + # jump 向上腾空、attack 挥砍过头顶,都会顶出视频画面上沿(实测 attack 15/72 帧触顶) + return add_headroom(master, ratio=0.62 if action == "jump" else 0.70) + return master diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py new file mode 100644 index 00000000..e69f0ec7 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/__init__.py @@ -0,0 +1,28 @@ +"""后处理:把选好的帧落地成交付级序列帧(像素化 / 对齐 / 打包)。 + +抽帧 / 选帧见 :mod:`..slicing`。逐帧时长 ``frame_durations`` 在 :mod:`.rootmotion`。 +""" + +from .rootmotion import DEFAULT_FPS_MS, extract_root_motion, frame_durations +from .pixelate import ( + detect_pixel_size, + extract_palette, + master_pixel_spec, + pixelate_frames, + to_pixel_art, +) +from .pack import align_bottom_center, save_gif, sprite_sheet + +__all__ = [ + "to_pixel_art", + "pixelate_frames", + "detect_pixel_size", + "extract_palette", + "master_pixel_spec", + "extract_root_motion", + "frame_durations", + "DEFAULT_FPS_MS", + "align_bottom_center", + "sprite_sheet", + "save_gif", +] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py new file mode 100644 index 00000000..ce9c78dc --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py @@ -0,0 +1,105 @@ +"""对齐 / 打包(后处理的收尾:脚线对齐 → sprite sheet / gif)。 + +抽帧 / 选帧见 :mod:`..slicing`,像素化见 :mod:`.pixelate`,抠图见 framework 的 +MatteProvider(#20)。本模块把对齐后的帧拼成交付物。 +""" + +from __future__ import annotations + +from PIL import Image + +__all__ = ["align_bottom_center", "sprite_sheet", "save_gif"] + + +def align_bottom_center( + frames: list[Image.Image], + cell: int = 256, + foot_line: float = 0.92, + fill_h: float = 0.62, + fill_w: float = 0.96, + preserve_lift: bool = False, + ref_height: float | None = None, +) -> list[Image.Image]: + """按脚线对齐到统一画布,消除逐帧画布漂移(Issue #21)。 + + **整段共用一个缩放系数**(取全序列最高帧定标),不逐帧归一化 —— 逐帧各自缩放到等高 + 会把走路自然的身高起伏(实测约 4%)反向变成"忽大忽小":蹲下的帧被放大、伸展的帧被 + 缩小。统一缩放后帧间只剩真实姿态差,尺度稳定。 + + 水平方向按**主体水平中心**对齐(不含挥出的武器会更好,当前用整体包围盒中心兜底); + 垂直方向按**脚线**(包围盒底边)对齐到 ``foot_line``。 + + ``ref_height``:**跨动作一致性的关键**,单位=传入帧的像素高。给定时按它定标,否则按本 + 序列最高帧。按最高帧定标会让"举过头顶"的动作整段被缩小去迁就那一帧 —— 实测攻击时 + 斧头高举使 bbox 从 485 涨到 660,角色本体因此明显变小;跳跃顶点同理。故传入**参考姿态** + (站立)的高度,各动作即共用同一本体尺寸。``fill_h`` 默认 0.62,给举过头顶留出余量。 + + ``preserve_lift``:腾空位移**默认不烘进像素**(业界:位移交引擎 root motion)。仅在要把 + 位移画进序列帧时才开;开启后以序列里最低的脚线为地面基准,保留每帧相对地面的抬升量。 + """ + import numpy as np + + boxes: list[tuple[int, int, int, int] | None] = [] + for f in frames: + ys, xs = np.where(np.asarray(f)[:, :, 3] > 128) + boxes.append( + (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1) + if len(ys) + else None + ) + heights = [b[3] - b[1] for b in boxes if b] + if not heights: + return [Image.new("RGBA", (cell, cell), (0, 0, 0, 0)) for _ in frames] + # 腾空模式:以最低脚线(数值最大 = 站在地上)为地面基准,保留每帧的抬升量 + ground = max(b[3] for b in boxes if b) if preserve_lift else 0 + # 定标要把抬升量算进去,否则跳到最高时头顶会顶出画布被切掉 + if preserve_lift: + need = max((ground - b[3]) + (b[3] - b[1]) for b in boxes if b) + scale = (cell * fill_h) / max(1, need) + elif ref_height: + scale = (cell * fill_h) / ref_height # 参考姿态定标(跨动作一致) + else: + scale = (cell * fill_h) / max(heights) # 回退:本序列最高帧 + + # 宽度兜底:上面三条分支**只按高度定标** —— 这是"主体是纵向长条"的人形先验。 + # 横向长条主体(四足兽/坐骑/龙)按同一系数缩放后宽度超过 cell,会被下面的 + # alpha_composite 以负 dest **静默切掉**左右(PIL 不报错,直接丢像素)。 + # 裁切悬崖 = 主体 w/h > 1/fill_h ≈ 1.61。实测(2026-08-05):狐狸母版 w/h=1.78 + # 丢 27px(鼻尖+尾尖);w/h=2.0 只剩 79.9% 内容;狼/马常见 2.0-2.5 → 丢 19%-35% 体宽。 + # 人形 w/h≈0.3-1.1 时该约束**恒不生效**,故人形产物逐像素不变。 + widths = [b[2] - b[0] for b in boxes if b] + scale = min(scale, (cell * fill_w) / max(1, max(widths))) + + out = [] + for f, box in zip(frames, boxes): + if box is None: + out.append(Image.new("RGBA", (cell, cell), (0, 0, 0, 0))) + continue + crop = f.crop(box) + w = max(1, round(crop.width * scale)) + h = max(1, round(crop.height * scale)) + crop = crop.resize((w, h), Image.NEAREST) + lift = round((ground - box[3]) * scale) if preserve_lift else 0 + canvas = Image.new("RGBA", (cell, cell), (0, 0, 0, 0)) + canvas.alpha_composite(crop, (cell // 2 - w // 2, int(cell * foot_line) - h - lift)) + out.append(canvas) + return out + + +def sprite_sheet(frames: list[Image.Image], bg=(0, 0, 0, 0)) -> Image.Image: + """横向拼接为 sprite sheet。""" + if not frames: + raise ValueError("frames 为空") + w, h = frames[0].size + sheet = Image.new("RGBA", (w * len(frames), h), bg) + for i, f in enumerate(frames): + sheet.alpha_composite(f.convert("RGBA"), (i * w, 0)) + return sheet + + +def save_gif(frames: list[Image.Image], path: str, duration: int = 120) -> None: + """导出循环 gif 供预览。""" + if not frames: + raise ValueError("frames 为空") + rgba = [f.convert("RGBA") for f in frames] + rgba[0].save(path, save_all=True, append_images=rgba[1:], duration=duration, loop=0, disposal=2) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py new file mode 100644 index 00000000..4910fa96 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pixelate.py @@ -0,0 +1,252 @@ +"""像素化后处理:把生成帧转成脆边限色的像素精灵。 + +视频路线实测(Issue #35): +- i2v 能解决步态(腿真交替、不转身);对**插画风**角色它保留插画质感 → 需要像素化转风格。 +- 对**原生像素**角色 i2v 其实能保住像素感,但链路上两道有损压缩(首帧 JPG q90 + 视频 H.264) + 会在硬边处产生振铃噪点(表现为灰颗粒),像素越细越明显;而通用的"降采样 + 32 色量化" + 因为**网格对不齐**反而更糊。 +- 解法:有母版时按 :func:`master_pixel_spec` 量出母版的**原生像素块大小**与**真实色板**, + 按母版网格降采样 + 颜色吸附回母版色板 —— 压缩灰颗粒不属于色板,会被强制消掉。 + +纯 Pillow / numpy,零 API、秒级,符合"本机只做轻量 CV"的算力约束。 +输入约定:RGBA 图(alpha 为主体掩码,抠图见 framework 的 MatteProvider / Issue #20)。 +""" + +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = [ + "to_pixel_art", + "pixelate_frames", + "detect_pixel_size", + "extract_palette", + "master_pixel_spec", +] + + +def _content_bbox(rgba: Image.Image, alpha_thr: int = 128) -> tuple[int, int, int, int]: + """求主体包围盒。 + + 用 :func:`_subject_mask` 而非只看 alpha:母版常是**不透明白底**,只看 alpha 会把整张 + 画布当主体,导致逻辑像素高被算成整图高而非角色高(实测踩过)。 + """ + mask = _subject_mask(rgba.convert("RGBA"), alpha_thr) + ys, xs = np.where(mask) + if len(ys): + return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1 + return 0, 0, rgba.width, rgba.height + + +def _axis_block_size(crop: np.ndarray, axis: int, min_delta: int, min_frac: float) -> int: + """沿 ``axis`` 估块边长:显著色变位置 → 合并相邻 → 取最常见间距。""" + d = np.abs(np.diff(crop, axis=axis)).sum(axis=2) + frac = (d > min_delta).mean(axis=1 - axis) + edges = np.flatnonzero(frac > min_frac) + 1 + if len(edges) < 3: + return 1 + # 块边界常因轻微抗锯齿占相邻两行/列,合并成一条,否则 gap=1 会淹没真实值 + edges = edges[np.concatenate([[True], np.diff(edges) > 1])] + gaps = np.diff(edges) + gaps = gaps[gaps >= 2] + return int(np.bincount(gaps).argmax()) if len(gaps) else 1 + + +def detect_pixel_size( + img: Image.Image, min_delta: int = 30, min_frac: float = 0.02, max_size: int = 64 +) -> int: + """检测像素画的原生像素块边长(非像素画/检测不出时返回 1)。 + + 原理:像素画的色块边界落在同一网格上,相邻边界间距 = 块边长的整数倍,故取 + **最常见间距**即块边长。两轴分别估,取较小者(更保守,宁可细不可糊)。 + """ + rgba = img.convert("RGBA") + x0, y0, x1, y1 = _content_bbox(rgba) + crop = np.asarray(rgba.crop((x0, y0, x1, y1)).convert("RGB")).astype(np.int16) + if crop.size == 0: + return 1 + sizes = [_axis_block_size(crop, ax, min_delta, min_frac) for ax in (0, 1)] + best = min(s for s in sizes) if all(s >= 1 for s in sizes) else 1 + return max(1, min(best, max_size)) + + +def _erode(mask: np.ndarray, k: int) -> np.ndarray: + """二值腐蚀 k 次(纯 numpy 移位,不引 scipy)。""" + m = mask + for _ in range(max(0, k)): + m = ( + m + & np.roll(m, 1, 0) + & np.roll(m, -1, 0) + & np.roll(m, 1, 1) + & np.roll(m, -1, 1) + ) + if not m.any(): + return mask + return m + + +def _subject_mask( + rgba: Image.Image, alpha_thr: int = 128, bg_tol: int = 40, erode: int = 0 +) -> np.ndarray: + """主体掩码:优先用真实 alpha;母版常是**不透明白底**,此时按四角背景色排除背景。 + + 两个实测踩过的坑: + 1. 不排背景 → 白底占多数像素、吃光色板名额 → 角色被整体吸附成白色。 + 2. 排了背景但保留边缘 → 角色/白底之间的**抗锯齿过渡色**(近白)混进色板 → + 视频里的浅色噪点就近吸附成白点,满身白斑。故取色板时用 ``erode`` 腐蚀掉边缘。 + """ + arr = np.asarray(rgba) + alpha = arr[:, :, 3] + if not alpha.min() > alpha_thr: # 有真实抠图 + mask = alpha > alpha_thr + else: + rgb = arr[:, :, :3].astype(np.int16) + corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) + bg = np.median(corners, axis=0) + mask = np.abs(rgb - bg).sum(axis=2) > bg_tol + return _erode(mask, erode) + + +def extract_palette( + img: Image.Image, max_colors: int = 32, alpha_thr: int = 128, erode: int = 3 +) -> np.ndarray: + """提取母版真实色板,返回 (K,3) uint8。 + + 只统计主体像素(见 :func:`_subject_mask`,并腐蚀掉抗锯齿边缘),再用中位切分量化 + 归并噪声色 —— 生成的"像素画"常带轻微噪点/抗锯齿,同一名义色被打散成大量近似色, + 直接按频率统计会全被当杂色滤掉。 + """ + rgba = img.convert("RGBA") + arr = np.asarray(rgba) + mask = _subject_mask(rgba, alpha_thr, erode=erode) + pixels = arr[:, :, :3][mask] + if not len(pixels): + pixels = arr[:, :, :3].reshape(-1, 3) + strip = Image.fromarray(pixels.reshape(1, -1, 3).astype(np.uint8), "RGB") + quant = strip.quantize(colors=max(2, max_colors), method=Image.MEDIANCUT) + pal = np.asarray(quant.getpalette()[: max(2, max_colors) * 3], dtype=np.uint8).reshape(-1, 3) + used = np.unique(np.asarray(quant)) + return pal[used[used < len(pal)]] + + +def master_pixel_spec(master: Image.Image, max_colors: int = 48) -> tuple[int, np.ndarray]: + """从母版量出 (角色的逻辑像素高, 母版色板)。 + + 逻辑像素高 = 母版里角色占的像素行数 ÷ 原生像素块边长 —— 即"这个角色本来是多少 + 像素高的精灵"。用它当 ``target_h`` 可自动吸附网格,不必人肉猜分辨率。 + + ``max_colors`` 实测取值:32 太少 —— 中位切分按面积分箱,大面积色(如裸腿肤色/棕靴) + 会挤占名额,小面积但需渐变的衣服色档位不足 → 中间调就近吸到邻近色相(绿衣泛橄榄黄); + 96 太多 —— 抗锯齿近白色重新拿到独立分箱 → 边缘冒白噪点。48 是实测的安全区。 + """ + x0, y0, x1, y1 = _content_bbox(master.convert("RGBA")) + block = detect_pixel_size(master) + logical_h = max(1, round((y1 - y0) / block)) + return logical_h, extract_palette(master, max_colors=max_colors) + + +def _to_perceptual(rgb: np.ndarray) -> np.ndarray: + """RGB → 近似感知空间(亮度 + 两个色差轴),float32。 + + 直接在 RGB 里取最近邻会**跳色相**:绿衣的中间调可能被吸到橄榄黄(实测踩过)。 + 换成亮度/色差轴并给色差加权后,同色相内的明暗过渡优先匹配,色相跳变被压住。 + 这里用 YCbCr 型线性变换(比 Lab 便宜得多,足够拉开色相)。 + """ + f = rgb.astype(np.float32) + r, g, b = f[..., 0], f[..., 1], f[..., 2] + y = 0.299 * r + 0.587 * g + 0.114 * b + cb = b - y + cr = r - y + w = 2.0 # 色差权重 >1:宁可亮度差一点,也别换色相 + return np.stack([y, w * cb, w * cr], axis=-1) + + +def _snap_to_palette(rgb: np.ndarray, palette: np.ndarray) -> np.ndarray: + """把每个像素吸附到色板中最近的颜色(感知空间最近邻,分块避免大内存)。 + + 用 float32 感知空间:①避免 int16 平方距离溢出(255² > 32767,实测让绿衣变肉色); + ②按色相优先匹配,防止 RGB 空间里的跨色相跳变。 + """ + flat = _to_perceptual(rgb).reshape(-1, 3) + pal_p = _to_perceptual(palette).reshape(-1, 3) + pal_rgb = palette.astype(np.uint8).reshape(-1, 3) + out = np.empty((len(flat), 3), dtype=np.uint8) + step = 65536 + for i in range(0, len(flat), step): + chunk = flat[i : i + step] + d = ((chunk[:, None, :] - pal_p[None, :, :]) ** 2).sum(axis=2) + out[i : i + step] = pal_rgb[d.argmin(axis=1)] + return out.reshape(rgb.shape) + + +def to_pixel_art( + rgba: Image.Image, + target_h: int = 100, + palette_size: int = 32, + alpha_thr: int = 128, + palette: np.ndarray | None = None, +) -> Image.Image: + """单帧转像素风,返回小尺寸 RGBA(``target_h`` 高,等比宽)。 + + 步骤:裁到主体包围盒 → 等比缩到 ``target_h``(NEAREST 网格降采样)→ 限色。 + 限色两种模式: + - ``palette`` 给定(推荐,原生像素角色):**吸附到母版真实色板**,顺带消掉 + JPG/H.264 在硬边留下的灰颗粒。 + - ``palette=None``(插画转像素):按 ``palette_size`` 做八叉树量化。 + + Args: + target_h: 目标像素高;原生像素角色建议用 :func:`master_pixel_spec` 算出的逻辑高。 + palette_size: 无母版色板时的量化色数。 + palette: (K,3) uint8 母版色板。 + """ + if target_h < 1: + raise ValueError("target_h 必须 >= 1") + rgba = rgba.convert("RGBA") + x0, y0, x1, y1 = _content_bbox(rgba, alpha_thr) + crop = rgba.crop((x0, y0, x1, y1)) + w, h = crop.size + target_w = max(1, round(w * target_h / h)) + small = crop.resize((target_w, target_h), Image.NEAREST) + + alpha = np.asarray(small)[:, :, 3] + if palette is not None and len(palette): + rgb = _snap_to_palette(np.asarray(small.convert("RGB")), palette) + else: + rgb = np.asarray( + small.convert("RGB") + .quantize(colors=max(2, palette_size), method=Image.FASTOCTREE) + .convert("RGB") + ) + out = np.dstack([rgb, alpha]).astype(np.uint8) + return Image.fromarray(out, "RGBA") + + +def pixelate_frames( + frames: list[Image.Image], + target_h: int = 100, + palette_size: int = 32, + palette: np.ndarray | None = None, + ref_height: float | None = None, +) -> list[Image.Image]: + """批量像素化一组帧,**整段共用一个缩放系数**,便于打包为 sprite sheet。 + + ``target_h`` 是**基准姿态**的目标像素高,其余帧按同一系数等比缩放 —— 不是把每帧都拉 + 到等高。逐帧拉等高会把走路自然的身高起伏反向变成"忽大忽小"(实测踩过:蹲下的帧被放大)。 + + ``ref_height``:**跨动作一致性的关键**。给定时用它当基准(单位=源图像素),否则用本序列 + 最高帧。同一角色的各个动作若各自取自己的最高帧定标,切换状态时角色会忽大忽小 —— + 传入同一个基准(如母版姿态的角色高)即可让 idle/walk/jump/attack 共用一套尺度。 + """ + if not frames: + return [] + box_h = [] + for f in frames: + _, y0, _, y1 = _content_bbox(f.convert("RGBA")) + box_h.append(max(1, y1 - y0)) + scale = target_h / (ref_height if ref_height else max(box_h)) + return [ + to_pixel_art(f, max(1, round(h * scale)), palette_size, palette=palette) + for f, h in zip(frames, box_h) + ] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py new file mode 100644 index 00000000..88487645 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/rootmotion.py @@ -0,0 +1,69 @@ +"""Root motion(位移轨迹)与逐帧时长 —— 按 2D 游戏业界惯例分离"姿势"与"位移"。 + +业界做法(调研 2026-07-28): +- **位移不烘进序列帧**。连续位移动作几乎一律用 *in-place animation + 引擎代码驱动移动*, + 因为玩家要即时操控:跑动中转向应立刻响应,而不是等一段烘死的位移播完。平台游戏的跳跃 + 也是"几个姿势定格 + 引擎物理驱动上下",不是把抛物线画进像素。 + → 序列帧保持**原地**(脚线对齐),位移单独作为 root-motion 轨道交给引擎。 +- **逐帧时长比帧数更重要**("frame timing beats frame count")。业界常用: + idle 400–500ms/帧、walk 100–150ms、run 80–100ms、attack 起手 80–100ms 且**触点定格 + 150–200ms**。全程等时长会让动作发飘、没有重量感。 + +本模块只做几何与时长计算,纯 numpy,零 API。 +""" + +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = ["extract_root_motion", "frame_durations", "DEFAULT_FPS_MS"] + +# 各动作的基准单帧时长(ms),取业界常用区间的中值。 +DEFAULT_FPS_MS = { + "idle": 450, + "walk": 125, + "run": 90, + "jump": 110, + "attack": 90, + "hit": 90, +} + + +def extract_root_motion(frames: list[Image.Image], alpha_thr: int = 128) -> list[tuple[int, int]]: + """逐帧相对首帧的 (dx, dy) 位移,单位=像素,y 向上为正。 + + 以主体包围盒的**底边中心**(脚点)为参考点。序列帧本身保持原地时,这条轨道就是引擎 + 要施加的 root motion:jump 的 dy 是腾空高度,walk 的 dx 是前进量。 + """ + pts: list[tuple[float, float]] = [] + for f in frames: + a = np.asarray(f.convert("RGBA")) + ys, xs = np.where(a[:, :, 3] > alpha_thr) + pts.append(((xs.min() + xs.max()) / 2, float(ys.max())) if len(ys) else (np.nan, np.nan)) + arr = np.array(pts, dtype=np.float32) + if np.isnan(arr).any(): # 空帧用邻近值补 + idx = np.arange(len(arr)) + for c in range(2): + good = ~np.isnan(arr[:, c]) + arr[:, c] = np.interp(idx, idx[good], arr[good, c]) if good.any() else 0.0 + base = arr[0] + return [(int(round(p[0] - base[0])), int(round(base[1] - p[1]))) for p in arr] + + +def frame_durations( + action: str, n_frames: int, key_frame: int | None = None, hold_ms: int = 180 +) -> list[int]: + """逐帧时长(ms)。关键帧(触点 / 顶点)加长定格,其余用该动作的基准时长。 + + Args: + action: 动作名(取 :data:`DEFAULT_FPS_MS` 的基准时长,未知动作按 walk)。 + n_frames: 帧数。 + key_frame: 要定格的帧下标(attack 的触点、jump 的顶点);None 表示全程等时长。 + hold_ms: 关键帧时长,业界常用 150–200ms。 + """ + base = DEFAULT_FPS_MS.get(action, DEFAULT_FPS_MS["walk"]) + out = [base] * max(0, n_frames) + if key_frame is not None and 0 <= key_frame < n_frames: + out[key_frame] = max(base, hold_ms) + return out diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py new file mode 100644 index 00000000..dba82745 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/__init__.py @@ -0,0 +1,15 @@ +"""prompt:各动作的生成提示词与装配。""" + +from .actions import build_attack_prompt, build_idle_prompt +from .jump import JUMP_PHASES, build_jump_prompt +from .walk import WALK_BODY_FRONT, WALK_BODY_SIDE, build_walk_prompt + +__all__ = [ + "WALK_BODY_SIDE", + "WALK_BODY_FRONT", + "build_walk_prompt", + "JUMP_PHASES", + "build_jump_prompt", + "build_idle_prompt", + "build_attack_prompt", +] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py new file mode 100644 index 00000000..7a6538c2 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py @@ -0,0 +1,83 @@ +"""待机 / 攻击 i2v 提示词。 + +措辞迁自 windup-pipeline 已验证的 prompt_library(idle / slash),按本模块的 facing 分流改写。 + +- **idle**:循环类(tail_match)。只写躯干呼吸节律,武器与双脚显式锁定 —— 逐帧生成待机 + 只会抖不会呼吸,故走 i2v 或程序化 Idle-B。 +- **attack**:一次性类。四条已验证的锁定:①"one single committed motion"防复读; + ②剑长与握点固定;③剑在身前、刃面朝观者(防 Z 轴穿模与刀刃翻转);④终态回戒备并保持。 + 节奏(蓄力慢/挥砍快/触点定格)在抽帧做,不写进 prompt。 +""" + +from __future__ import annotations + +__all__ = ["build_idle_prompt", "build_attack_prompt"] + +_IDLE_SIDE = ( + "The character stands in place, seen from the side facing right: the chest breathes in one " + "slow, even rhythm, the ribcage expanding and easing back while the shoulders stay level and " + "settled at the same height, the torso rising and lowering in that same slow rhythm, " + "{weapon} resting steady at the side in a fixed grip, {garment} hanging and swaying in the " + "same rhythm, both boots planted firmly on the ground, weight centered, the character stays " + "in the same spot and keeps facing right." +) + +_IDLE_FRONT = ( + "The character stands in place facing the viewer: the chest breathes in one slow, even " + "rhythm, the ribcage expanding and easing back while the shoulders stay level and settled at " + "the same height, the torso rising and lowering in that same slow rhythm, {weapon} resting " + "steady at the side in a fixed grip, {garment} hanging and swaying in the same rhythm, both " + "boots planted firmly on the ground, weight centered, the character keeps FACING THE VIEWER " + "and stays in the same spot." +) + +_ATTACK_SIDE = ( + "Seen from the side facing right, the character makes ONE single committed attack, staying in " + "STRICT SIDE VIEW the whole time: starting coiled with the weight on the back foot, the body " + "leans forward and the weight surges onto the front foot, the arm sweeping {weapon} through " + "one smooth downward crescent arc from high behind the shoulder down across the front to full " + "extension low, {weapon} keeping its exact length and grip position and staying clearly in " + "front of the body with its flat side facing the viewer the whole way, {garment} swinging with " + "the motion, then the body settles back upright into guard and holds that stance, standing " + "steady. The torso and hips keep pointing to the right the entire time and the character never " + "turns toward or away from the viewer." +) + +_ATTACK_FRONT = ( + "Facing the viewer, the character makes ONE single committed attack: starting coiled with the " + "weight on the back foot, the whole body uncoils forward, the arm sweeping {weapon} through " + "one smooth arc across the front to full extension, {weapon} keeping its exact length and grip " + "position and staying clearly in front of the body with its flat side facing the viewer the " + "whole way, {garment} swinging with the motion, then the body settles back upright into guard " + "and holds that stance, standing steady and keeping FACING THE VIEWER." +) + +DEFAULT_WEAPON = "the sword" +DEFAULT_GARMENT = "the cape" + + +def _build(side: str, front: str, weapon: str, garment: str, feet: str, facing: str) -> str: + if facing not in ("side", "front"): + raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") + body = (side if facing == "side" else front).format(weapon=weapon, garment=garment) + return body.replace("boot", feet) if feet != "boot" else body + + +def build_idle_prompt( + weapon: str = DEFAULT_WEAPON, + garment: str = DEFAULT_GARMENT, + feet: str = "boot", + facing: str = "side", +) -> str: + """待机正文(循环类)。``facing`` 须与母版朝向一致。""" + return _build(_IDLE_SIDE, _IDLE_FRONT, weapon, garment, feet, facing) + + +def build_attack_prompt( + weapon: str = DEFAULT_WEAPON, + garment: str = DEFAULT_GARMENT, + feet: str = "boot", + facing: str = "side", +) -> str: + """攻击正文(一次性类)。``facing`` 须与母版朝向一致。""" + return _build(_ATTACK_SIDE, _ATTACK_FRONT, weapon, garment, feet, facing) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py new file mode 100644 index 00000000..dac08595 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py @@ -0,0 +1,63 @@ +"""跳跃 i2v 提示词(一次性动作,非循环)。 + +与 walk/run 的根本差别: +- **不循环**。跳跃是一段有始有终的动作,不能像步态那样抽单周期闭环。 +- **要拆状态**。游戏里跳跃是状态机:蓄力 → 上升 → 顶点 → 下降 → 落地缓冲;悬空时长由 + 物理决定、上升中可被打断,所以必须能分段播放,不能烘成一整段。 +- 提示词要写**"只做一次 + 终态保持"**,防 5s 内复读跳第二次(实测:写了仍会复读,故抽帧层 + 另有 first_action_end 兜底)。 +- **原地起跳、幅度适中**:水平位移交引擎做 root-motion,不烘进像素;幅度过大会让角色顶出 + 视频画面,且序列帧里角色被缩得很小。 + +朝向同 walk:必须与母版一致(side 横版 / front 俯视·2.5D)。 +""" + +from __future__ import annotations + +__all__ = ["JUMP_BODY_SIDE", "JUMP_BODY_FRONT", "JUMP_PHASES", "build_jump_prompt"] + +# 跳跃的五个状态(引擎侧按这个切段;顺序即时间顺序)。 +JUMP_PHASES = ("crouch", "rise", "apex", "fall", "land") + +JUMP_BODY_SIDE = ( + "The character performs ONE single jump in place, seen from the side facing right: " + "first the knees bend deep into a crouch and the arms drop back, then both boots push " + "off the ground and the whole body lifts straight upward a modest height with the legs " + "tucking up, the body reaches the top of the jump and hangs there for an instant with {garment} " + "floating upward, then the body falls back down with the legs reaching for the ground, " + "and both boots land together with the knees bending to absorb the impact, the weapon " + "stays held steady in a fixed grip the whole time. The character does this ONCE and " + "then stays standing upright in the landing spot, staying centered in frame." +) + +JUMP_BODY_FRONT = ( + "The character performs ONE single jump in place, facing the viewer: first the knees " + "bend deep into a crouch and the arms drop back, then both boots push off the ground " + "hard and the whole body launches straight upward with the knees tucking up toward the " + "camera, the body reaches the top of the jump and hangs there for an instant with " + "{garment} floating upward, then the body falls back down with the legs reaching for " + "the ground, and both boots land together with the knees bending to absorb the impact, " + "the weapon stays held steady in a fixed grip the whole time. The character keeps " + "FACING THE VIEWER, does this ONCE and then stays standing upright, centered in frame." +) + +DEFAULT_GARMENT = "the cape and tabard" + + +def build_jump_prompt( + garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: str = "side" +) -> str: + """按角色装备 + 母版朝向生成跳跃正文。 + + Args: + garment: 起跳时上飘的衣饰。 + feet: 落脚部件用词(替换 boot)。 + facing: "side" 或 "front",**必须与母版朝向一致**。 + """ + if facing not in ("side", "front"): + raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") + template = JUMP_BODY_SIDE if facing == "side" else JUMP_BODY_FRONT + body = template.format(garment=garment) + if feet != "boot": + body = body.replace("boot", feet) + return body diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py new file mode 100644 index 00000000..5d3c4a6e --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py @@ -0,0 +1,57 @@ +"""走路 i2v 提示词(视频路线)。 + +实测要点(Issue #35): +- 只写正向词、逐条写腿部可见动作(抬 / 摆 / 蹬 / 承重),锁死手持武器不乱动。 +- **提示词的朝向必须与母版朝向一致**。给正面母版喂侧走词(STRICT SIDE)会让模型靠"转身" + 调和图文矛盾——早期"正面母版必转身"的结论正是这么造成的。故按 facing 分流: + side(横版侧走)/ front(俯视·2.5D 朝观者行进),对应 Project.perspective。 +- "半侧"母版(头侧脸 + 身体略正)配 side 词,实测会被自然解析成正侧面走,不转身,够用。 +- 换角色只替换装备子句(如 骷髅:boot→骨足、cape→围巾),机制词保持不变。 +""" + +from __future__ import annotations + +__all__ = ["WALK_BODY_SIDE", "WALK_BODY_FRONT", "DEFAULT_GARMENT", "build_walk_prompt"] + +# 侧走(横版):整体向右推进 + 锁侧视。 +WALK_BODY_SIDE = ( + "The character walks steadily to the right through the open space, the whole body " + "advancing with every stride: the front boot lifts, swings forward and plants heel " + "first, the rear boot pushes off the ground, the hips and torso carry the weight " + "forward over the planted foot, {garment} swing with the steps, the weapon stays held " + "low and steady at the side in a fixed grip, the upper body stays calm and upright, " + "SIDE VIEW facing right the whole time, the legs clearly visible." +) + +# 正面走(俯视 / 2.5D):朝观者原地行进,身体始终正对观者、不转身。 +WALK_BODY_FRONT = ( + "The character walks in place toward the viewer, marching forward on the spot: each " + "boot lifts, swings forward and plants down in turn while the other pushes off, the " + "knees rise alternately toward the camera, the hips and shoulders sway naturally with " + "each step, {garment} sway with the steps, the weapon stays held low and steady in a " + "fixed grip, the upper body stays calm and upright, the character keeps FACING THE " + "VIEWER the whole time and stays centered in frame, both legs clearly visible." +) + +# 每个角色只替换 garment / feet 两处装备子句,机制词不动。 +DEFAULT_GARMENT = "the cape and tabard" + + +def build_walk_prompt( + garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: str = "side" +) -> str: + """按角色装备 + 母版朝向生成走路正文。 + + Args: + garment: 随步伐摆动的衣饰(如 "the cape and tabard" / "the red scarf and tabard")。 + feet: 落脚部件用词(如 "boot" / "bare bony foot"),替换机制句里的 boot。 + facing: "side"(横版侧走,母版朝侧向)或 "front"(俯视/2.5D,母版朝观者)。 + **必须与母版朝向一致**,否则模型会靠转身调和矛盾。 + """ + if facing not in ("side", "front"): + raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") + template = WALK_BODY_SIDE if facing == "side" else WALK_BODY_FRONT + body = template.format(garment=garment) + if feet != "boot": + body = body.replace("boot", feet) + return body diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py new file mode 100644 index 00000000..3489de23 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py @@ -0,0 +1,27 @@ +"""slicing:视频 → 帧序列。抽帧(extract)+ 选帧(周期 loop / 一次性 oneshot)。 + +视频路线里"从连续视频里挑出交付用的那几帧"这一步:循环类动作抽单步态周期(无缝 +loop),一次性动作裁动作区间。像素化 / 对齐 / 打包在 :mod:`..postprocess`。 +""" + +from .extract import extract_all_frames_bytes, extract_frames_bytes +from .loop import find_period, pick_cycle +from .oneshot import ( + find_motion_span, + first_action_end, + foot_line_series, + pick_oneshot, + split_jump_phases, +) + +__all__ = [ + "extract_frames_bytes", + "extract_all_frames_bytes", + "find_period", + "pick_cycle", + "find_motion_span", + "first_action_end", + "foot_line_series", + "pick_oneshot", + "split_jump_phases", +] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py new file mode 100644 index 00000000..f34f48cc --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py @@ -0,0 +1,62 @@ +"""视频抽帧(切片层的解码入口)。 + +承接视频路线(Issue #35):i2v 产出的短视频步态真实但为插画质感。本模块只负责 +把视频 bytes 解码成帧序列;选帧(周期 / 一次性)见 :mod:`.loop` / :mod:`.oneshot`, +像素化 / 对齐 / 打包见 :mod:`..postprocess`。抽帧后端(imageio/ffmpeg)函数内惰性, +模块导入零成本、CI 可收集。 +""" + +from __future__ import annotations + +import os +import tempfile + +from PIL import Image + +__all__ = ["extract_frames_bytes", "extract_all_frames_bytes"] + + +def extract_frames_bytes(video: bytes, n: int) -> list[Image.Image]: + """从视频 bytes 均匀抽 ``n`` 帧(供后端 strategy 用,provider 返回的是 bytes)。""" + with tempfile.NamedTemporaryFile(suffix=".mp4", delete=True) as f: + f.write(video) + f.flush() + return _extract_frames(f.name, n) + + +def extract_all_frames_bytes(video: bytes, cap: int = 150) -> list[Image.Image]: + """抽视频全部帧(至多 ``cap``,均匀降采样),供周期检测用。""" + with tempfile.NamedTemporaryFile(suffix=".mp4", delete=True) as f: + f.write(video) + f.flush() + return _extract_frames(f.name, cap) + + +def _extract_frames(video_path: str, n: int) -> list[Image.Image]: + """从视频均匀抽 ``n`` 帧。优先 imageio,回退系统 ffmpeg。""" + try: + import imageio.v3 as iio + + all_frames = iio.imread(video_path, plugin="pyav") # (T, H, W, C) + total = len(all_frames) + m = min(n, total) + idx = [round(i * (total - 1) / max(1, m - 1)) for i in range(m)] + return [Image.fromarray(all_frames[i]).convert("RGBA") for i in idx] + except Exception: + pass + + import glob + import subprocess + + with tempfile.TemporaryDirectory() as tmp: + subprocess.run( + ["ffmpeg", "-y", "-i", video_path, "-vsync", "0", + os.path.join(tmp, "f_%04d.png")], + capture_output=True, check=True, + ) + files = sorted(glob.glob(os.path.join(tmp, "f_*.png"))) + if not files: + raise RuntimeError("抽帧失败:视频无可解码帧") + m = min(n, len(files)) + idx = [round(i * (len(files) - 1) / max(1, m - 1)) for i in range(m)] + return [Image.open(files[i]).convert("RGBA").copy() for i in idx] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py new file mode 100644 index 00000000..b524986b --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py @@ -0,0 +1,148 @@ +"""循环闭合(最后一公里之一,Issue #21)—— 从 i2v 密集帧里抽正好一个步态周期,做无缝 loop。 + +i2v 的 5s 视频里含 ~2-3 个步态周期,均匀抽 N 帧跨多个周期 → 首尾接缝跳。做法: +帧自相似检测周期(灰度小图,frame[i] 与 frame[i+p] 差最小的 p = 一个周期), +再在一个周期内均匀取 N 帧 → frame[N-1] 的下一拍≈frame[0],循环自然闭合。 +纯 numpy / PIL,零 API。 +""" +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = ["find_period", "pick_cycle"] + +_SMALL = 48 # 周期检测用的灰度小图边长 + + +def _gray(frames: list[Image.Image]) -> list[np.ndarray]: + return [np.asarray(f.convert("L").resize((_SMALL, _SMALL)), dtype=np.float32) for f in frames] + + +def _deskew(gs: list[np.ndarray]) -> list[np.ndarray]: + """消掉角色的整体水平平移再比对。i2v 里角色会横着挪(实测走位达画宽 18%),而下游 + :func:`postprocess.pack.align_bottom_center` 本来就会逐帧重新居中 —— 不消平移的话, + d(p) 被"挪了多远"主导、随 p 单调上升,真周期的凹陷被压平(骷髅走路真周期 56 完全消失, + 只剩 22 的假凹陷)。做法:中值背景差取主体列范围,把质心 roll 到画面中心。""" + a = np.stack(gs) + d = np.abs(a - np.median(a, axis=0)) + thr = max(4.0, float(np.percentile(d, 99)) * 0.25) + out = [] + for g, di in zip(gs, d): + cols = (di > thr).any(0) + cx = float(np.where(cols)[0].mean()) if cols.any() else _SMALL / 2 + out.append(np.roll(g, int(round(_SMALL / 2 - cx)), axis=1)) + return out + + +def _dmat(gs: list[np.ndarray]) -> np.ndarray: + """全帧对距离矩阵(48x48 灰度平均绝对差),后续所有判据共用,只算一次。""" + flat = np.stack(gs).reshape(len(gs), -1) + return np.stack([np.abs(flat - flat[i]).mean(1) for i in range(len(gs))]) + + +def _curve(M: np.ndarray, pmin: int, pmax: int) -> dict[int, float]: + n = len(M) + return {p: float(np.mean([M[i, i + p] for i in range(n - p)])) for p in range(pmin, pmax + 1)} + + +def _prominent_period(curve: dict[int, float], scale: float) -> tuple[int, float] | None: + """只认"内部局部极小 + 凹陷够深"的 p。曲线单调(无周期)时返回 None,而不是交出边界值。""" + ps = sorted(curve) + best = None + for j in range(1, len(ps) - 1): + p = ps[j] + if not (curve[p] <= curve[ps[j - 1]] and curve[p] <= curve[ps[j + 1]]): + continue + w = max(3, p // 2) + lo = [curve[q] for q in ps if p - w <= q < p] + hi = [curve[q] for q in ps if p < q <= p + w] + if not lo or not hi: + continue + prom = (min(max(lo), max(hi)) - curve[p]) / max(scale, 1e-6) + if best is None or prom > best[1]: + best = (p, prom) + return best + + +def find_period(frames: list[Image.Image], pmin: int | None = None, pmax: int | None = None) -> int: + """自相似求步态周期(帧数)。frame[i] 与 frame[i+p] 平均差最小的 p。""" + n = len(frames) + gs = _gray(frames) + pmin = pmin or max(4, n // 6) + pmax = pmax or max(pmin + 1, n // 2) + best_p, best_d = pmin, float("inf") + for p in range(pmin, pmax + 1): + d = float(np.mean([np.abs(gs[i] - gs[i + p]).mean() for i in range(n - p)])) + if d < best_d: + best_d, best_p = d, p + return best_p + + +def _offsets(P: int, n: int) -> list[int]: + return [round(k * P / n) for k in range(n)] + + +def pick_cycle(frames: list[Image.Image], n: int) -> list[Image.Image]: + """从密集帧里抽正好一个步态周期的 N 帧(无缝 loop)。帧数不足则原样返回。 + + 周期检测的三个坑(实测 5 段真 i2v 视频): + 1. d(p) 会被"角色整体平移 + 画质漂移"抬成单调上升 —— 直接取 argmin 会滑到搜索窗边界 + 交出**假周期**(走路视频: p 恒等于 pmin)。故只认有足够凹陷深度的内部局部极小, + 测不到就判"无周期",退化成全片均匀取(不硬闭环) —— 硬闭环反而制造接缝。 + 2. 搜索窗要覆盖真周期: 上界 n//2 会把 5s 里只有 ~2 个周期的待机挡在窗外(实测真周期 62 + > pmax 60,于是取到边界值);下界要 >= 目标帧数 n,否则 round(k*p/n) 直接产出重复帧。 + 3. 谐波: 平移偏置让 d(p) 偏爱短 lag,常选中真周期的 1/2(骷髅走路: 取到 22,真周期 ~56), + 半周期闭环 = 末帧接回首帧时左右腿瞬间互换 = 肉眼可见的"跳一下"。故在 p 的整数倍里 + 按**归一化接缝**(末→首 差 / 组内相邻差均值)复选,并保证取样索引互不重复。 + 不要在这里接 :mod:`.quality` 的死帧判据 —— 2026-08-07 消融实测(4 段真 i2v)证否: + ① 用 ``active_span`` 先掐头尾冻结段:``_deskew`` 已经解决了"曲线被不动的帧压平"这个 + 问题,再掐只是缩小 i0 的搜索空间、丢掉更优相位起点(奔跑 seam 0.81→2.00); + ② 取样后按 ``dead_frame_mask`` 就近避让死帧:i2v 死帧占比常达一半(24fps 容器隔帧复制, + 实测 59/121 与 63/121),避让会系统性打乱相位均匀性,反而选中更多死帧(1→3)。 + 候选评分里的 ``a < 0.5 * scale`` 已经排掉"几乎不动"的窗口,够用。 + quality 留作诊断/报告用,不进选帧。 + """ + total = len(frames) + if total <= n: + return frames + M = _dmat(_deskew(_gray(frames))) + adj = np.array([M[i, i + 1] for i in range(total - 1)]) + scale = float(np.median(adj)) + + pmin = max(6, min(n, total // 6)) + pmax = min(total - 3, max(pmin + 2, int(total * 0.6))) + got = _prominent_period(_curve(M, pmin, pmax), scale) + if got is None or got[1] < 0.25: # 测不到可信周期 → 不硬闭环 + idx = [round(k * (total - 1) / n) for k in range(n)] + return [frames[i] for i in idx] + + p = got[0] + cands = [] + for k in range(1, 4): # 在基周期的整数倍里复选 + P = k * p + if P > total - 2: + break + offs = _offsets(P, n) + if len(set(offs)) < n: # 该倍数取不出 n 个不同相位 + continue + best = None + for i0 in range(total - P): + idx = [i0 + o for o in offs] + a = float(np.mean([M[idx[j], idx[j + 1]] for j in range(n - 1)])) + if a < 0.5 * scale: # 窗口几乎不动(i2v 尾部常停顿)→ 弃 + continue + score = M[idx[-1], idx[0]] / max(a, 1e-6) # 归一化接缝 + if best is None or score < best[0]: + best = (score, idx) + if best: + cands.append((k, best[0], best[1])) + if not cands: + idx = [round(k * (total - 1) / n) for k in range(n)] + return [frames[i] for i in idx] + # 倍数越大 = 一个 loop 里塞进越多周期 = 每周期帧数越少(动作变糙),故**优先最小倍数**: + # 取第一个"已经闭合"的倍数(归一化接缝 <= 1.2,即末→首的跳幅不超过一个正常帧间步长), + # 都不闭合才退而取最优 —— 这一条专治"取到半周期 → 接缝处左右腿瞬间互换"。 + ok = [c for c in cands if c[1] <= 1.2] + pick = ok[0] if ok else min(cands, key=lambda c: c[1]) + return [frames[i] for i in pick[2]] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py new file mode 100644 index 00000000..990604bf --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py @@ -0,0 +1,189 @@ +"""一次性动作(jump / attack / hit)的抽帧:裁动作起止 + 按状态切段。 + +与循环类(idle/walk/run)的根本差别: +- 循环类用 :mod:`.loop` 找步态周期抽单周期闭环;一次性动作**不能闭环** —— 首尾姿态不同, + 强行闭环会把落地帧接回蓄力帧,读起来是抽搐。 +- i2v 出的 5s 视频里,真正的动作往往只占中间一段(前后是静止的起手/终态保持),直接均匀 + 抽帧会浪费一半帧在不动的地方 → 需要先**裁到动作发生的区间**。 +- jump 还要进一步**按状态切段**(蓄力/上升/顶点/下降/落地),因为引擎里悬空时长由物理 + 决定、上升中可被打断,必须能分段播放。 + +纯 numpy / PIL,零 API。 +""" + +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = [ + "find_motion_span", + "first_action_end", + "pick_oneshot", + "split_jump_phases", + "foot_line_series", +] + + +def _frame_energy(frames: list[Image.Image], size: int = 64) -> np.ndarray: + """逐帧与前一帧的差异强度(灰度小图),长度 = len(frames)-1。""" + gs = [np.asarray(f.convert("L").resize((size, size)), dtype=np.float32) for f in frames] + return np.array([np.abs(gs[i + 1] - gs[i]).mean() for i in range(len(gs) - 1)]) + + +def find_motion_span(frames: list[Image.Image], rel_thr: float = 0.25) -> tuple[int, int]: + """定位"动作真正发生"的帧区间 ``[start, end]``(含端点)。 + + 以帧间差异强度超过峰值 ``rel_thr`` 倍的最早/最晚位置为界,并各留一帧余量。 + 静止的起手与终态保持会被裁掉。 + """ + if len(frames) < 3: + return 0, len(frames) - 1 + e = _frame_energy(frames) + peak = float(e.max()) + if peak <= 1e-6: + return 0, len(frames) - 1 + active = np.flatnonzero(e >= peak * rel_thr) + if not len(active): + return 0, len(frames) - 1 + start = max(0, int(active[0]) - 1) + end = min(len(frames) - 1, int(active[-1]) + 2) + return start, end + + +def _airborne_end(frames: list[Image.Image], start: int, end: int, tol: float = 6.0) -> int: + """腾空类(jump)的结束:脚线越过最高点后**首次回到地面**。 + + 几何信号,明确无歧义 —— 比任何"能量安静"判据都稳。 + """ + y = foot_line_series(frames[start : end + 1]) + if len(y) < 4: + return end + apex = int(np.argmin(y)) + ground = float(np.median([y[0], y[-1]])) + back = np.flatnonzero(y[apex:] >= ground - tol) + return min(end, start + apex + int(back[0]) + 2) if len(back) else end + + +def _swing_end(frames: list[Image.Image], start: int, end: int, + drop_ratio: float = 0.35, recover: int = 2) -> int: + """挥击类(attack/hit)的结束:能量越过峰值后**首次跌到峰值的 ``drop_ratio``**,再留收势余量。 + + 挥击是"蓄力 → 峰值 → 收势"的单峰结构,收势很短,故用"跌破比例 + 固定余量"即可; + 不要求长时间静止 —— 实测挥砍收势段的能量并不干净(视频压缩噪点),等不到静止平台。 + """ + e = _frame_energy(frames[start : end + 1]) + if len(e) < 4: + return end + peak_i = int(np.argmax(e)) + thr = float(e.max()) * drop_ratio + for i in range(peak_i + 1, len(e)): + if e[i] < thr: + return min(end, start + i + recover) + return end + + +def first_action_end( + frames: list[Image.Image], start: int, end: int, kind: str = "swing" +) -> int: + """在 ``[start, end]`` 内找**第一次**动作的结束帧,按动作物理分流。 + + i2v 常在 5s 里把一次性动作**复读第二遍**(实测:提示词写了 "ONCE",兽人跳了两次、 + 挥砍也挥了两次),不裁会把两次动作压进一套序列帧。 + + 不同动作的"结束"信号本质不同,**一个通用判据管不了两种**(实测踩过): + - ``kind="airborne"``(jump):脚线回到地面 —— 几何、无歧义。 + - ``kind="swing"``(attack/hit):能量跌破峰值比例 + 收势余量。 + + 三个已验证无效的通用解法(别再试):①只看"帧间安静" → 在跳跃**顶点悬停**处误触发, + 把动作截在半空;②要求静止段足够长 → 挥砍收势并不干净(压缩噪点),等不到,完全不裁; + ③找"回到起始姿态"的谷底 → 收势姿态(戒备)与起始姿态(蓄力)不同,回不到低位。 + """ + if end - start < 4: + return end + return (_airborne_end if kind == "airborne" else _swing_end)(frames, start, end) + + +def pick_oneshot( + frames: list[Image.Image], n: int, first_only: bool = True, kind: str = "swing" +) -> list[Image.Image]: + """一次性动作抽 ``n`` 帧:裁到动作区间 → 只留第一次动作 → 区间内均匀取(不闭环)。 + + ``first_only`` 默认开:防 i2v 在 5s 内复读第二遍动作被一起抽进来。 + ``kind``:``"airborne"``(jump,按脚线回地判结束)或 ``"swing"``(attack/hit,按能量跌破判)。 + """ + if len(frames) <= n: + return frames + start, end = find_motion_span(frames) + if first_only: + end = max(start + 1, first_action_end(frames, start, end, kind=kind)) + span = frames[start : end + 1] + if len(span) <= n: + return span + idx = [round(i * (len(span) - 1) / (n - 1)) for i in range(n)] + return [span[i] for i in idx] + + +def _subject_rows(frame: Image.Image, alpha_thr: int = 128, bg_tol: int = 60) -> np.ndarray: + """主体所在的行下标。有真实 alpha 用 alpha;**全不透明帧**(原始视频帧)按四角背景色判。 + + 必须兼容不透明帧:抽帧阶段拿到的是原始视频帧,还没抠图,只看 alpha 会把整幅当主体、 + 脚线恒定,导致腾空判据立刻误判"已落地"(实测踩过,跳跃被裁在起跳前)。 + """ + arr = np.asarray(frame.convert("RGBA")) + alpha = arr[:, :, 3] + if not alpha.min() > alpha_thr: + return np.where(alpha > alpha_thr)[0] + rgb = arr[:, :, :3].astype(np.int16) + corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) + bg = np.median(corners, axis=0) + return np.where(np.abs(rgb - bg).sum(axis=2) > bg_tol)[0] + + +def foot_line_series(frames: list[Image.Image], alpha_thr: int = 128) -> np.ndarray: + """逐帧主体**底边** y 坐标(脚线)。跳跃时脚线先降(蹲)、再升(腾空)、再落回。""" + out = [] + for f in frames: + ys = _subject_rows(f, alpha_thr) + out.append(float(ys.max()) if len(ys) else np.nan) + arr = np.array(out, dtype=np.float32) + if np.isnan(arr).any(): # 空帧用邻近值补 + idx = np.arange(len(arr)) + good = ~np.isnan(arr) + if good.any(): + arr = np.interp(idx, idx[good], arr[good]) + else: + arr = np.zeros_like(arr) + return arr + + +def split_jump_phases(frames: list[Image.Image]) -> dict[str, list[int]]: + """按脚线轨迹把跳跃切成 crouch / rise / apex / fall / land 五段,返回每段的帧下标。 + + 判据:脚线 y 越小 = 人越高。最高点(y 最小)即 apex;起跳前脚线最低(蹲)处为 crouch + 结束;之后到 apex 为 rise,apex 之后到脚线回到地面高度为 fall,余下为 land。 + 只依赖几何,不依赖模型。 + """ + n = len(frames) + if n < 5: + return {"rise": list(range(n))} + y = foot_line_series(frames) + apex = int(np.argmin(y)) # 最高点 + ground = float(np.median([y[0], y[-1]])) # 地面脚线 + # 起跳点:apex 之前脚线最低(数值最大 = 蹲得最深)的位置 + takeoff = int(np.argmax(y[: max(1, apex)])) if apex > 0 else 0 + # 落地点:apex 之后脚线首次回到地面附近 + after = y[apex:] + back = np.flatnonzero(after >= ground - 2) + landing = apex + int(back[0]) if len(back) else n - 1 + + apex_lo = max(takeoff + 1, apex - 1) + apex_hi = min(landing - 1, apex + 1) + phases = { + "crouch": list(range(0, takeoff + 1)), + "rise": list(range(takeoff + 1, apex_lo)), + "apex": list(range(apex_lo, apex_hi + 1)), + "fall": list(range(apex_hi + 1, landing)), + "land": list(range(landing, n)), + } + return {k: v for k, v in phases.items() if v} diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py new file mode 100644 index 00000000..4c851c6c --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py @@ -0,0 +1,108 @@ +"""帧质量诊断:死帧(重复/冻结)与坏帧(糊/伪影)的判据。 + +与 :mod:`.loop` 的分工:loop 负责选帧,本模块只负责"这帧是什么成色"。 +2026-08-05 实测(6 个真 i2v 视频):**没有一帧糊帧**,但死帧极多——24fps 容器里 +隔一帧就是复制帧(奔跑视频奇偶帧差比 22.5x),有效内容帧率只有 ~11-14fps; +且普遍有起步冻结(头部)或动作衰减停住(尾部)。所以"坏帧"与"死帧"必须分开判、 +分开统计——只判一型会漏掉一半。 +""" +from __future__ import annotations + +import numpy as np + +__all__ = ["active_span", "blur_ratio", "dead_frame_mask", "frame_deltas"] + +_SMALL = 48 + + +def _gray(frames): + return [np.asarray(f.convert("L").resize((_SMALL, _SMALL)), dtype=np.float32) for f in frames] + + +def frame_deltas(frames) -> np.ndarray: + """d[i] = |f_i - f_{i-1}| 均值,d[0]=0。小图(48x48 灰度),CPU 便宜。""" + gs = _gray(frames) + return np.array([0.0] + [float(np.abs(gs[i] - gs[i - 1]).mean()) for i in range(1, len(gs))]) + + +def dead_frame_mask(frames, ratio: float = 0.35, floor: float = 0.25) -> np.ndarray: + """死帧 = 相对前一帧几乎没有新内容。两型必须都判,缺一漏一半: + + A 型「隔帧死」: d[i] < ratio * max(d[i-1], d[i+1]) + i2v 常见"有效帧率减半"——24fps 容器里隔一帧就是复制帧。只用全局阈值抓不到, + 因为半数帧是死帧时 median 本身落在死帧堆里(实测 run 奇偶比 9.9x 却报 0 死帧)。 + B 型「持续冻结」: d[i] < floor * p75(d) + 视频头部的 i2v 起步冻结、尾部的动作衰减停住。只用 A 型抓不到, + 因为连续冻结段里邻居同样低,比值≈1(实测 attack 尾部 9 帧全漏)。 + """ + d = frame_deltas(frames) if not isinstance(frames, np.ndarray) else frames + n = len(d) + p75 = float(np.percentile(d[1:], 75)) if n > 1 else 0.0 + m = np.zeros(n, dtype=bool) + for i in range(1, n): + nb = [d[j] for j in (i - 1, i + 1) if 1 <= j < n] + if nb and d[i] < ratio * max(nb): + m[i] = True + if d[i] < floor * p75: + m[i] = True + return m + + +def active_span(frames, floor: float = 0.25, min_run: int = 3) -> tuple[int, int]: + """掐掉头尾的**持续**冻结段,返回 [s, e](闭区间)。中间的隔帧死不动。""" + d = frame_deltas(frames) + n = len(d) + p75 = float(np.percentile(d[1:], 75)) if n > 1 else 0.0 + low = d < floor * p75 + s, e = 0, n - 1 + r = 0 + for i in range(1, n): # 头部 + if low[i]: + r += 1 + else: + break + if r >= min_run: + s = r + r = 0 + for i in range(n - 1, 0, -1): # 尾部 + if low[i]: + r += 1 + else: + break + if r >= min_run: + e = n - 1 - r + if e - s < 4: # 掐过头就放弃 + return 0, n - 1 + return s, e + + +def blur_ratio(frames, ps: int = 32) -> np.ndarray: + """逐帧「静止区清晰度 / 前后帧同区清晰度」。<1 = 这帧自己糊了,与动作快慢无关。""" + def _pm(a): + h, w = a.shape + H, W = max(ps, h // ps * ps), max(ps, w // ps * ps) + a = a[:H, :W] + return a.reshape(H // ps, ps, W // ps, ps).mean(axis=(1, 3)) + + def _ag(g): + gx = np.zeros_like(g) + gy = np.zeros_like(g) + gx[:, 1:-1] = np.abs(g[:, 2:] - g[:, :-2]) * .5 + gy[1:-1, :] = np.abs(g[2:, :] - g[:-2, :]) * .5 + return np.maximum(gx, gy) + + gs = [np.asarray(f.convert("L"), np.float32) for f in frames] + sharp = np.stack([_pm(_ag(g)) for g in gs]) + out = np.ones(len(gs), np.float32) + for i in range(1, len(gs) - 1): + mv = np.maximum(_pm(np.abs(gs[i] - gs[i - 1])), _pm(np.abs(gs[i] - gs[i + 1]))) + ref = .5 * (sharp[i - 1] + sharp[i + 1]) + m = (mv < 2.5) & (ref > 3.0) + if m.sum() < 4: + cand = ref > 3.0 + if cand.sum() < 4: + continue + k = max(4, int(cand.sum() * .25)) + m = cand & (mv <= np.sort(mv[cand])[:k].max()) + out[i] = float(np.median(sharp[i][m] / np.maximum(ref[m], 1e-6))) + return out diff --git a/backend/tests/test_loop.py b/backend/tests/test_loop.py new file mode 100644 index 00000000..4d447a9a --- /dev/null +++ b/backend/tests/test_loop.py @@ -0,0 +1,46 @@ +"""循环闭合(周期检测 + 单周期取帧)测试 —— 纯 CV,无需联网。""" + +from PIL import Image + +from windup_ai_engine.slicing import find_period, pick_cycle + + +def _periodic_frames(period: int, cycles: int) -> list[Image.Image]: + """构造已知周期的帧序列:亮度按周期正弦变化(每帧一张纯灰图)。""" + import math + + frames = [] + for i in range(period * cycles): + v = int(128 + 100 * math.sin(2 * math.pi * i / period)) + frames.append(Image.new("RGB", (48, 48), (v, v, v))) + return frames + + +def test_find_period_detects_known_period(): + frames = _periodic_frames(period=20, cycles=5) + p = find_period(frames) + assert abs(p - 20) <= 1 # 检出周期 ≈ 真值 + + +def test_pick_cycle_returns_n_frames(): + frames = _periodic_frames(period=20, cycles=5) + out = pick_cycle(frames, 8) + assert len(out) == 8 + + +def test_pick_cycle_passthrough_when_too_few(): + frames = _periodic_frames(period=4, cycles=1) # 4 帧 < 8 + assert pick_cycle(frames, 8) is frames + + +def test_pick_cycle_closes_the_loop(): + # 取出的一周期,末帧的下一拍应接近首帧(亮度差小) + import numpy as np + + frames = _periodic_frames(period=20, cycles=5) + out = pick_cycle(frames, 8) + first = np.asarray(out[0].convert("L"), float) + last = np.asarray(out[-1].convert("L"), float) + step = np.abs(np.asarray(out[1].convert("L"), float) - first).mean() + seam = np.abs(last - first).mean() + assert seam <= step * 2 + 5 # 回接缝不显著大于一个正常步幅 diff --git a/backend/tests/test_oneshot.py b/backend/tests/test_oneshot.py new file mode 100644 index 00000000..ea1cde95 --- /dev/null +++ b/backend/tests/test_oneshot.py @@ -0,0 +1,68 @@ +"""一次性动作抽帧(裁动作区间 / 跳跃状态切段)测试 —— 纯 CV,无需联网。""" + +import numpy as np +from PIL import Image + +from windup_ai_engine.slicing import ( + find_motion_span, + foot_line_series, + pick_oneshot, + split_jump_phases, +) + + +def _figure_at(y_bottom: int, size: int = 64, h: int = 20) -> Image.Image: + """在指定底边高度画一个方块"角色"(RGBA,其余透明)。""" + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + arr = np.asarray(img).copy() + top = max(0, y_bottom - h) + arr[top:y_bottom, size // 2 - 4 : size // 2 + 4] = (200, 60, 60, 255) + return Image.fromarray(arr, "RGBA") + + +def _jump_sequence() -> list[Image.Image]: + """合成跳跃:静止 → 蹲(底边下移)→ 升 → 顶点 → 落 → 静止。""" + ground, low, apex = 50, 52, 30 + ys = [ground] * 3 + [low, low] + [44, 38, apex, apex, 38, 44] + [ground] * 3 + return [_figure_at(y) for y in ys] + + +def test_find_motion_span_trims_static_head_and_tail(): + frames = _jump_sequence() + start, end = find_motion_span(frames) + assert start >= 1 # 前面的静止帧被裁掉 + assert end <= len(frames) - 2 # 后面的静止帧被裁掉 + assert end > start + + +def test_pick_oneshot_returns_n_and_does_not_wrap(): + frames = _jump_sequence() + out = pick_oneshot(frames, 6) + assert len(out) == 6 + # 一次性动作不闭环:首尾姿态应不同(闭环的话会几乎一样) + first = np.asarray(out[0].convert("L"), float) + last = np.asarray(out[-1].convert("L"), float) + assert np.abs(first - last).mean() >= 0 + + +def test_foot_line_tracks_height(): + frames = _jump_sequence() + y = foot_line_series(frames) + assert y.argmin() in range(6, 10) # 最高点(y 最小)落在顶点附近 + assert y[0] > y.min() # 起始在地面,低于顶点 + + +def test_split_jump_phases_covers_all_frames_in_order(): + frames = _jump_sequence() + phases = split_jump_phases(frames) + assert "apex" in phases + idx = [i for seg in phases.values() for i in seg] + assert sorted(idx) == list(range(len(frames))) # 不重不漏 + # apex 段应在 rise 之后、fall 之前 + if "rise" in phases and "fall" in phases: + assert max(phases["rise"]) < min(phases["apex"]) + assert max(phases["apex"]) < min(phases["fall"]) + + +def test_split_jump_phases_short_input_is_safe(): + assert split_jump_phases([_figure_at(50)] * 3) diff --git a/backend/tests/test_pixelate.py b/backend/tests/test_pixelate.py new file mode 100644 index 00000000..11e5bdae --- /dev/null +++ b/backend/tests/test_pixelate.py @@ -0,0 +1,103 @@ +"""像素化后处理测试(纯 CV,无需联网 / API)。""" + +import numpy as np +from PIL import Image + +from windup_ai_engine.postprocess import ( + pixelate_frames, + sprite_sheet, + to_pixel_art, +) + + +def _synthetic_char(size=256, box=(80, 40, 176, 220)) -> Image.Image: + """透明底上画一个不透明矩形"角色",四周留透明边。""" + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + arr = np.asarray(img).copy() + x0, y0, x1, y1 = box + arr[y0:y1, x0:x1] = (200, 60, 60, 255) + # 加一点颜色变化,让色板量化有意义 + arr[y0:y1, x0 : (x0 + x1) // 2] = (60, 120, 200, 255) + return Image.fromarray(arr, "RGBA") + + +def test_to_pixel_art_targets_height_and_keeps_ratio(): + src = _synthetic_char() # 主体 96x180 + out = to_pixel_art(src, target_h=60, palette_size=16) + assert out.height == 60 + # 主体宽高比 96/180 → 目标宽 ≈ 60*96/180 = 32 + assert abs(out.width - 32) <= 1 + assert out.mode == "RGBA" + + +def test_to_pixel_art_crops_to_alpha_bbox(): + """输出应裁到主体包围盒:透明边被切掉,首列即主体。""" + out = to_pixel_art(_synthetic_char(), target_h=90, palette_size=16) + alpha = np.asarray(out)[:, :, 3] + assert alpha.max() == 255 # 有实心主体 + # 顶行与左列应落在主体上(已裁边),而非全透明 + assert alpha[0, :].max() > 0 + assert alpha[:, 0].max() > 0 + + +def test_to_pixel_art_reduces_palette(): + out = to_pixel_art(_synthetic_char(), target_h=80, palette_size=8) + rgb = np.asarray(out.convert("RGB")).reshape(-1, 3) + colors = np.unique(rgb, axis=0) + assert len(colors) <= 8 + + +def test_pixelate_frames_uniform_height_packs_to_sheet(): + frames = pixelate_frames([_synthetic_char() for _ in range(4)], target_h=48, palette_size=16) + assert all(f.height == 48 for f in frames) + sheet = sprite_sheet(frames) + assert sheet.height == 48 + assert sheet.width == sum(f.width for f in frames) + + +def test_to_pixel_art_rejects_bad_height(): + import pytest + + with pytest.raises(ValueError): + to_pixel_art(_synthetic_char(), target_h=0) + + +def _pixel_art(block=8, logical_h=20, bg=(255, 255, 255)) -> Image.Image: + """合成像素画:每个逻辑像素放大成 block×block 方块,白底(模拟母版)。""" + colors = [(200, 60, 60), (60, 120, 200), (40, 160, 90)] + small = np.full((logical_h, logical_h // 2, 3), bg, dtype=np.uint8) + for y in range(4, logical_h - 4): + for x in range(2, logical_h // 2 - 2): + small[y, x] = colors[(x + y) % len(colors)] + img = Image.fromarray(small, "RGB").resize( + (small.shape[1] * block, logical_h * block), Image.NEAREST + ) + return img.convert("RGBA") + + +def test_detect_pixel_size_finds_block(): + from windup_ai_engine.postprocess import detect_pixel_size + + assert detect_pixel_size(_pixel_art(block=8)) == 8 + assert detect_pixel_size(_pixel_art(block=12)) == 12 + + +def test_master_pixel_spec_gives_logical_height_and_palette(): + from windup_ai_engine.postprocess import master_pixel_spec + + logical_h, palette = master_pixel_spec(_pixel_art(block=8, logical_h=20)) + assert 10 <= logical_h <= 14 # 主体(去掉白边)约 12 个逻辑像素高 + assert 2 <= len(palette) <= 32 + # 色板不应被白底/抗锯齿近白色占据 + assert not (palette.astype(int).sum(axis=1) > 700).all() + + +def test_palette_lock_restricts_output_colors(): + """锁色板后,输出颜色必须全部来自给定色板(用于消掉压缩灰颗粒)。""" + palette = np.array([[200, 60, 60], [60, 120, 200]], dtype=np.uint8) + noisy = _synthetic_char() + out = to_pixel_art(noisy, target_h=24, palette=palette) + rgb = np.asarray(out.convert("RGB")).reshape(-1, 3) + used = np.unique(rgb, axis=0) + for c in used: + assert (c == palette).all(axis=1).any(), f"{c} 不在色板内" diff --git a/backend/uv.lock b/backend/uv.lock index bfcbe78f..b907d468 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -14,7 +14,6 @@ members = [ dev = [ { name = "import-linter", specifier = ">=2.0" }, { name = "pytest", specifier = ">=8.0" }, - { name = "pytest-cov", specifier = ">=5.0" }, { name = "ruff", specifier = ">=0.6" }, ] @@ -50,69 +49,29 @@ wheels = [ ] [[package]] -name = "bcrypt" -version = "5.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be" }, - { url = "https://mirrors.aliyun.com/pypi/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493" }, - { url = "https://mirrors.aliyun.com/pypi/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10" }, - { url = "https://mirrors.aliyun.com/pypi/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927" }, +name = "av" +version = "18.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c" }, ] [[package]] @@ -206,117 +165,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, ] -[[package]] -name = "coloredlogs" -version = "15.0.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "humanfriendly" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934" }, -] - -[[package]] -name = "coverage" -version = "7.15.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921" }, - { url = "https://mirrors.aliyun.com/pypi/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734" }, - { url = "https://mirrors.aliyun.com/pypi/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84" }, -] - [[package]] name = "distro" version = "1.9.0" @@ -326,28 +174,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, ] -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af" }, -] - -[[package]] -name = "email-validator" -version = "2.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "dnspython" }, - { name = "idna" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4" }, -] - [[package]] name = "fastapi" version = "0.139.2" @@ -364,14 +190,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c" }, ] -[[package]] -name = "flatbuffers" -version = "25.12.19" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4" }, -] - [[package]] name = "greenlet" version = "3.5.4" @@ -565,24 +383,25 @@ wheels = [ ] [[package]] -name = "humanfriendly" -version = "10.0" +name = "idna" +version = "3.18" source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" }, ] [[package]] -name = "idna" -version = "3.18" +name = "imageio" +version = "2.37.4" source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/48/62/aa770a9307508d2a2a2c62d536a49347bffe9e55322db27838d3c93d0b07/imageio-2.37.4.tar.gz", hash = "sha256:e45cbc5e83502047fb138f7f585f7f105a136a57eea5f4b3cfc6ce1b52720bd3" } wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/2d/ca050652104bab2cf55e569db2a178b1b61cb041fef28307f2db383f6d9f/imageio-2.37.4-py3-none-any.whl", hash = "sha256:1ab2e22c8debf700f24c3ac43e8f95f3b3a8110c83b93411e97b4b0b2cd1c7e6" }, ] [[package]] @@ -609,74 +428,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, ] -[[package]] -name = "jiter" -version = "0.16.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09" }, - { url = "https://mirrors.aliyun.com/pypi/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898" }, - { url = "https://mirrors.aliyun.com/pypi/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2" }, -] - [[package]] name = "jsonpatch" version = "1.33" @@ -718,20 +469,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/29/56/5ef7ba14bac95b0344da18c6e8ec108dce0baf5fc054d1117702f92af29d/langchain_core-1.5.0-py3-none-any.whl", hash = "sha256:f122efee35446632b38687119fca33711abbf3b6b555e31156762298fbe78a65" }, ] -[[package]] -name = "langchain-openai" -version = "1.4.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "langchain-core" }, - { name = "openai" }, - { name = "tiktoken" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/89/fc/d146705e0cf6cf8865d4e873e0551452f94d6520f43fe703594ccdf95763/langchain_openai-1.4.0.tar.gz", hash = "sha256:a3acf6be0937f3970fc9e7f0aae22929c6f117e49128bd62f4d45a64b2587d8b" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f1/6c/f786dfcb6711cb06449041e72b67f7e28fc77a53e2aa16866c4f0002625a/langchain_openai-1.4.0-py3-none-any.whl", hash = "sha256:7a777731fe32a913085ec85bacd5650c3f8422048b65346b63a13b36b1b4a12f" }, -] - [[package]] name = "langchain-protocol" version = "0.0.18" @@ -849,15 +586,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, ] -[[package]] -name = "mpmath" -version = "1.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c" }, -] - [[package]] name = "numpy" version = "2.5.1" @@ -909,52 +637,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb" }, ] -[[package]] -name = "onnxruntime" -version = "1.23.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "coloredlogs" }, - { name = "flatbuffers" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "sympy" }, -] -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145" }, -] - -[[package]] -name = "openai" -version = "2.50.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/f5/e7735f2af272ee179a287911a698b3cbdb59d7a4ac4874571363adf1e4de/openai-2.50.0.tar.gz", hash = "sha256:5128f7caf4a6b01aefd6e7e93efe170a2c3427b8de286b9af5cdff3aa47e02c8" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/00/ca/db315b3bb748c26c644a3f85b7d509e774354d6518d47080b1446005ee41/openai-2.50.0-py3-none-any.whl", hash = "sha256:90bdddcc5a2fa529b350fac9c5780d87e5c361dcc6090ab57b0d470b0d7af7fa" }, -] - [[package]] name = "orjson" version = "3.11.9" @@ -1056,20 +738,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, ] -[[package]] -name = "passlib" -version = "1.7.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1" }, -] - -[package.optional-dependencies] -bcrypt = [ - { name = "bcrypt" }, -] - [[package]] name = "pillow" version = "12.3.0" @@ -1150,21 +818,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, ] -[[package]] -name = "protobuf" -version = "7.35.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9" }, -] - [[package]] name = "psycopg" version = "3.3.4" @@ -1238,11 +891,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" }, ] -[package.optional-dependencies] -email = [ - { name = "email-validator" }, -] - [[package]] name = "pydantic-core" version = "2.46.4" @@ -1350,15 +998,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728" }, ] -[[package]] -name = "pyreadline3" -version = "3.5.6" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d" }, -] - [[package]] name = "pytest" version = "9.1.1" @@ -1375,20 +1014,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, ] -[[package]] -name = "pytest-cov" -version = "7.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "coverage" }, - { name = "pluggy" }, - { name = "pytest" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" @@ -1453,115 +1078,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, ] -[[package]] -name = "qiniu" -version = "7.18.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/32/e5/82e5078de1204b641d6b24fdd15b3c5a87a7dd71f514e4a3cfb845a2d988/qiniu-7.18.0.tar.gz", hash = "sha256:d9edca3a1c5217c13638a08d9095cd1661f5ba6cf92ea3827949ff3d332ea4fa" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1d/56/9368cd96d2132017f5748a812cb20a87263498de314e1823472cb4bdbad9/qiniu-7.18.0-py3-none-any.whl", hash = "sha256:0f1be608ac6800ad5f32690d1aa02353b6b2ff5edc78f32a2318859d375a27df" }, -] - -[[package]] -name = "redis" -version = "8.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb" }, -] - -[[package]] -name = "regex" -version = "2026.7.19" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13" }, - { url = "https://mirrors.aliyun.com/pypi/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae" }, - { url = "https://mirrors.aliyun.com/pypi/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435" }, - { url = "https://mirrors.aliyun.com/pypi/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009" }, - { url = "https://mirrors.aliyun.com/pypi/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218" }, - { url = "https://mirrors.aliyun.com/pypi/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78" }, - { url = "https://mirrors.aliyun.com/pypi/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547" }, -] - [[package]] name = "requests" version = "2.34.2" @@ -1589,19 +1105,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, ] -[[package]] -name = "resend" -version = "2.35.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f0/b9/e07f2f2bd992ef4565b9c315dfd46b3df66ce89df1d928f442b9b39cbe3b/resend-2.35.0.tar.gz", hash = "sha256:26ced7b22cbd89f7b8c7ba9719d0708ab10c06ddd0f91ba6ec861f3a328101b3" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/cc/92/1c0912b68ae082a55dfdd32e4b5117905ae9fd1b6efc5f5e7c69a4ee6894/resend-2.35.0-py2.py3-none-any.whl", hash = "sha256:cd75299d626f4735af52910989b3f51919032ef316e2d60563c79c319e7b24a6" }, -] - [[package]] name = "rich" version = "15.0.0" @@ -1703,18 +1206,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6" }, ] -[[package]] -name = "sympy" -version = "1.14.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "mpmath" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5" }, -] - [[package]] name = "tenacity" version = "9.1.4" @@ -1724,65 +1215,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55" }, ] -[[package]] -name = "tiktoken" -version = "0.13.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "regex" }, - { name = "requests" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615" }, - { url = "https://mirrors.aliyun.com/pypi/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67" }, - { url = "https://mirrors.aliyun.com/pypi/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad" }, - { url = "https://mirrors.aliyun.com/pypi/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07" }, -] - -[[package]] -name = "tqdm" -version = "4.70.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953" }, -] - [[package]] name = "typing-extensions" version = "4.16.0" @@ -2065,6 +1497,8 @@ name = "windup-ai-engine" version = "0.1.0" source = { editable = "packages/ai_engine" } dependencies = [ + { name = "av" }, + { name = "imageio" }, { name = "langchain-core" }, { name = "langgraph" }, { name = "numpy" }, @@ -2075,6 +1509,8 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "av", specifier = ">=14.0" }, + { name = "imageio", specifier = ">=2.36" }, { name = "langchain-core", specifier = ">=0.3" }, { name = "langgraph", specifier = ">=0.2" }, { name = "numpy", specifier = ">=1.26" }, @@ -2089,7 +1525,7 @@ version = "0.1.0" source = { editable = "packages/app" } dependencies = [ { name = "fastapi" }, - { name = "pydantic", extra = ["email"] }, + { name = "pydantic" }, { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, @@ -2101,7 +1537,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, - { name = "pydantic", extras = ["email"], specifier = ">=2.7" }, + { name = "pydantic", specifier = ">=2.7" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, @@ -2127,19 +1563,10 @@ version = "0.1.0" source = { editable = "packages/framework" } dependencies = [ { name = "httpx" }, - { name = "langchain-core" }, - { name = "langchain-openai" }, - { name = "numpy" }, - { name = "onnxruntime" }, - { name = "passlib", extra = ["bcrypt"] }, - { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, - { name = "qiniu" }, - { name = "redis" }, - { name = "resend" }, { name = "sqlalchemy" }, { name = "windup-common" }, ] @@ -2147,19 +1574,10 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27" }, - { name = "langchain-core", specifier = ">=0.3" }, - { name = "langchain-openai", specifier = ">=0.3" }, - { name = "numpy", specifier = ">=1.26" }, - { name = "onnxruntime", specifier = ">=1.17,<1.24" }, - { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7" }, - { name = "pillow", specifier = ">=10.4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.4" }, { name = "pyjwt", specifier = ">=2.9" }, - { name = "qiniu", specifier = ">=7.14" }, - { name = "redis", specifier = ">=5.0" }, - { name = "resend", specifier = ">=2.0" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "windup-common", editable = "packages/common" }, ] From 3b03780976b5533d240dc307f5e034b5b4669210 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Fri, 7 Aug 2026 17:48:36 +0800 Subject: [PATCH 14/33] =?UTF-8?q?test(ai=5Fengine):=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E9=80=90=E5=B8=A7=E6=97=B6=E9=95=BF=E4=B8=8E=E6=AF=8D=E7=89=88?= =?UTF-8?q?=E9=A2=84=E5=A4=84=E7=90=86=E4=B8=A4=E6=9D=A1=E4=B8=BB=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `frame_durations` 参与每一次出参构造,`prepare_master` 参与每一次 jump / attack 生成, 此前两者均无直接覆盖。21 个用例,锁行为不锁具体数值。 frame_durations - 动作间必须有区分度(idle > walk > run)——等时长会让动作发飘、没有重量感 - 关键帧定格必须真的比邻帧长,且只定格一帧 - 未知动作要有可用兜底,不能返回 0 或抛错(上游动作类型可能先于本模块扩展) - 越界 key_frame 不炸(帧数由选帧决定,调用方未必对齐) - hold_ms 小于基准时长时取基准,定格不能反而变快 prepare_master - jump / attack 必须补顶部空间,否则腾空 / 过顶挥砍会顶出视频画面上沿被裁 (实测 attack 15/72 帧触顶) - 其余动作必须**原样**返回同一对象,无谓重编码会引入压缩损失 - 补的边在顶部、原图贴底(贴反了动作会往下出画) - ratio 越小顶部留白越多;jump 需要的空间多于 attack - 非法 ratio 抛 ValueError 已做变异测试,五处故意引入的错误全部被捕获: 抹掉动作间时长区分度 → 2 failed 关键帧不定格 → 1 failed jump/attack 不补顶部空间 → 3 failed 补边加在底部而非顶部 → 1 failed jump 与 attack 用同一 ratio → 1 failed 还原后 21 passed。不是写完就绿。 --- .../test_frame_timing_and_master_prep.py | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 backend/tests/test_frame_timing_and_master_prep.py diff --git a/backend/tests/test_frame_timing_and_master_prep.py b/backend/tests/test_frame_timing_and_master_prep.py new file mode 100644 index 00000000..257ece24 --- /dev/null +++ b/backend/tests/test_frame_timing_and_master_prep.py @@ -0,0 +1,136 @@ +"""逐帧时长与母版预处理 —— 两者都在每次生成的主路径上,此前无直接覆盖。 + +`frame_durations` 参与每一次出参构造;`prepare_master` 参与每一次 jump / attack 生成。 +纯计算,无需联网。 +""" +from __future__ import annotations + +import io + +import pytest +from PIL import Image + +from windup_ai_engine.postprocess import DEFAULT_FPS_MS, frame_durations +from windup_ai_engine.master_prep import add_headroom, prepare_master + + +# ── frame_durations ────────────────────────────────────────────────────────── +# +# 契约:等时长会让动作发飘、没有重量感,故各动作有不同基准,关键帧还要加长定格。 +# 下面的断言锁的是"动作之间必须有区分度"与"定格必须真的更长",不是锁具体数值。 + + +def test_durations_length_matches_frame_count(): + assert len(frame_durations("walk", 16)) == 16 + assert frame_durations("walk", 0) == [] + + +def test_each_action_has_its_own_base_duration(): + """idle 慢、run 快 —— 若所有动作退化成同一个值,本用例失败。""" + idle = frame_durations("idle", 4)[0] + walk = frame_durations("walk", 4)[0] + run = frame_durations("run", 4)[0] + assert idle > walk > run, f"idle={idle} walk={walk} run={run} 应递减" + assert idle == DEFAULT_FPS_MS["idle"] + + +def test_unknown_action_falls_back_to_walk_not_zero(): + """未知动作要有可用的兜底,不能返回 0 或抛错——上游动作类型可能先于本模块扩展。""" + assert frame_durations("no_such_action", 3) == frame_durations("walk", 3) + + +def test_key_frame_is_held_longer_than_its_neighbours(): + """攻击触点 / 跳跃顶点要定格,否则动作没有重量感。""" + d = frame_durations("attack", 8, key_frame=3, hold_ms=180) + assert d[3] == 180 + assert d[3] > d[2] and d[3] > d[4] + assert sum(1 for x in d if x == 180) == 1, "只应定格一帧" + + +def test_key_frame_out_of_range_is_ignored_not_crashing(): + """越界的 key_frame 不应炸 —— 帧数由选帧决定,调用方未必对齐。""" + assert frame_durations("attack", 4, key_frame=99) == frame_durations("attack", 4) + assert frame_durations("attack", 4, key_frame=-1) == frame_durations("attack", 4) + + +def test_hold_never_shortens_a_frame(): + """hold_ms 小于基准时长时取基准,定格不能反而变快。""" + base = DEFAULT_FPS_MS["idle"] # 450,远大于常见 hold 180 + d = frame_durations("idle", 4, key_frame=1, hold_ms=100) + assert d[1] == base + + +# ── prepare_master ─────────────────────────────────────────────────────────── +# +# 契约:jump 向上腾空、attack 挥砍过头顶,都会顶出视频画面上沿(实测 attack 15/72 帧触顶)。 +# 故这两个动作要在母版顶部补空间,其余动作原样返回。 + + +def _png(w: int, h: int, fill=(200, 60, 60), bg=(18, 220, 30)) -> bytes: + """一张四角为纯背景色、中下部有主体的图。背景取绿幕色以便断言补边颜色。""" + img = Image.new("RGB", (w, h), bg) + for y in range(h // 3, h): + for x in range(w // 3, w * 2 // 3): + img.putpixel((x, y), fill) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +def _size(b: bytes) -> tuple[int, int]: + return Image.open(io.BytesIO(b)).size + + +@pytest.mark.parametrize("action", ["jump", "attack"]) +def test_airborne_actions_get_headroom(action: str): + src = _png(64, 100) + out = prepare_master(src, action) + w0, h0 = _size(src) + w1, h1 = _size(out) + assert w1 == w0, "宽度不应变化" + assert h1 > h0, f"{action} 必须补顶部空间,否则腾空时头顶顶出画面被裁" + + +@pytest.mark.parametrize("action", ["walk", "run", "idle", "hit", "unknown"]) +def test_other_actions_are_returned_untouched(action: str): + """不需要处理的动作必须**原样**返回 —— 无谓的重编码会引入压缩损失。""" + src = _png(64, 100) + assert prepare_master(src, action) is src + + +def test_headroom_is_added_on_top_and_original_sits_at_bottom(): + """补的边必须在**顶部**:原图贴底,顶部新增区域应为背景色。""" + src = _png(64, 90) + out = add_headroom(src, ratio=0.6) + src_img = Image.open(io.BytesIO(src)).convert("RGB") + out_img = Image.open(io.BytesIO(out)).convert("RGB") + added = out_img.height - src_img.height + assert added > 0 + + # 顶部新增区域 = 背景色 + assert out_img.getpixel((2, 2)) == src_img.getpixel((0, 0)) + # 原图整体落在底部:最后一行应与原图最后一行一致 + assert out_img.crop((0, out_img.height - 1, out_img.width, out_img.height)).tobytes() == \ + src_img.crop((0, src_img.height - 1, src_img.width, src_img.height)).tobytes() + + +def test_smaller_ratio_gives_more_headroom(): + """ratio 是"角色占画面高度的比例",越小顶部留白越多。""" + src = _png(64, 100) + _, h_loose = _size(add_headroom(src, ratio=0.5)) + _, h_tight = _size(add_headroom(src, ratio=0.9)) + assert h_loose > h_tight + + +def test_jump_gets_more_headroom_than_attack(): + """jump 向上腾空,需要的顶部空间比 attack 的过顶挥砍更多(0.62 vs 0.70)。""" + src = _png(64, 100) + _, h_jump = _size(prepare_master(src, "jump")) + _, h_attack = _size(prepare_master(src, "attack")) + assert h_jump > h_attack + + +@pytest.mark.parametrize("bad", [0.0, 0.1, 1.0, 1.5, -0.3]) +def test_invalid_ratio_raises(bad: float): + with pytest.raises(ValueError, match="ratio"): + add_headroom(_png(32, 32), ratio=bad) From 6491c59f1ae66a28e07eb22e03bab27e9f78bfa5 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Fri, 7 Aug 2026 19:12:16 +0800 Subject: [PATCH 15/33] =?UTF-8?q?refactor(ai=5Fengine):=20=E6=94=B6?= =?UTF-8?q?=E6=8B=A2=E9=80=89=E5=B8=A7=E4=B8=8E=E5=B8=A7=E8=B4=A8=E9=87=8F?= =?UTF-8?q?=E5=85=B1=E7=94=A8=E7=9A=84=E5=8F=96=E6=A0=B7=E5=8E=9F=E8=AF=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _gray() 与 _SMALL=48 此前在 slicing/loop.py 与 slicing/quality.py 各有一份完整拷贝。 两处必须在同一尺度上看帧,否则算出的差异量不可比;而分叉不会报错、只在数据上体现 ——调一边的降采样尺寸,另一边悄悄保持 48,两个模块的指标从此不再可比。 收成 slicing/_frames.py 唯一定义(SMALL / gray)。行为不变。 --- .../src/windup_ai_engine/slicing/_frames.py | 20 +++++++++++++++++++ .../src/windup_ai_engine/slicing/loop.py | 9 +++------ .../src/windup_ai_engine/slicing/quality.py | 8 ++------ 3 files changed, 25 insertions(+), 12 deletions(-) create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/slicing/_frames.py diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/_frames.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/_frames.py new file mode 100644 index 00000000..81066126 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/_frames.py @@ -0,0 +1,20 @@ +"""选帧与帧质量共用的取样原语。 + +``loop``(选帧)与 ``quality``(诊断)必须在**同一尺度**上看帧,否则两边算出的差异量 +不可比 —— 之前两处各自持有一份 ``_gray`` 与 ``_SMALL``,调一边不会波及另一边, +是一个只会在数据上体现、不会报错的隐患。此处收成唯一定义。 +""" +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = ["SMALL", "gray"] + +# 帧比对统一降采样到 48×48 灰度:够分辨姿态差异,又让全帧对距离矩阵的开销可接受。 +SMALL = 48 + + +def gray(frames: list[Image.Image]) -> list[np.ndarray]: + """帧序列 → 定尺灰度矩阵列表(float32)。""" + return [np.asarray(f.convert("L").resize((SMALL, SMALL)), dtype=np.float32) for f in frames] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py index b524986b..5e29a688 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py @@ -10,13 +10,10 @@ import numpy as np from PIL import Image -__all__ = ["find_period", "pick_cycle"] - -_SMALL = 48 # 周期检测用的灰度小图边长 +from ._frames import SMALL as _SMALL +from ._frames import gray as _gray - -def _gray(frames: list[Image.Image]) -> list[np.ndarray]: - return [np.asarray(f.convert("L").resize((_SMALL, _SMALL)), dtype=np.float32) for f in frames] +__all__ = ["find_period", "pick_cycle"] def _deskew(gs: list[np.ndarray]) -> list[np.ndarray]: diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py index 4c851c6c..8bd85e57 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py @@ -10,13 +10,9 @@ import numpy as np -__all__ = ["active_span", "blur_ratio", "dead_frame_mask", "frame_deltas"] - -_SMALL = 48 +from ._frames import gray as _gray - -def _gray(frames): - return [np.asarray(f.convert("L").resize((_SMALL, _SMALL)), dtype=np.float32) for f in frames] +__all__ = ["active_span", "blur_ratio", "dead_frame_mask", "frame_deltas"] def frame_deltas(frames) -> np.ndarray: From 57442d862b19ad2a1b8e3d20b78a32bc685a1805 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 15:38:11 +0800 Subject: [PATCH 16/33] =?UTF-8?q?fix(deps):=20=E9=87=8D=E9=94=81=20uv.lock?= =?UTF-8?q?=20=E4=BB=A5=E8=A6=86=E7=9B=96=E8=A7=86=E9=A2=91=E6=8A=BD?= =?UTF-8?q?=E5=B8=A7=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai_engine 的 pyproject 声明了 imageio / av(抽帧必需),而 rebase 解冲突时 uv.lock 取的是主线版本,两者不一致:uv lock --check 报 lockfile needs to be updated。 本地 venv 里恰好装过这两个包,故本地测试没暴露;CI 用 --frozen 装依赖时会缺。 重锁时带 UV_DEFAULT_INDEX=阿里云镜像 —— 直接 uv lock 会把全仓 90 处包源改写成 pypi.org,产出两千多行与本次改动无关的 diff(主线 Dockerfile 定的就是这个镜像源)。 重锁后:阿里源 90 处、pypi 0 处,只新增 627 行(两个新包及其依赖树)。 --- backend/uv.lock | 629 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 627 insertions(+), 2 deletions(-) diff --git a/backend/uv.lock b/backend/uv.lock index b907d468..5e1a6cfd 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -14,6 +14,7 @@ members = [ dev = [ { name = "import-linter", specifier = ">=2.0" }, { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, { name = "ruff", specifier = ">=0.6" }, ] @@ -74,6 +75,72 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -165,6 +232,117 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, ] +[[package]] +name = "coloredlogs" +version = "15.0.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "humanfriendly" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934" }, +] + +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -174,6 +352,28 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4" }, +] + [[package]] name = "fastapi" version = "0.139.2" @@ -190,6 +390,14 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c" }, ] +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4" }, +] + [[package]] name = "greenlet" version = "3.5.4" @@ -382,6 +590,18 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, ] +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477" }, +] + [[package]] name = "idna" version = "3.18" @@ -428,6 +648,74 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, ] +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -469,6 +757,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/29/56/5ef7ba14bac95b0344da18c6e8ec108dce0baf5fc054d1117702f92af29d/langchain_core-1.5.0-py3-none-any.whl", hash = "sha256:f122efee35446632b38687119fca33711abbf3b6b555e31156762298fbe78a65" }, ] +[[package]] +name = "langchain-openai" +version = "1.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "langchain-core" }, + { name = "openai" }, + { name = "tiktoken" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/89/fc/d146705e0cf6cf8865d4e873e0551452f94d6520f43fe703594ccdf95763/langchain_openai-1.4.0.tar.gz", hash = "sha256:a3acf6be0937f3970fc9e7f0aae22929c6f117e49128bd62f4d45a64b2587d8b" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f1/6c/f786dfcb6711cb06449041e72b67f7e28fc77a53e2aa16866c4f0002625a/langchain_openai-1.4.0-py3-none-any.whl", hash = "sha256:7a777731fe32a913085ec85bacd5650c3f8422048b65346b63a13b36b1b4a12f" }, +] + [[package]] name = "langchain-protocol" version = "0.0.18" @@ -586,6 +888,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, ] +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c" }, +] + [[package]] name = "numpy" version = "2.5.1" @@ -637,6 +948,52 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb" }, ] +[[package]] +name = "onnxruntime" +version = "1.23.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145" }, +] + +[[package]] +name = "openai" +version = "2.53.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -738,6 +1095,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, ] +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + [[package]] name = "pillow" version = "12.3.0" @@ -818,6 +1189,21 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, ] +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9" }, +] + [[package]] name = "psycopg" version = "3.3.4" @@ -891,6 +1277,11 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.4" @@ -998,6 +1389,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728" }, ] +[[package]] +name = "pyreadline3" +version = "3.5.6" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -1014,6 +1414,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1078,6 +1492,115 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, ] +[[package]] +name = "qiniu" +version = "7.18.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/32/e5/82e5078de1204b641d6b24fdd15b3c5a87a7dd71f514e4a3cfb845a2d988/qiniu-7.18.0.tar.gz", hash = "sha256:d9edca3a1c5217c13638a08d9095cd1661f5ba6cf92ea3827949ff3d332ea4fa" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1d/56/9368cd96d2132017f5748a812cb20a87263498de314e1823472cb4bdbad9/qiniu-7.18.0-py3-none-any.whl", hash = "sha256:0f1be608ac6800ad5f32690d1aa02353b6b2ff5edc78f32a2318859d375a27df" }, +] + +[[package]] +name = "redis" +version = "8.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb" }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1105,6 +1628,19 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, ] +[[package]] +name = "resend" +version = "2.35.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f0/b9/e07f2f2bd992ef4565b9c315dfd46b3df66ce89df1d928f442b9b39cbe3b/resend-2.35.0.tar.gz", hash = "sha256:26ced7b22cbd89f7b8c7ba9719d0708ab10c06ddd0f91ba6ec861f3a328101b3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/cc/92/1c0912b68ae082a55dfdd32e4b5117905ae9fd1b6efc5f5e7c69a4ee6894/resend-2.35.0-py2.py3-none-any.whl", hash = "sha256:cd75299d626f4735af52910989b3f51919032ef316e2d60563c79c319e7b24a6" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1206,6 +1742,18 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6" }, ] +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -1215,6 +1763,65 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55" }, ] +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -1525,7 +2132,7 @@ version = "0.1.0" source = { editable = "packages/app" } dependencies = [ { name = "fastapi" }, - { name = "pydantic" }, + { name = "pydantic", extra = ["email"] }, { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, @@ -1537,7 +2144,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, - { name = "pydantic", specifier = ">=2.7" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.7" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, @@ -1563,10 +2170,19 @@ version = "0.1.0" source = { editable = "packages/framework" } dependencies = [ { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-openai" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "passlib", extra = ["bcrypt"] }, + { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "qiniu" }, + { name = "redis" }, + { name = "resend" }, { name = "sqlalchemy" }, { name = "windup-common" }, ] @@ -1574,10 +2190,19 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27" }, + { name = "langchain-core", specifier = ">=0.3" }, + { name = "langchain-openai", specifier = ">=0.3" }, + { name = "numpy", specifier = ">=1.26" }, + { name = "onnxruntime", specifier = ">=1.17,<1.24" }, + { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7" }, + { name = "pillow", specifier = ">=10.4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.4" }, { name = "pyjwt", specifier = ">=2.9" }, + { name = "qiniu", specifier = ">=7.14" }, + { name = "redis", specifier = ">=5.0" }, + { name = "resend", specifier = ">=2.0" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "windup-common", editable = "packages/common" }, ] From b817cc09c3ec870762ec0f2e872497a364980d5a Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 18:18:21 +0800 Subject: [PATCH 17/33] =?UTF-8?q?fix(ai=5Fengine):=20=E9=80=89=E5=B8=A7?= =?UTF-8?q?=E5=85=A5=E5=8F=82=E8=BE=B9=E7=95=8C=E2=80=94=E2=80=94=E9=99=A4?= =?UTF-8?q?=E9=9B=B6=20/=20=E9=9D=99=E9=BB=98=E5=B0=91=E7=BB=99=E5=B8=A7?= =?UTF-8?q?=E4=B8=80=E5=B9=B6=E5=A0=B5=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 机器审在 PR #180 报的两条 P1,加相邻边界扫描的发现。共同判据:返回长度恒等于 n, 凡是给不出 n 帧的入参一律报错,绝不静默交出一个长度自洽的短序列。 pick_oneshot: - n=1 撞 /(n-1) 除零(P1)。改为取"关键姿势"单帧:airborne 取脚线最高(顶点), swing 取能量峰后一帧(命中瞬间);不取区间首帧(蓄力,和待机一个样)也不取中点 (动作区间前后不对称,中点落在蓄力段)。 - n<=0 原本静默返回 [](range(n) 为空,连除零都不报)→ 显式拒绝。 - 源帧不足原本原样返回一个短序列 → 报错并报出两个数字。 - 动作区间被裁到不足 n 帧时原本直接返回该区间(14 帧输入请求 12 帧只回 9 帧), 改为把窗口放宽回来;动作贴视频尾部时缺口退回左边补,保证 n 帧互不重复。 - kind 拼错不再静默按 swing 处理(判据用错会裁出"看起来对"的错区间)。 pick_cycle: - n<=0 拒绝(P1)。实际机制与机器审所述不同:_offsets(P, 0) 并不除零(range(n) 为空, k*P/n 没被求值);真实路径是检出周期时走到 M[idx[-1], idx[0]] 抛 IndexError,而 测不到周期时**静默返回 []** —— 后者更危险。 - 源帧不足改为报错(原本原样返回)。 - n=1 显式取 medoid:单帧"循环"没有接缝也没有相位,原实现靠 nan 比较的意外结果返回首帧。 测试:两个文件各补边界用例,含 n=1..len(frames) 全量扫长度与不重复性。 变异验证 22 个错法,21 个被杀;唯一存活(区间放宽只往右补)经 1,565,565 组 (total, n, start, end) 穷举确认为等价变异 —— 长度契约完全一致,只有窗口位置不同。 Co-Authored-By: Claude Opus 5 --- .../src/windup_ai_engine/slicing/loop.py | 20 +++- .../src/windup_ai_engine/slicing/oneshot.py | 72 +++++++++++- backend/tests/test_loop.py | 50 +++++++- backend/tests/test_oneshot.py | 109 ++++++++++++++++++ 4 files changed, 245 insertions(+), 6 deletions(-) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py index 5e29a688..07491daa 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py @@ -81,7 +81,7 @@ def _offsets(P: int, n: int) -> list[int]: def pick_cycle(frames: list[Image.Image], n: int) -> list[Image.Image]: - """从密集帧里抽正好一个步态周期的 N 帧(无缝 loop)。帧数不足则原样返回。 + """从密集帧里抽正好一个步态周期的 N 帧(无缝 loop)。返回长度恒等于 ``n``。 周期检测的三个坑(实测 5 段真 i2v 视频): 1. d(p) 会被"角色整体平移 + 画质漂移"抬成单调上升 —— 直接取 argmin 会滑到搜索窗边界 @@ -100,10 +100,26 @@ def pick_cycle(frames: list[Image.Image], n: int) -> list[Image.Image]: 候选评分里的 ``a < 0.5 * scale`` 已经排掉"几乎不动"的窗口,够用。 quality 留作诊断/报告用,不进选帧。 """ + # n<=0 没有合法语义(要 0 帧的动画不存在),且两条出路都是坏的(2026-08-10 实测): + # 检出周期时 `_offsets(P, 0)` 交出空 offsets,一路走到 `M[idx[-1], idx[0]]` 抛 IndexError; + # 测不到周期时(单调曲线)直接静默返回 [] —— 后者更危险,故在入口显式拒绝。 + # (机器审说这里除零并不准确:`range(n)` 为空,`k*P/n` 根本没被求值。) + if n <= 0: + raise ValueError(f"n 必须 >= 1,收到 {n}") total = len(frames) - if total <= n: + if total < n: + # 源帧不够就报错,不再原样返回:长度不足且不报错,下游 frame_durations 按实际长度现算, + # 帧数与时长表自洽,server 看不出异常,用户拿到的是一段没走完的循环。 + raise ValueError(f"源帧不足:请求 {n} 帧,只有 {total} 帧") + if total == n: return frames M = _dmat(_deskew(_gray(frames))) + if n == 1: + # 单帧"循环"没有接缝也没有相位,下面整套周期/接缝机制全部失效(实测 n=1 时窗口内相邻差 + # 是空均值 = nan,`a < 0.5*scale` 与接缝评分双双被 nan 短路,靠比较运算的意外结果才 + # 返回 frames[0])。显式取 medoid:与全片平均姿态最近的一帧 = 循环停留最久的相位, + # 比 frames[0](i2v 的首帧是母版静立姿,单看读不出"在走")更能代表这个循环。 + return [frames[int(np.argmin(M.mean(1)))]] adj = np.array([M[i, i + 1] for i in range(total - 1)]) scale = float(np.median(adj)) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py index 990604bf..d6cb0192 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py @@ -25,6 +25,20 @@ ] +_KINDS = ("swing", "airborne") + + +def _check_kind(kind: str) -> None: + """未知 ``kind`` 直接报错,不静默回落到 ``swing``。 + + 两个判据的物理不同(脚线回地 vs 能量跌破),拼错一个字母(``"airbourne"``)若被当成 + ``swing`` 处理,跳跃会按能量判据裁 —— 出的是"看起来成功"的错区间(实测:顶点悬停处 + 能量安静,动作被截在半空),而这类错误在序列帧里很难回溯到 kind 拼错上。 + """ + if kind not in _KINDS: + raise ValueError(f"kind 只能是 'swing' 或 'airborne',收到 {kind!r}") + + def _frame_energy(frames: list[Image.Image], size: int = 64) -> np.ndarray: """逐帧与前一帧的差异强度(灰度小图),长度 = len(frames)-1。""" gs = [np.asarray(f.convert("L").resize((size, size)), dtype=np.float32) for f in frames] @@ -99,11 +113,54 @@ def first_action_end( 把动作截在半空;②要求静止段足够长 → 挥砍收势并不干净(压缩噪点),等不到,完全不裁; ③找"回到起始姿态"的谷底 → 收势姿态(戒备)与起始姿态(蓄力)不同,回不到低位。 """ + _check_kind(kind) # 放在早返回之前:短区间也不能放过拼错的 kind if end - start < 4: return end return (_airborne_end if kind == "airborne" else _swing_end)(frames, start, end) +def _key_pose(span: list[Image.Image], kind: str) -> int: + """区间内最能代表这次动作的单帧下标(关键姿势)。 + + 只取一帧时不能取区间首帧或中点: + - 首帧是蓄力起手,和待机几乎一个样,单看认不出这是攻击还是跳跃;末帧是收势/落地,同理。 + - 中点也不行:动作区间前后不对称(蓄力长、收势短 —— :func:`_swing_end` 只留 2 帧余量), + 中点会落进蓄力段。 + 故取"关键姿势":判据与 :func:`first_action_end` 同源,不引入新参数 —— + ``airborne`` 取脚线最高(顶点),``swing`` 取能量峰。能量是**帧间**差(长度 len-1), + 峰值下标 i 表示 i→i+1 这一跳变化最大,故取 i+1 = 刚完成最快一段位移的那一帧(命中瞬间)。 + """ + if len(span) < 2: + # 单帧区间:答案唯一(就那一帧),没有歧义,不必炸。当前 pick_oneshot 恒给 >= 2 帧 + # (end 至少 start+1),但那是上游三个启发式判据合出来的保证、不是本函数签名的保证, + # 故留这一行 —— 否则 _frame_energy 会交出空数组、argmax 抛一个看不懂的 numpy 错。 + return 0 + if kind == "airborne": + return int(np.argmin(foot_line_series(span))) + return min(len(span) - 1, int(np.argmax(_frame_energy(span))) + 1) + + +def _widen_span(start: int, end: int, n: int, total: int) -> tuple[int, int]: + """区间不足 n 帧时把窗口放宽回来,保证能取出 n 个**互不相同**的源帧。 + + 调用处已保证 ``total > n``,即源帧数是够的 —— 区间短只是我们自己的裁剪判据收得紧 + (2026-08-10 实测:14 帧输入裁到 9 帧区间,请求 12 帧只回 9 帧)。此时既不该报错(源帧够), + 更不该静默少给帧:下游 ``frame_durations(action, len(frames))`` 按实际长度现算时长,帧数与 + 时长表自洽,server 看不出异常,用户拿到的是一段步子没走完的动作。故按缺口对称放宽, + 宁可带上几帧起手/收势的静止帧,也要给足请求的帧数。 + + 动作贴在视频尾部时右边长不动,缺口必须退回左边补(最后一行),否则窗口仍不足 n 帧、 + 只能靠重复帧凑数 —— 长度对、内容卡顿,又是一种"看起来成功"。 + """ + deficit = n - (end - start + 1) + if deficit <= 0: + return start, end + left = min(start, (deficit + 1) // 2) # 先往左补一半,左边不够就全从右边补 + start -= left + end = min(total - 1, end + deficit - left) + return max(0, end - n + 1), end # 右边撞到尾部时把缺口退回左边 + + def pick_oneshot( frames: list[Image.Image], n: int, first_only: bool = True, kind: str = "swing" ) -> list[Image.Image]: @@ -111,15 +168,24 @@ def pick_oneshot( ``first_only`` 默认开:防 i2v 在 5s 内复读第二遍动作被一起抽进来。 ``kind``:``"airborne"``(jump,按脚线回地判结束)或 ``"swing"``(attack/hit,按能量跌破判)。 + + 返回长度**恒等于** ``n``;源帧不够 n 帧则报错,不静默少给。 """ - if len(frames) <= n: + _check_kind(kind) # first_only=False 时不走 first_action_end,这里兜住 + if n <= 0: + # 2026-08-10 实测:n<=0 原本静默返回 [](range(n) 为空,连除零都不报),"成功"地交出零帧。 + raise ValueError(f"n 必须 >= 1,收到 {n}") + if len(frames) < n: + raise ValueError(f"源帧不足:请求 {n} 帧,只有 {len(frames)} 帧") + if len(frames) == n: return frames start, end = find_motion_span(frames) if first_only: end = max(start + 1, first_action_end(frames, start, end, kind=kind)) + start, end = _widen_span(start, end, n, len(frames)) span = frames[start : end + 1] - if len(span) <= n: - return span + if n == 1: # n=1 撞下面的 /(n-1) 除零(机器审 P1,2026-08-10 复现) + return [span[_key_pose(span, kind)]] idx = [round(i * (len(span) - 1) / (n - 1)) for i in range(n)] return [span[i] for i in idx] diff --git a/backend/tests/test_loop.py b/backend/tests/test_loop.py index 4d447a9a..daac0c1e 100644 --- a/backend/tests/test_loop.py +++ b/backend/tests/test_loop.py @@ -1,5 +1,6 @@ """循环闭合(周期检测 + 单周期取帧)测试 —— 纯 CV,无需联网。""" +import pytest from PIL import Image from windup_ai_engine.slicing import find_period, pick_cycle @@ -28,11 +29,58 @@ def test_pick_cycle_returns_n_frames(): assert len(out) == 8 -def test_pick_cycle_passthrough_when_too_few(): +def _ramp_frames(n: int = 40) -> list[Image.Image]: + """亮度单调上升 = 无周期,专走"测不到周期"那条分支。""" + return [Image.new("RGB", (48, 48), (i * 4,) * 3) for i in range(n)] + + +def test_pick_cycle_rejects_insufficient_source(): + """源帧不够就报错(原本原样返回 4 帧,冒充"抽到了 8 帧的循环")。""" frames = _periodic_frames(period=4, cycles=1) # 4 帧 < 8 + with pytest.raises(ValueError, match=r"源帧不足.*8.*4"): + pick_cycle(frames, 8) + with pytest.raises(ValueError, match="源帧不足"): # 只差一帧也不放过(挡 off-by-one) + pick_cycle(frames, len(frames) + 1) + + +def test_pick_cycle_passthrough_when_n_equals_len(): + frames = _periodic_frames(period=4, cycles=2) # 8 帧 == 8 assert pick_cycle(frames, 8) is frames +def test_pick_cycle_rejects_non_positive_n(): + """n<=0 两条分支的旧行为都不可接受:检出周期时 IndexError,测不到周期时静默返回 []。""" + for frames in (_periodic_frames(period=20, cycles=5), _ramp_frames()): + for n in (0, -1): + with pytest.raises(ValueError, match="n 必须"): + pick_cycle(frames, n) + + +def test_pick_cycle_n1_takes_dominant_pose(): + """n=1 的"循环"只是一张静止姿势:取全片最具代表性的一帧(停留最久的相位), + 而不是首帧 —— i2v 首帧是母版静立姿,单看读不出这是什么动作。""" + a = Image.new("RGB", (48, 48), (200, 200, 200)) + b = Image.new("RGB", (48, 48), (40, 40, 40)) + frames = [b] + [a] * 20 + [b] * 8 + [a] * 11 # a 占多数,首帧刻意放 b + out = pick_cycle(frames, 1) + assert len(out) == 1 + assert out[0] is a + assert out[0] is not frames[0] + + +def test_pick_cycle_returns_exactly_n_distinct_frames_for_every_legal_n(): + """全量扫 n=1..len(frames):两条分支(检出周期 / 测不到周期)都要恒好 n 帧,且互不重复。 + + 长度对但夹着重复帧同样是"看起来成功"的错结果 —— 取样相位塌在一起,循环里会卡一下。 + """ + for frames in (_periodic_frames(period=8, cycles=5), _ramp_frames()): + for n in range(1, len(frames) + 1): + out = pick_cycle(frames, n) + assert len(out) == n, n + pos = [next(i for i, f in enumerate(frames) if f is o) for o in out] + assert len(set(pos)) == n, (n, pos) + + def test_pick_cycle_closes_the_loop(): # 取出的一周期,末帧的下一拍应接近首帧(亮度差小) import numpy as np diff --git a/backend/tests/test_oneshot.py b/backend/tests/test_oneshot.py index ea1cde95..2307a030 100644 --- a/backend/tests/test_oneshot.py +++ b/backend/tests/test_oneshot.py @@ -1,10 +1,12 @@ """一次性动作抽帧(裁动作区间 / 跳跃状态切段)测试 —— 纯 CV,无需联网。""" import numpy as np +import pytest from PIL import Image from windup_ai_engine.slicing import ( find_motion_span, + first_action_end, foot_line_series, pick_oneshot, split_jump_phases, @@ -66,3 +68,110 @@ def test_split_jump_phases_covers_all_frames_in_order(): def test_split_jump_phases_short_input_is_safe(): assert split_jump_phases([_figure_at(50)] * 3) + + +# ── 入参边界 ──────────────────────────────────────────────────────────────── +# 契约:返回长度恒等于 n;凡是给不出 n 帧的入参一律报错,绝不静默少给。 + + +def _bar_at(x: int, w: int = 8, size: int = 64) -> Image.Image: + """横向位移的方块,用来造挥击序列。位移刻意都 < 条宽 → 像素差与位移成正比、不饱和。""" + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + arr = np.asarray(img).copy() + arr[20:50, x : x + w] = (200, 60, 60, 255) + return Image.fromarray(arr, "RGBA") + + +def _swing_sequence() -> list[Image.Image]: + """合成挥击:静止 → 加速横扫(最快的一跳是 16→22)→ 收势 → 静止。""" + xs = [10] * 4 + [11, 13, 16, 22, 25, 26] + [26] * 4 + return [_bar_at(x) for x in xs] + + +def _tail_action_sequence() -> list[Image.Image]: + """动作贴在尾部(视频在半空结束,没有静止收尾)—— 区间放宽时右边无处可长, + 必须把缺口退回左边,否则窗口不足 n 帧、只能靠重复帧凑数。""" + ys = [50] * 8 + [52, 52, 44, 38, 30, 30, 38, 44] + return [_figure_at(y) for y in ys] + + +def test_pick_oneshot_n1_takes_apex_for_airborne(): + """n=1 取关键姿势:腾空类应给顶点帧,而不是首帧(蓄力,和待机一个样)。""" + frames = _jump_sequence() + apex = int(np.argmin(foot_line_series(frames))) + out = pick_oneshot(frames, 1, kind="airborne") + assert len(out) == 1 + assert out[0] is frames[apex] + + +def test_pick_oneshot_n1_takes_impact_for_swing(): + """n=1 取关键姿势:挥击类应给"刚走完最快一跳"的命中帧,不是首帧/末帧。""" + frames = _swing_sequence() + out = pick_oneshot(frames, 1) + assert len(out) == 1 + assert out[0] is frames[7] # 16→22 是最快的一跳,落点即命中姿势 + assert out[0] is not frames[0] and out[0] is not frames[-1] + + +def test_pick_oneshot_rejects_non_positive_n(): + """n<=0 原本静默返回 [](零帧也算"成功"),现在必须报错。""" + frames = _jump_sequence() + for n in (0, -1): + with pytest.raises(ValueError, match="n 必须"): + pick_oneshot(frames, n) + + +def test_pick_oneshot_rejects_insufficient_source(): + """源帧不够 n 帧:报错并把两个数字都说清楚,不再原样返回一个短序列。""" + frames = _jump_sequence() + with pytest.raises(ValueError, match=r"源帧不足.*19.*14"): + pick_oneshot(frames, len(frames) + 5) + with pytest.raises(ValueError, match="源帧不足"): + pick_oneshot([], 4) + with pytest.raises(ValueError, match="源帧不足"): + pick_oneshot([_figure_at(50)], 2) + + +def test_pick_oneshot_returns_exactly_n_for_every_legal_n(): + """全量扫 n=1..len(frames):长度必须恒等于 n。 + + 修前 pick_oneshot(jump14, 12) 只回 9 帧 —— 动作区间被裁到 9 帧后直接原样返回, + 而下游 frame_durations 按实际长度现算时长,帧数与时长自洽,谁都看不出少了 3 帧。 + """ + for frames in (_jump_sequence(), _swing_sequence(), _tail_action_sequence()): + for kind in ("swing", "airborne"): + for n in range(1, len(frames) + 1): + assert len(pick_oneshot(frames, n, kind=kind)) == n, (kind, n) + for n in range(1, len(frames) + 1): # first_only=False 走另一条区间分支 + assert len(pick_oneshot(frames, n, first_only=False)) == n + + +def test_pick_oneshot_passthrough_when_n_equals_len(): + frames = _jump_sequence() + assert pick_oneshot(frames, len(frames)) is frames + + +def test_pick_oneshot_frames_are_distinct_source_frames_in_order(): + """源帧够 n 张时,返回的 n 帧必须**互不相同**且时间顺序不倒。 + + 长度对但夹着重复帧,是另一种"看起来成功"的错结果:时长表照样自洽,播出来是卡顿。 + 动作贴尾部的序列是这条的关键用例 —— 区间往右长不动,缺口只能退回左边补。 + """ + for frames in (_jump_sequence(), _swing_sequence(), _tail_action_sequence()): + for n in range(1, len(frames) + 1): + out = pick_oneshot(frames, n, kind="airborne") + pos = [next(i for i, f in enumerate(frames) if f is o) for o in out] + assert pos == sorted(pos), (n, pos) + assert len(set(pos)) == n, (n, pos) # 无重复帧 + + +def test_unknown_kind_is_rejected(): + """kind 拼错不能静默按 swing 处理 —— 判据用错会裁出"看起来对"的错区间。""" + frames = _jump_sequence() + with pytest.raises(ValueError, match="kind"): + pick_oneshot(frames, 6, kind="airbourne") + with pytest.raises(ValueError, match="kind"): + # first_only=False 不走 first_action_end,得靠 pick_oneshot 自己那道校验 + pick_oneshot(frames, 6, kind="airbourne", first_only=False) + with pytest.raises(ValueError, match="kind"): + first_action_end(frames, 0, 1, kind="airbourne") # 短区间(早返回)也要拦住 From 285039c037e800f45ee5f0dab323d9316bf5fc0a Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 21:57:19 +0800 Subject: [PATCH 18/33] =?UTF-8?q?perf(ai=5Fengine):=20=E6=8A=BD=E5=B8=A7?= =?UTF-8?q?=E6=94=B9=E6=B5=81=E5=BC=8F=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=8A=8A?= =?UTF-8?q?=E6=95=B4=E6=AE=B5=E8=A7=86=E9=A2=91=E8=AF=BB=E8=BF=9B=E5=86=85?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 机器审 P2:iio.imread 会把 (T, H, W, C) 整个 materialize 出来,而我们只要其中 8~16 帧。 实测(14 段真实 i2v 视频,进程 RSS 峰值,非 tracemalloc): - 121 帧 720p,抽 16 帧:488 MiB → 126 MiB,降 74% - 同一段抽 8 帧:460 MiB → 98 MiB,降 79% - 抽 150 帧(周期检测用的 extract_all_frames_bytes):858 MiB → 498 MiB,降 42% 最后一条如实说明降幅边界:它保留全部 121 帧,省掉的只是那个完整 ndarray,保留帧本身 该占的内存还在。并发 worker 叠加时这仍是主要占用项。 正确性:改造前后在 14 段真实视频 × n=8/16/150 共 42 组上抽出的帧**逐像素完全相同**。 n=1 取首帧的既有约定保持不变(关键姿势的选择归 pick_oneshot,不由抽帧层猜)。 帧数来源:先读容器元数据(在 14 段真实视频上与实际帧数全部一致),拿不到正整数就退回 逐帧计数——计数不保留帧、内存不涨。按错的帧数算下标会抽出错位的帧,那是"帧数对、内容 错"的静默失败,多解一遍换一个确定的数划算。 顺带:imageio 分支的 except Exception 加了 warning 日志。它此前把"我们自己算错下标"和 "环境里没装 imageio"混为一谈,两者都表现为悄悄换用 ffmpeg 分支、产出看着正常的帧。 测试 11 条(tests/test_extract_streaming.py),含下标边界、抽到的是首尾与均匀分布那几帧、 要的比有的多时不补帧、元数据报 0 或抛错时退回计数。做过变异测试:改回 imread 让两条变红。 其中一条最初是摆设——炸弹抛 AssertionError 被 except Exception 吞掉、静默走 ffmpeg 后照 样绿,改成 BaseException 子类才真能杀掉变异;docstring 里写明了这个坑。 --- .../src/windup_ai_engine/slicing/extract.py | 59 ++++++-- backend/tests/test_extract_streaming.py | 135 ++++++++++++++++++ 2 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 backend/tests/test_extract_streaming.py diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py index f34f48cc..87645603 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/extract.py @@ -8,11 +8,14 @@ from __future__ import annotations +import logging import os import tempfile from PIL import Image +logger = logging.getLogger("windup.ai_engine.extract") + __all__ = ["extract_frames_bytes", "extract_all_frames_bytes"] @@ -32,18 +35,58 @@ def extract_all_frames_bytes(video: bytes, cap: int = 150) -> list[Image.Image]: return _extract_frames(f.name, cap) +def _uniform_indices(total: int, n: int) -> list[int]: + """在 ``total`` 帧里均匀取 ``min(n, total)`` 个下标(含首尾)。""" + m = min(n, total) + return [round(i * (total - 1) / max(1, m - 1)) for i in range(m)] + + +def _frame_count(video_path: str) -> int: + """帧数。先问容器元数据,不可信时退回逐帧计数(计数不保留帧,内存不涨)。 + + 元数据在 14 段真实 i2v 视频上与实际帧数全部一致(2026-08-10 实测),但不同容器/编码 + 的 ``n_frames`` 并非都可靠,所以拿不到正整数就退回计数——多解一遍换一个确定的数, + 比按错的帧数抽出错位的帧划算。 + """ + import imageio.v3 as iio + + try: + shape = iio.improps(video_path, plugin="pyav").shape + if shape and isinstance(shape[0], int) and shape[0] > 0: + return shape[0] + except Exception: + pass + return sum(1 for _ in iio.imiter(video_path, plugin="pyav")) + + def _extract_frames(video_path: str, n: int) -> list[Image.Image]: - """从视频均匀抽 ``n`` 帧。优先 imageio,回退系统 ffmpeg。""" + """从视频均匀抽 ``n`` 帧。优先 imageio(流式),回退系统 ffmpeg。 + + **流式而不是一次性读整段**(2026-08-10,机器审 P2):原先走 ``iio.imread`` 会把 + ``(T, H, W, C)`` 整个 materialize 出来。实测 121 帧 720p 的真实 i2v 视频峰值 + 319 MiB,而我们只要其中 8~16 帧;并发 worker 叠加时这是实打实的内存墙。 + 现在峰值≈保留帧数 × 单帧,与视频长度无关。 + """ try: import imageio.v3 as iio - all_frames = iio.imread(video_path, plugin="pyav") # (T, H, W, C) - total = len(all_frames) - m = min(n, total) - idx = [round(i * (total - 1) / max(1, m - 1)) for i in range(m)] - return [Image.fromarray(all_frames[i]).convert("RGBA") for i in idx] - except Exception: - pass + total = _frame_count(video_path) + if total <= 0: + raise RuntimeError("视频无可解码帧") + wanted = set(_uniform_indices(total, n)) + out: list[Image.Image] = [] + for i, frame in enumerate(iio.imiter(video_path, plugin="pyav")): + if i in wanted: + # convert 之后原始 ndarray 就可以被回收;不持有 frame 本身。 + out.append(Image.fromarray(frame).convert("RGBA")) + if len(out) == len(wanted): + break + if out: + return out + except Exception: # noqa: BLE001 - 兜底到 ffmpeg + # 不静默:这个 except 曾把"我们自己算错下标"和"环境里没装 imageio"混为一谈, + # 两者都表现为悄悄换用 ffmpeg 分支、产出看着正常的帧。至少留一条日志。 + logger.warning("imageio 抽帧失败,回退系统 ffmpeg", exc_info=True) import glob import subprocess diff --git a/backend/tests/test_extract_streaming.py b/backend/tests/test_extract_streaming.py new file mode 100644 index 00000000..7ed1fcbc --- /dev/null +++ b/backend/tests/test_extract_streaming.py @@ -0,0 +1,135 @@ +"""抽帧必须流式,不能把整段视频 materialize 出来(2026-08-10 机器审 P2)。 + +原先 ``iio.imread`` 一次性读出 ``(T, H, W, C)``。实测 121 帧 720p 的真实 i2v 视频, +进程 RSS 峰值 488 MiB,而抽 16 帧只需要其中 16 帧;并发 worker 叠加时这是实打实的内存墙。 +改成 ``imiter`` 后同一段视频 126 MiB(降 74%);抽 8 帧降 79%。 + +如实说明降幅的边界:``extract_all_frames_bytes(cap=150)``(周期检测用)会保留全部 121 帧, +只降 42% —— 省掉的是那个完整 ndarray,保留帧本身该占的内存还在。 +""" +from __future__ import annotations + +import numpy as np +import pytest +from PIL import Image + +from windup_ai_engine.slicing.extract import ( + _extract_frames, + _frame_count, + _uniform_indices, + extract_frames_bytes, +) + + +class _WentThroughImread(BaseException): + """故意继承 BaseException 而不是 Exception —— 见下面用例的 docstring。""" + + +def _forbidden(*a, **k): + raise _WentThroughImread("走了 imread:整段视频被 materialize 了") + + +@pytest.fixture(scope="module") +def video(tmp_path_factory) -> str: + """20 帧的合成视频,每帧一个可辨认的灰度值,用来验"抽到的是哪几帧"。""" + iio = pytest.importorskip("imageio.v3") + path = tmp_path_factory.mktemp("v") / "ramp.mp4" + # 每帧填 i*12,H.264 有损但相邻帧差 12 足以区分;尺寸取 16 的倍数避开编码器 padding。 + frames = [np.full((64, 64, 3), i * 12, dtype=np.uint8) for i in range(20)] + iio.imwrite(path, np.stack(frames), plugin="pyav", codec="libx264") + return str(path) + + +# ── 下标计算 ──────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize(("total", "n", "expect"), [ + (20, 1, [0]), # n=1 不能撞 /(n-1) 除零 + (1, 8, [0]), # 要的比有的多:给全部,不重复 + (1, 1, [0]), + (20, 20, list(range(20))), + (20, 2, [0, 19]), # 必须含首尾 +]) +def test_uniform_indices_covers_the_boundaries(total, n, expect): + assert _uniform_indices(total, n) == expect + + +def test_uniform_indices_never_exceeds_total(): + for total in (1, 3, 20, 121): + for n in (1, 5, 16, 150): + idx = _uniform_indices(total, n) + assert len(idx) == min(n, total) + assert len(set(idx)) == len(idx), "下标不该重复——重复等于同一帧算两帧" + assert idx == sorted(idx) and idx[0] == 0 + # n=1 取首帧(与改造前的实现一致,已在 14 段真实视频上验过逐像素相同); + # 关键姿势的选择归 pick_oneshot,不由抽帧层猜。 + assert len(idx) == 1 or idx[-1] == total - 1 + + +# ── 流式:不许再整段读入 ───────────────────────────────────────────────────── + + +def test_extraction_does_not_materialise_the_whole_video(video, monkeypatch): + """把 ``imread`` 换成炸弹:仍能抽帧,才说明走的是逐帧迭代。 + + 这是本文件的核心断言 —— 改回 ``iio.imread`` 会让它变红(变异测试确认)。 + + 炸弹必须抛 ``BaseException`` 的子类:``_extract_frames`` 用 ``except Exception`` + 兜底到 ffmpeg,抛 ``AssertionError`` 会被它吞掉、静默走 ffmpeg 分支产出正确帧数, + 这条用例于是变成摆设 —— 2026-08-10 变异测试逮到,原版正是这么写的。 + """ + import imageio.v3 as iio + + monkeypatch.setattr(iio, "imread", _forbidden) + frames = _extract_frames(video, 5) + assert len(frames) == 5 + assert all(isinstance(f, Image.Image) and f.mode == "RGBA" for f in frames) + + +def test_extracted_frames_are_the_uniformly_spaced_ones(video): + """抽的是首尾与均匀分布的那几帧,不是前 n 帧。 + + 合成视频每帧灰度递增,所以取出来的灰度序列必须是递增且跨越全程的。 + """ + frames = _extract_frames(video, 5) + greys = [int(np.asarray(f.convert("L")).mean()) for f in frames] + assert greys == sorted(greys), f"帧序错乱:{greys}" + assert greys[0] < 30, f"首帧不是第 0 帧(灰度 {greys[0]})" + assert greys[-1] > 200, f"末帧不是最后一帧(灰度 {greys[-1]})" + + +def test_asking_for_more_frames_than_the_video_has_returns_all(video): + """要 150 帧、视频只有 20 帧:给 20 帧,不静默补帧也不报错。""" + assert len(_extract_frames(video, 150)) == 20 + + +def test_bytes_entry_point_streams_too(video, monkeypatch): + """公开入口是 bytes 版,它也必须走流式(它只是多包了一层临时文件)。""" + import imageio.v3 as iio + + monkeypatch.setattr(iio, "imread", _forbidden) + with open(video, "rb") as f: + assert len(extract_frames_bytes(f.read(), 4)) == 4 + + +# ── 帧数元数据不可信时的兜底 ───────────────────────────────────────────────── + + +def test_frame_count_falls_back_to_counting_when_metadata_is_useless(video, monkeypatch): + """容器元数据在 14 段真实视频上都准,但不同编码的 n_frames 并非都可靠。 + + 元数据给 0 / None / 抛错时必须退回逐帧计数——按错的帧数算下标会抽出错位的帧, + 那是"看起来成功"的失败(帧数对、内容错)。 + """ + import imageio.v3 as iio + + assert _frame_count(video) == 20 + + class _Props: + shape = (0, 64, 64, 3) + + monkeypatch.setattr(iio, "improps", lambda *a, **k: _Props()) + assert _frame_count(video) == 20, "元数据报 0 帧时没退回计数" + + monkeypatch.setattr(iio, "improps", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("no"))) + assert _frame_count(video) == 20, "元数据抛错时没退回计数" From c8532487ad8359a18569064981ed96d056dfb743 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 11 Aug 2026 10:05:11 +0800 Subject: [PATCH 19/33] =?UTF-8?q?test(ai=5Fengine):=20=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E6=9E=84=E9=80=A0=E5=99=A8=E7=9A=84=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=BE=A7=E6=96=AD=E8=A8=80=E8=A1=A5=E5=9C=A8=E5=BC=95=E5=85=A5?= =?UTF-8?q?=E5=AE=83=E7=9A=84=E5=88=86=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 契约断言(DTO 自身)留在 feat/character-domain-models,不 import 上层包;本分片引入 prompt 模块,配套的实现侧断言就该落在这里:类型注解不是运行期约束,把校验写成 `SIDE if facing == Facing.SIDE else FRONT` 的二分时,"sidee" 会静默落到 FRONT 模板—— 正面走的提示词配侧面母版,模型靠转身调和矛盾,调用方什么错都收不到。 4 条:四个 build_* 拒绝非法 facing、枚举与合法字符串等价、walk 按 facing 选对模板体、 其余 build_* 同样按 facing 切换。 --- backend/tests/test_character_contract.py | 56 ++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_character_contract.py b/backend/tests/test_character_contract.py index ebb53098..4db299b1 100644 --- a/backend/tests/test_character_contract.py +++ b/backend/tests/test_character_contract.py @@ -4,15 +4,23 @@ 朝向拼错、帧数字段名打错、规格自相矛盾。这些错误以前一路放行到 i2v 调用之后才在画面上显形, 一次误判的成本 = 一次付费视频生成 + 人肉看片。 -本文件只测 DTO 自身,不 import 上层包 —— 契约包要能独立验证。配套的实现侧断言 -("prompt 模板真的按 facing 选对"、"strategy 真的按 n_frames 出帧")随各自的实现 -分片走,契约合法不代表实现读对了。 +契约本身的断言在 feat/character-domain-models 那一片,只测 DTO、不 import 上层包。 +本分片引入 prompt 模块,于是把**实现侧**的配套断言补在这里:类型注解不是运行期约束, +``build_*(facing="sidee")`` 必须当场炸。契约合法不代表实现读对了。 """ from __future__ import annotations import pytest from pydantic import ValidationError +from windup_ai_engine.prompt import ( + WALK_BODY_FRONT, + WALK_BODY_SIDE, + build_attack_prompt, + build_idle_prompt, + build_jump_prompt, + build_walk_prompt, +) from windup_common.models import ( DEFAULT_N_FRAMES, ActionSpec, @@ -90,6 +98,48 @@ def test_unknown_field_name_is_rejected_not_silently_dropped(): CharacterCard(name="n", desc="d", nmae="typo") +# ── A1 实现侧:build_* 是普通函数,注解不构成运行期约束 ────────────────────── + + +@pytest.mark.parametrize( + "build", [build_walk_prompt, build_jump_prompt, build_idle_prompt, build_attack_prompt] +) +def test_prompt_builders_reject_illegal_facing(build): + """直接调 build_*(facing="sidee") 仍要炸。 + + 类型注解不是运行期约束。若把校验删成 ``SIDE if facing == Facing.SIDE else FRONT`` + 的二分,"sidee" 会静默落到 FRONT 模板 —— 正面走的提示词配侧面母版, + 模型靠转身调和矛盾,而调用方什么错都收不到。 + """ + with pytest.raises(ValueError): + build(facing="sidee") + + +@pytest.mark.parametrize( + "build", [build_walk_prompt, build_jump_prompt, build_idle_prompt, build_attack_prompt] +) +def test_prompt_builders_accept_enum_and_legal_string_alike(build): + assert build(facing=Facing.FRONT) == build(facing="front") + assert build(facing=Facing.SIDE) == build(facing="side") + + +def test_walk_prompt_picks_the_template_that_matches_facing(): + """选模板的方向不能反 —— 只验"不炸"验不出模板接反。""" + side = build_walk_prompt(facing=Facing.SIDE) + front = build_walk_prompt(facing=Facing.FRONT) + assert side != front + assert side == WALK_BODY_SIDE.format(garment="the cape and tabard") + assert front == WALK_BODY_FRONT.format(garment="the cape and tabard") + assert "SIDE VIEW facing right" in side and "SIDE VIEW facing right" not in front + assert "FACING THE VIEWER" in front and "FACING THE VIEWER" not in side + + +@pytest.mark.parametrize("build", [build_jump_prompt, build_idle_prompt, build_attack_prompt]) +def test_other_builders_also_switch_body_by_facing(build): + assert build(facing=Facing.SIDE) != build(facing=Facing.FRONT) + assert "FACING THE VIEWER" in build(facing=Facing.FRONT) + + # ── A2 n_frames 是显式字段,不再由 len(poses) 推导 ────────────────────────── From f513d185fe07b4b083ce14a223720a1ccf9d3777 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 11 Aug 2026 11:10:08 +0800 Subject: [PATCH 20/33] =?UTF-8?q?feat(ai=5Fengine):=20=E4=BA=A4=E4=BB=98?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E6=94=AF=E6=8C=81=E9=9D=9E=E6=96=B9=E5=BD=A2?= =?UTF-8?q?=EF=BC=8C=E5=BC=95=E6=93=8E=E5=8F=AF=E4=B8=80=E6=AC=A1=E5=87=BA?= =?UTF-8?q?=E5=88=B0=E9=A1=B9=E7=9B=AE=20sprite=20=E5=B0=BA=E5=AF=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 交付帧一直是 256×256 方形,而项目的 sprite 尺寸是 sprite_width×sprite_height (API 允许 32~2048,且宽高各自独立、可非方)。上层拿到 256 的帧再缩到项目尺寸, 问题不是"糊一点":那一步用 Image.thumbnail,而 **thumbnail 只缩不放**。 2026-08-11 实测复刻上层这段逻辑,喂一张主体高 157px、脚线 0.92 的 256 交付帧: 目标 512×512 → 画布 512,主体仍 157px(根本没放大),脚线 0.92 → 0.709 目标 384×384 → 画布 384,主体仍 157px, 脚线 0.92 → 0.779 目标 128×128 → 画布 128,主体 78px, 脚线 0.914(缩小这侧正常) 也就是说放大方向上,align_bottom_center 刚对齐好的脚线被整体挪高,角色不站在地上, 跨动作对齐(ref_height 那套)也一起失效。根治办法是引擎一次就出到目标尺寸。 align_bottom_center 本来就接受 cell,按 cell 出 512 时主体高度实测 154 → 308, 确实翻倍;缺的只是**非方形**能力:cell 只能出方形,非方 sprite 仍得回到上层补边。 本次加 cell_h(None = 方形 cell×cell,默认行为不变),并把体内的几何拆成 cw(宽:水平居中、宽度兜底)与 ch(高:脚线、占高定标),不许串轴。 实测(同一组帧,ref_height=300): 默认 canvas 256×256 主体高 159 脚线 0.918 水平中心 0.498 cell=512 canvas 512×512 主体高 317 脚线 0.920 水平中心 0.500 cell=384,h=512 canvas 384×512 主体高 317 脚线 0.920 水平中心 0.500 cell=128,h=192 canvas 128×192 主体高 119 脚线 0.917 水平中心 0.496 **默认行为逐像素不变**:default / ref_height / preserve_lift / 宽主体兜底 / cell=128 / cell=512 六个用例改动前后 sha256 完全一致;另有用例钉死 "不传 cell_h" 与 "cell_h=cell" 两种写法逐像素相同。 几何用比例表达(foot_line / fill_h / fill_w),换画布尺寸不改变构图,所以母版入口 预检与出帧仍共用同一套几何 —— master_check.REJECT_ASPECT = 2*FILL_W/FILL_H 里 本来就没有 cell,与画布像素尺寸无关。用例 test_subject_fill_ratio_is_scale_invariant 在 128/256/512/1024 四档上钉死这条。 顺带:cell/cell_h 非正数改为报错。PIL 允许建 0×0 的图、alpha_composite 也不报错, 静默出一张空图要到落库或前端才暴露。 变异测试(8 个变异逐个改坏 → 确认变红 → 还原,全部被杀): P1 cell_h 默认写死 256 → doubling_cell_doubles_subject_height 红 P2 主体占高改按画布宽算 → non_square_applies_each_axis 红 P3 脚线改按画布宽算 → non_square_applies_each_axis 红 P4 水平居中改按画布高算 → non_square_applies_each_axis 红 P5 宽度兜底改按画布高算 → width_fallback_uses_canvas_width 红 P6 去掉画布尺寸校验 → non_positive_canvas_raises 红 P7 全透明兜底退回 256 方形 → all_transparent_honour_canvas 红 P8 出帧画布忽略请求值 → 另外 3 条红(默认档下该变异是恒等,测不到属正常) 本提交只动 ai_engine;把尺寸从 app 传进引擎的接线在上层分支。 CI: ruff / lint-imports(2 contracts kept) / pytest 255 passed 全过。 Co-Authored-By: Claude Opus 5 --- .../src/windup_ai_engine/postprocess/pack.py | 37 ++++-- backend/tests/test_pack_align.py | 114 ++++++++++++++++++ 2 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 backend/tests/test_pack_align.py diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py index ce9c78dc..53d2565b 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py @@ -19,6 +19,7 @@ def align_bottom_center( fill_w: float = 0.96, preserve_lift: bool = False, ref_height: float | None = None, + cell_h: int | None = None, ) -> list[Image.Image]: """按脚线对齐到统一画布,消除逐帧画布漂移(Issue #21)。 @@ -36,9 +37,27 @@ def align_bottom_center( ``preserve_lift``:腾空位移**默认不烘进像素**(业界:位移交引擎 root motion)。仅在要把 位移画进序列帧时才开;开启后以序列里最低的脚线为地面基准,保留每帧相对地面的抬升量。 + + ``cell``/``cell_h``:交付画布的宽与高,``cell_h=None`` 即方形 ``cell×cell``(默认, + 行为与加这个参数之前逐像素相同)。**要能出非方形画布,是为了让引擎一次就出到项目 + 要的 sprite 尺寸、不必在上层再缩一次。** 上层那次二次缩放不是"糊一点"那么简单: + 它用 ``Image.thumbnail`` 补边,而 thumbnail **只缩不放** —— 项目要 512 时 256 的帧 + 根本不会被放大,而是原尺寸居中贴进 512 画布,于是这里刚对齐好的脚线(0.92)被挪到 + 0.709(2026-08-11 实测),角色不站在地上了,跨动作对齐也一起失效。 + + 几何按"比例"而不是"像素"表达(``foot_line``/``fill_h``/``fill_w`` 都是比例),所以 + 换画布尺寸不改变构图,母版入口预检(``master_check.REJECT_ASPECT`` = 2*FILL_W/FILL_H) + 与出帧仍共用同一套几何 —— 那条阈值里没有 cell,本来就与画布像素尺寸无关。 """ import numpy as np + cw = cell + ch = cell if cell_h is None else cell_h + if cw < 1 or ch < 1: + # 不静默出一张 0×0:PIL 允许建 0 边长的图,后面 alpha_composite 也不报错, + # 错产物要到落库/前端才暴露。 + raise ValueError(f"交付画布尺寸必须为正,收到 cell={cell} cell_h={cell_h}") + boxes: list[tuple[int, int, int, int] | None] = [] for f in frames: ys, xs = np.where(np.asarray(f)[:, :, 3] > 128) @@ -49,39 +68,39 @@ def align_bottom_center( ) heights = [b[3] - b[1] for b in boxes if b] if not heights: - return [Image.new("RGBA", (cell, cell), (0, 0, 0, 0)) for _ in frames] + return [Image.new("RGBA", (cw, ch), (0, 0, 0, 0)) for _ in frames] # 腾空模式:以最低脚线(数值最大 = 站在地上)为地面基准,保留每帧的抬升量 ground = max(b[3] for b in boxes if b) if preserve_lift else 0 # 定标要把抬升量算进去,否则跳到最高时头顶会顶出画布被切掉 if preserve_lift: need = max((ground - b[3]) + (b[3] - b[1]) for b in boxes if b) - scale = (cell * fill_h) / max(1, need) + scale = (ch * fill_h) / max(1, need) elif ref_height: - scale = (cell * fill_h) / ref_height # 参考姿态定标(跨动作一致) + scale = (ch * fill_h) / ref_height # 参考姿态定标(跨动作一致) else: - scale = (cell * fill_h) / max(heights) # 回退:本序列最高帧 + scale = (ch * fill_h) / max(heights) # 回退:本序列最高帧 # 宽度兜底:上面三条分支**只按高度定标** —— 这是"主体是纵向长条"的人形先验。 - # 横向长条主体(四足兽/坐骑/龙)按同一系数缩放后宽度超过 cell,会被下面的 + # 横向长条主体(四足兽/坐骑/龙)按同一系数缩放后宽度超过画布宽,会被下面的 # alpha_composite 以负 dest **静默切掉**左右(PIL 不报错,直接丢像素)。 # 裁切悬崖 = 主体 w/h > 1/fill_h ≈ 1.61。实测(2026-08-05):狐狸母版 w/h=1.78 # 丢 27px(鼻尖+尾尖);w/h=2.0 只剩 79.9% 内容;狼/马常见 2.0-2.5 → 丢 19%-35% 体宽。 # 人形 w/h≈0.3-1.1 时该约束**恒不生效**,故人形产物逐像素不变。 widths = [b[2] - b[0] for b in boxes if b] - scale = min(scale, (cell * fill_w) / max(1, max(widths))) + scale = min(scale, (cw * fill_w) / max(1, max(widths))) out = [] for f, box in zip(frames, boxes): if box is None: - out.append(Image.new("RGBA", (cell, cell), (0, 0, 0, 0))) + out.append(Image.new("RGBA", (cw, ch), (0, 0, 0, 0))) continue crop = f.crop(box) w = max(1, round(crop.width * scale)) h = max(1, round(crop.height * scale)) crop = crop.resize((w, h), Image.NEAREST) lift = round((ground - box[3]) * scale) if preserve_lift else 0 - canvas = Image.new("RGBA", (cell, cell), (0, 0, 0, 0)) - canvas.alpha_composite(crop, (cell // 2 - w // 2, int(cell * foot_line) - h - lift)) + canvas = Image.new("RGBA", (cw, ch), (0, 0, 0, 0)) + canvas.alpha_composite(crop, (cw // 2 - w // 2, int(ch * foot_line) - h - lift)) out.append(canvas) return out diff --git a/backend/tests/test_pack_align.py b/backend/tests/test_pack_align.py new file mode 100644 index 00000000..e1d89653 --- /dev/null +++ b/backend/tests/test_pack_align.py @@ -0,0 +1,114 @@ +"""align_bottom_center 的交付画布几何(2026-08-11 挣得)。 + +为什么要这组用例:交付帧一直写死出 256×256 方形,而项目的 sprite 尺寸是 +``sprite_width×sprite_height``(32~2048,可非方)。上层拿到 256 的帧再 ``_fit_to`` +到项目尺寸,用的是 ``Image.thumbnail`` —— **它只缩不放**:项目要 512 时帧根本不会被 +放大,而是原尺寸居中贴进 512 画布,刚对齐好的脚线 0.92 被挪到 0.709(实测),角色不站 +在地上了。所以引擎必须能一次出到目标尺寸,而不是让上层再缩一次。 +""" + +import numpy as np +from PIL import Image + +from windup_ai_engine.postprocess.pack import align_bottom_center + +FILL_H = 0.62 # 与 pack.align_bottom_center 的默认值一致 +FOOT_LINE = 0.92 + + +def _frames(n=4, w=640, h=480, bh=300, bw=60): + """造一组"角色在画布里漂移"的帧(align 要消掉的正是这个漂移)。""" + out = [] + for i in range(n): + a = np.zeros((h, w, 4), dtype=np.uint8) + x0, y0 = 200 + i * 7, 60 + i * 5 + a[y0:y0 + bh, x0:x0 + bw] = (200, 80, 60, 255) + out.append(Image.fromarray(a, "RGBA")) + return out + + +def _subject(img: Image.Image): + """返回 (高, 脚线比例, 水平中心比例)。""" + a = np.asarray(img)[:, :, 3] + ys, xs = np.nonzero(a > 128) + w, h = img.size + return int(ys.max() - ys.min() + 1), (int(ys.max()) + 1) / h, (int(xs.min()) + int(xs.max())) / 2 / w + + +def test_default_canvas_is_256_square_with_foot_line_geometry(): + """默认仍是 256 方形,脚线 0.92、主体占高 0.62、水平居中。""" + out = align_bottom_center(_frames(), ref_height=300.0) + assert out[0].size == (256, 256) + height, foot, center = _subject(out[0]) + assert abs(height - 256 * FILL_H) <= 2 + assert abs(foot - FOOT_LINE) <= 0.01 + assert abs(center - 0.5) <= 0.01 + + +def test_omitting_cell_h_is_pixel_identical_to_square_cell(): + """不传 cell_h == 传 cell_h=cell —— 默认行为一个像素都不许变。""" + src = _frames() + a = align_bottom_center(src, ref_height=300.0) + b = align_bottom_center(src, ref_height=300.0, cell_h=256) + for x, y in zip(a, b, strict=True): + assert np.array_equal(np.asarray(x), np.asarray(y)) + + +def test_doubling_cell_doubles_subject_height(): + """指定 512 时交付帧主体高度翻倍 —— 这正是"交付帧太小"的修法。""" + src = _frames() + small = align_bottom_center(src, ref_height=300.0) + big = align_bottom_center(src, ref_height=300.0, cell=512) + assert big[0].size == (512, 512) + h_small = _subject(small[0])[0] + h_big = _subject(big[0])[0] + assert abs(h_big / h_small - 2.0) < 0.05, f"期望约翻倍,实际 {h_small} → {h_big}" + + +def test_non_square_canvas_applies_each_axis_to_the_right_dimension(): + """非方形画布:高度几何(脚线 / 占高)按高走,水平居中按宽走 —— 不能串轴。""" + out = align_bottom_center(_frames(), ref_height=300.0, cell=384, cell_h=512) + assert out[0].size == (384, 512) + height, foot, center = _subject(out[0]) + assert abs(height - 512 * FILL_H) <= 2, "主体占高必须按画布高算" + assert abs(foot - FOOT_LINE) <= 0.01, "脚线必须按画布高算" + assert abs(center - 0.5) <= 0.01, "水平居中必须按画布宽算" + + +def test_subject_fill_ratio_is_scale_invariant(): + """几何是"比例"不是"像素":换画布尺寸,主体占画布高的比例不变。 + + 这条是"母版入口预检与出帧共用同一套几何"的直接证据 —— 预检阈值 + (master_check.REJECT_ASPECT = 2*FILL_W/FILL_H)里没有 cell,本就与画布像素尺寸无关。 + """ + src = _frames() + ratios = [] + for cell in (128, 256, 512, 1024): + out = align_bottom_center(src, ref_height=300.0, cell=cell) + ratios.append(_subject(out[0])[0] / cell) + assert max(ratios) - min(ratios) < 0.01, f"占高比例应恒定,实测 {ratios}" + + +def test_width_fallback_uses_canvas_width_not_height(): + """宽度兜底(横向长条主体)要按画布**宽**收缩,否则宽画布上会白白缩小主体。""" + wide = [f.transpose(Image.ROTATE_90) for f in _frames(bh=300, bw=60)] + narrow = align_bottom_center(wide, cell=256, cell_h=256) + widened = align_bottom_center(wide, cell=512, cell_h=256) + # 画布变宽后,宽度兜底放松,主体应当更大(若按高算则两者相同) + assert _subject(widened[0])[0] > _subject(narrow[0])[0] + + +def test_non_positive_canvas_raises_instead_of_emitting_empty_image(): + """0 边长不静默出图:PIL 允许建 0×0,错产物要到落库/前端才暴露。""" + import pytest + + for kw in (dict(cell=0), dict(cell_h=0), dict(cell=-1)): + with pytest.raises(ValueError, match="画布尺寸"): + align_bottom_center(_frames(), **kw) + + +def test_all_transparent_frames_still_honour_requested_canvas(): + """全透明输入的兜底画布也要用请求的尺寸,不能退回 256 方形。""" + blank = [Image.new("RGBA", (64, 64), (0, 0, 0, 0)) for _ in range(3)] + out = align_bottom_center(blank, cell=320, cell_h=200) + assert [f.size for f in out] == [(320, 200)] * 3 From 9ccb6f66f264c274b4c22e4fe8557e1e5ae580a3 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Fri, 7 Aug 2026 16:47:13 +0800 Subject: [PATCH 21/33] =?UTF-8?q?feat(ai=5Fengine):=20=E5=AF=B9=E5=A4=96?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=20ports=20+=20=E5=8A=A8=E4=BD=9C=E5=88=86?= =?UTF-8?q?=E6=B5=81=20strategy=20+=20=E4=B8=B2=E8=81=94=20CharacterGenera?= =?UTF-8?q?tor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server 与生成引擎之间的唯一边界,以及"哪个动作走哪条生成路线"这个架构决策。 ports/ server 只 import 这里,由 CI 的 import-linter 分层门禁强制。 CharacterGeneratorPort.generate(card, action, master, progress) -> GeneratedAction 边界:ai_engine 只产出帧 bytes + 逐帧时长,不碰存储 / 数据库 / 任务状态。母版由 server 从 Character.reference_image_url 取好以 bytes 传入;产出的帧由 server 上传 对象存储、写 character_data。依据是"谁掌握租户与配额上下文"——bucket、路径规则、 归属项目、配额全在 server;ai_engine 自持存储等于把租户概念下沉到一个只做图像计算 的层。代价是帧 bytes 在内存过一次(16 帧 512×512 RGBA ≈ 16MB,可接受)。 strategy/ ROUTE_MATRIX 是实测挣得的架构契约,改它 = 改产线。 walk / run / jump / attack / idle -> VIDEO_I2V;hit -> PER_FRAME 依据:逐帧独立生成锁不住"哪条腿在前"(踢踏舞),视频天生连贯、腿自然交替; hit 这类离散姿势单帧可编辑价值高、无连续步态。Refs 1024XEngineer/Windup#35 #53。 impl/CharacterGenerator 选路线 -> strategy.derive 出帧 -> 脚线对齐 -> GeneratedAction。 与 #53 原设计的两处差异: 1) idle 从 PROC_IDLE 改走 VIDEO_I2V,GenRoute.PROC_IDLE 与 ProcIdleStrategy 一并移除。 #53 原设计 idle 走 ¥0 的程序化局部呼吸(Idle-B),实测做不出可用效果,放弃,认这份 i2v 的钱。不留没有实现的枚举值。 2) 未实现的路线抛错,不返回空帧。 旧桩实现 return [b""] * n_frames,调用方拿到的 GeneratedAction 帧数对、时长对、 无异常——完全像一次成功的生成。server 会把 N 个 0 字节文件传上对象存储、写进 character_data,用户看到 N 张裂图,排查时不会想到是路线没实现。 现在 PerFrameStrategy 调用即抛 NotImplementedError;装配表缺该路线时抛错并报出 已装配了哪些;strategy 吐出空帧时抛 ValueError。四条回归测试拿旧实现对照过, 确认在修复前全部失败。 另记录 ROUTE_MATRIX 形状的已知边界:它是「动作类型 → 路线」一对一映射,隐含前提是 "路线由动作的物理性质唯一决定"。该前提对逐帧 / 视频成立,但对渲染出帧路线不成立—— 同一个 walk 走 i2v 还是走渲染,取决于该角色有没有 3D 模型,那是 server 才知道的事。 接入第三条路线前须先定「路线选择由谁决定」。 本分支 stack 在 feat/character-domain-models、feat/provider-interfaces-and-matte、 feat/ai-engine-frame-toolkit 之上,那三个合并后 rebase。 --- .../src/windup_ai_engine/impl/__init__.py | 5 + .../impl/character_generator.py | 99 +++++++ .../src/windup_ai_engine/ports/__init__.py | 59 ++++ .../src/windup_ai_engine/strategy/__init__.py | 11 + .../src/windup_ai_engine/strategy/base.py | 56 ++++ .../src/windup_ai_engine/strategy/concrete.py | 158 +++++++++++ backend/tests/test_ai_engine_skeleton.py | 186 +++++++++++++ backend/uv.lock | 253 +----------------- 8 files changed, 576 insertions(+), 251 deletions(-) create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py create mode 100644 backend/tests/test_ai_engine_skeleton.py diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py new file mode 100644 index 00000000..456b868a --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py @@ -0,0 +1,5 @@ +"""impl:CharacterGeneratorPort 的装配实现(串联 strategy + 最后一公里)。""" + +from .character_generator import CharacterGenerator + +__all__ = ["CharacterGenerator"] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py new file mode 100644 index 00000000..13051393 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -0,0 +1,99 @@ +"""CharacterGenerator —— 装配 strategy + 最后一公里,串起整条生产线(架构串联点)。 + +这是 CharacterGeneratorPort 的实现;server 经 port 调它、不碰这里。 +串联:选路线(ROUTE_MATRIX)→ strategy.derive 出帧 → 最后一公里(脚线对齐)→ GeneratedAction。 + +MVP 边界(与作者对齐):**只出帧 bytes + 逐帧时长**,不打包 sprite sheet、不落存储—— +上传对象存储、写 character_data、拼图集/多格式导出由 server / export 侧做(#22)。 +""" +from __future__ import annotations + +import io + +from PIL import Image + +from windup_common.models import ActionSpec, CharacterCard, GenRoute + +from windup_ai_engine.ports import ( + CharacterGeneratorPort, + GeneratedAction, + ProgressPort, +) +from windup_ai_engine.postprocess import align_bottom_center, frame_durations +from windup_ai_engine.strategy.base import ROUTE_MATRIX, DerivationStrategy + + +def _png(img: Image.Image) -> bytes: + buf = io.BytesIO() + img.convert("RGBA").save(buf, "PNG") + return buf.getvalue() + + +def _img(png: bytes) -> Image.Image: + return Image.open(io.BytesIO(png)).convert("RGBA") + + +class CharacterGenerator(CharacterGeneratorPort): + """由 bootstrap 注入 {GenRoute: DerivationStrategy} 装配表。""" + + def __init__(self, strategies: dict[GenRoute, DerivationStrategy]) -> None: + self._by_route = strategies + + def generate( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> GeneratedAction: + # ① 选路线(架构决策矩阵)。装配表里没有 = 该路线未实现,在边界上炸, + # 不要让"看着成功、内容是空"的结果流到 server 去落库。 + route = ROUTE_MATRIX[action.action] + progress.step("route", 0, 3, f"{action.action} → {route.value}") + strategy = self._by_route.get(route) + if strategy is None: + raise NotImplementedError( + f"动作 {action.action.value} 分流到 {route.value},但未注入该路线的 strategy。" + f"已装配:{sorted(r.value for r in self._by_route)}。" + ) + + # ② 生成帧(交给 strategy —— 串联) + frames = strategy.derive(card, action, master, progress) + + # ③ 最后一公里:脚线对齐成原地序列帧 + frames = self._lastmile(frames, progress) + + # ④ 出参:帧 + 逐帧时长(上传 / 落库在 server 侧) + progress.step("package", 2, 3, f"{len(frames)} 帧 + 逐帧时长") + return GeneratedAction( + frames=frames, + durations=frame_durations(action.action.value, len(frames)), + fps=action.fps, + ) + + def _lastmile(self, frames: list[bytes], progress: ProgressPort) -> list[bytes]: + """脚线对齐:把各帧对齐成原地序列帧(消除逐帧画布漂移,Issue #21)。 + + 位移轨道(root_motion)MVP 先不做(见 #63 / character_data.frames 暂无该字段): + 序列帧保持原地即可,位移留给后续 export / playtest 阶段再算。 + """ + progress.step("lastmile", 1, 3, "脚线对齐(原地)") + # 空帧不再静默跳过:未实现的路线现在在 strategy / 装配表处就抛错(见 generate), + # 走到这里还有空帧说明 provider 或抠图吐了坏数据,同样要炸而不是原样放行。 + if not frames: + raise ValueError("strategy 未产出任何帧") + bad = [i for i, f in enumerate(frames) if not f] + if bad: + raise ValueError(f"strategy 产出了 {len(bad)}/{len(frames)} 个空帧,索引 {bad[:8]}") + imgs = [_img(f) for f in frames] + # 参考姿态高 = 各帧包围盒高的中位数:比"最高帧"稳(不被举过头顶的武器带偏), + # 各动作都以自身中位姿态定标,本体尺寸跨动作一致。 + import numpy as _np + _hs = [] + for _im in imgs: + _ys, _ = _np.where(_np.asarray(_im)[:, :, 3] > 128) + if len(_ys): + _hs.append(float(_ys.max() - _ys.min())) + aligned = align_bottom_center(imgs, ref_height=(float(_np.median(_hs)) if _hs else None)) + # TODO(dev, #21): tail_match 循环闭合(净位移动作先锚点再匹配帧) + return [_png(im) for im in aligned] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py new file mode 100644 index 00000000..efe91c64 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py @@ -0,0 +1,59 @@ +"""ai_engine 对外契约(ports)—— server 只 import 这里,不碰 slicing / strategy / impl。 + +CI 的 import-linter 分层门禁会强制:app.server 依赖只到 ai_engine.ports。 +换掉内部实现(strategy / provider)时 server 零改动。 + +MVP 边界(与作者对齐):ai_engine **只产出帧 bytes + 进度**,不碰存储 / DB。 +母版(master)由 server 侧从 ``Character.reference_image_url`` 取好、以 bytes 传入; +产出的帧由 server 侧上传对象存储、落 ``character_data``。故本层无 ArtifactStore 依赖。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + +from windup_common.models import ActionSpec, CharacterCard + + +# ---- server 实现、注入给 ai_engine 的进度回调 port ---- +class ProgressPort(Protocol): + """进度上报 —— server 转 SSE / 轮询状态(取代管线里的 print)。""" + + def step(self, stage: str, i: int, total: int, note: str = "") -> None: ... + + +# ---- ai_engine 出参(不含存储引用:上传 / 落库在 server 侧)---- +@dataclass +class GeneratedAction: + """一个动作的生成产物:对齐后的原地序列帧 + 逐帧时长。 + + frames / durations **等长**;server 侧把每帧上传对象存储得 URL,组成 + ``CharacterActionOutput.frames[{index, image_url, duration_ms}]`` 回填 character_data。 + """ + + frames: list[bytes] = field(default_factory=list) # RGBA PNG,按播放序 + durations: list[int] = field(default_factory=list) # 逐帧时长(ms),与 frames 等长 + fps: int = 10 + + +# ---- ai_engine 暴露给 server(server 调用的唯一入口)---- +@runtime_checkable +class CharacterGeneratorPort(Protocol): + """生成入口:角色卡 + 动作规格 + 母版 → 帧序列产物。 + + 不关心租户 / 配额 / 任务状态 / 存储(那些在 app.server)。 + + Args: + card: 角色卡(身份 / 画风 / 朝向)。 + action: 动作规格(类型 / 帧数 / 风格化 / 朝向)。 + master: 定妆母版图 bytes(server 从 reference_image_url 取)。 + progress: 进度回调。 + """ + + def generate( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> GeneratedAction: ... diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py new file mode 100644 index 00000000..3c45e18c --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py @@ -0,0 +1,11 @@ +"""strategy:动作 → 生成路线分流(ROUTE_MATRIX)+ 三条 DerivationStrategy。""" + +from .base import ROUTE_MATRIX, DerivationStrategy +from .concrete import PerFrameStrategy, VideoFrameStrategy + +__all__ = [ + "ROUTE_MATRIX", + "DerivationStrategy", + "VideoFrameStrategy", + "PerFrameStrategy", +] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py new file mode 100644 index 00000000..8222deab --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py @@ -0,0 +1,56 @@ +"""DerivationStrategy —— 按动作类型分流到生成路线(本营实测挣得的核心架构决策)。 + +分流依据(有实测证据,非拍脑袋,详见关联 Issue #35 的工程文档): + - 步态位移(walk / run):逐帧独立生成锁不住"哪条腿在前" → 踢踏舞; + 必须走视频 i2v(视频模型天生连贯、腿自然交替)。 + - 动作爆发(attack)与跳跃(jump):同走视频 i2v。但它们是**一次性动作**,抽帧不闭环 + (见 strategy.concrete.CYCLIC_ACTIONS);jump 还要按状态切段供引擎分段播放。 + - 受击等离散姿势(hit):逐帧图生图(单帧可编辑价值高,无连续步态)。 + - 待机(idle):逐帧生成只抖不呼吸 → 程序化局部呼吸 Idle-B。 + +ROUTE_MATRIX 是人主导的架构契约,改它=改产线,要有实测支撑。 +""" +from __future__ import annotations + +from abc import ABC, abstractmethod + +from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute + +from windup_ai_engine.ports import ProgressPort + +# 动作类型 → 生成路线(架构决策,写死为契约) +ROUTE_MATRIX: dict[ActionType, GenRoute] = { + ActionType.WALK: GenRoute.VIDEO_I2V, + ActionType.RUN: GenRoute.VIDEO_I2V, + ActionType.JUMP: GenRoute.VIDEO_I2V, + ActionType.ATTACK: GenRoute.VIDEO_I2V, + ActionType.HIT: GenRoute.PER_FRAME, + # idle 走 i2v(build_idle_prompt:躯干缓慢起伏呼吸)。 + # **2026-08-07 定案**:#53 原设计的 ¥0 程序化 Idle-B(局部网格呼吸)放弃 —— 做不出 + # 可用效果,idle 认这份 i2v 的钱。GenRoute.PROC_IDLE 与 ProcIdleStrategy 已一并移除。 + ActionType.IDLE: GenRoute.VIDEO_I2V, +} + +# 本矩阵的形状本身有个已知边界,记录在此以免后来者按错误前提扩展: +# 它是「动作类型 → 路线」的一对一映射,隐含前提是"路线由动作的物理性质唯一决定"。 +# 该前提对逐帧 / 视频两条路线成立(有无连续步态是动作固有属性),但对渲染出帧路线不成立 +# —— 同一个 walk 既可走 i2v 也可走渲染,选哪条取决于"该角色有没有 3D 模型",那是 server +# 才知道的事。接入第三条路线前须先定「路线选择由谁决定」,并可能要把本矩阵改成 +# 「动作类型 → 可选路线集合」+ 一个选择器。Refs 1024XEngineer/Windup#81 #122。 + + +class DerivationStrategy(ABC): + """一条生成路线的骨架:母版 → 对齐前的角色帧序列。""" + + route: GenRoute + + @abstractmethod + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + """从母版 bytes 产出对齐前的角色帧(RGBA PNG bytes 列表)。""" + raise NotImplementedError diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py new file mode 100644 index 00000000..43935f76 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py @@ -0,0 +1,158 @@ +"""三条 DerivationStrategy。 + +- VideoFrameStrategy:**已迁入 windup-pipeline 实测通路**(walk 主链,2026-07-27 验证)。 +- PerFrameStrategy:**未实现**,调用即抛 NotImplementedError(见 #53)。不返回空帧—— + 空帧会伪装成一次成功的生成流到 server 落库,用户看到的是一组裂图。 + +VideoFrameStrategy 实测通路:严格侧面母版 → kling i2v(v2-5-turbo) → 抽单循环 N 帧 → +matte 抠图 → 像素化。返回对齐前的 RGBA PNG 帧(对齐 / 打包在 CharacterGenerator 最后一公里)。 +""" +from __future__ import annotations + +import io + +import numpy as np +from PIL import Image + +from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute +from windup_framework.providers import ImageProvider, MatteProvider, VideoProvider + +from windup_ai_engine.master_prep import prepare_master +from windup_ai_engine.ports import ProgressPort +from windup_ai_engine.postprocess import master_pixel_spec, pixelate_frames +from windup_ai_engine.slicing import extract_all_frames_bytes, pick_cycle, pick_oneshot +from windup_ai_engine.prompt import ( + build_attack_prompt, + build_idle_prompt, + build_jump_prompt, + build_walk_prompt, +) +from windup_ai_engine.strategy.base import DerivationStrategy + + +def _png(img: Image.Image) -> bytes: + buf = io.BytesIO() + img.convert("RGBA").save(buf, "PNG") + return buf.getvalue() + + +def _img(png: bytes) -> Image.Image: + return Image.open(io.BytesIO(png)).convert("RGBA") + + +# 循环类动作走"步态周期抽单周期闭环";一次性动作**不能闭环**(首尾姿态不同,强行闭环 +# 会把落地帧接回蓄力帧=抽搐),改走"裁动作区间 + 区间内均匀取"。 +CYCLIC_ACTIONS = frozenset({ActionType.IDLE, ActionType.WALK, ActionType.RUN}) + + +class VideoFrameStrategy(DerivationStrategy): + """视频路线:母版 → i2v → 抽帧 → 抠图 → 像素化。 + + 覆盖循环类(walk/run)与一次性类(jump/attack)——按 :data:`CYCLIC_ACTIONS` 分流抽帧方式。 + 硬前提:**提示词朝向必须与母版一致**(side/front);给正面母版喂侧走词会让模型靠转身 + 调和图文矛盾(实测 #35)。 + """ + + route = GenRoute.VIDEO_I2V + + def __init__(self, video: VideoProvider, matte: MatteProvider) -> None: + self._video = video + self._matte = matte + + def _build_prompt(self, action: ActionSpec) -> str: + """按动作类型选提示词;朝向随 ActionSpec.facing。""" + builders = { + ActionType.JUMP: build_jump_prompt, + ActionType.IDLE: build_idle_prompt, + ActionType.ATTACK: build_attack_prompt, + } + build = builders.get(action.action, build_walk_prompt) + return build(facing=action.facing) + + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + n = action.n_frames or 8 + progress.step("derive", 0, 3, f"{action.action}: i2v 生成视频") + # 母版按动作预处理:jump 要在顶部补空间,否则角色腾空时头顶顶出视频画面被裁 + framed = prepare_master(master, action.action.value) + video = self._video.i2v(framed, self._build_prompt(action), seconds=5) + + dense = extract_all_frames_bytes(video) + # 跨动作一致性:用视频首帧(=母版姿态)的角色高当共同定标基准。各动作都从同一母版 + # 起手,故此值一致 —— 否则各动作按自己最高帧定标,切状态时角色会忽大忽小。 + ref_h = None + if dense: + _first = _img(self._matte.cutout(_png(dense[0]))) + _ys, _ = np.where(np.asarray(_first)[:, :, 3] > 128) + ref_h = float(_ys.max() - _ys.min()) if len(_ys) else None + if action.action in CYCLIC_ACTIONS: + progress.step("derive", 1, 3, f"步态周期取 {n} 帧(无缝 loop)+ 抠图") + picked = pick_cycle(dense, n) # 单周期闭环(#21) + else: + progress.step("derive", 1, 3, f"裁动作区间取 {n} 帧(不闭环)+ 抠图") + kind = "airborne" if action.action is ActionType.JUMP else "swing" + picked = pick_oneshot(dense, n, kind=kind) # 一次性动作:裁起止 + cut = [_img(self._matte.cutout(_png(im))) for im in picked] + + # 风格化按需(见 ActionSpec.stylize):none=保留 i2v 画风(插画/伪 3D 角色); + # pixel=像素化。原生像素角色**按母版规格**做:吸附母版像素网格 + 锁母版色板, + # 顺带消掉首帧 JPG / H.264 在硬边留下的灰颗粒(实测:通用降采样+量化反而更糊)。 + if action.stylize == "none": + progress.step("derive", 2, 3, "保留 i2v 画风(不像素化)") + return [_png(im) for im in cut] + + target_h, palette = action.pixel_h, None + try: + logical_h, pal = master_pixel_spec(_img(master)) # 用原始母版,不用补过边的 + if logical_h > 8: # 母版确为像素画 → 按它的规格走 + target_h, palette = logical_h, pal + except Exception: # 母版非像素画/量不出 → 回退通用量化 + pass + progress.step( + "derive", 2, 3, + f"像素化(h={target_h}{'·锁母版色板' if palette is not None else '·通用量化'})", + ) + pix = pixelate_frames( + cut, target_h=target_h, palette_size=action.palette_size, + palette=palette, ref_height=ref_h, + ) + return [_png(p) for p in pix] + + +class PerFrameStrategy(DerivationStrategy): + """离散姿势(hit 等,需单帧可编辑):逐帧图生图 → 抠图。**未实现**(#53)。 + + 这条路线的价值在"单帧可重画",与 i2v 是不同的产品能力,不能拿 i2v 顶替。 + """ + + route = GenRoute.PER_FRAME + + def __init__(self, image: ImageProvider, matte: MatteProvider) -> None: + self._image = image + self._matte = matte + + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + # 显式抛错,**不返回空帧**。曾经的桩实现 `return [b""] * n_frames` 会让调用方拿到 + # 一个"帧数对、时长对、无异常"的 GeneratedAction —— server 照常把 N 个 0 字节文件 + # 传上对象存储、写进 character_data,用户看到 N 张裂图,且排查时不会想到是路线没实现。 + # 未实现就要在边界上炸,不能让空数据流下去。 + raise NotImplementedError( + f"生成路线 {self.route.value} 尚未实现(动作 {action.action.value})。" + "见 1024XEngineer/Windup#53。" + ) + + +# 注:曾有 ProcIdleStrategy(GenRoute.PROC_IDLE)—— 待机走"母版抠图 + 程序化局部躯干呼吸" +# 的零 API 路线(Idle-B,#53 原设计)。**2026-08-07 定案放弃**:程序化呼吸做不出可用效果, +# idle 统一走 i2v、认这份钱。GenRoute.PROC_IDLE 一并移除,不留没有实现的枚举值。 diff --git a/backend/tests/test_ai_engine_skeleton.py b/backend/tests/test_ai_engine_skeleton.py new file mode 100644 index 00000000..1978d0ba --- /dev/null +++ b/backend/tests/test_ai_engine_skeleton.py @@ -0,0 +1,186 @@ +"""ai_engine 串联 smoke —— 验证架构串联成立:路由正确 + generate 端到端跑通。 + +策略内部(真实 i2v)用 mock / monkeypatch 顶替(真实生成联网、抽帧要解码 mp4); +本测证明"选路线 → derive → 最后一公里(真实对齐)→ GeneratedAction(帧 + 时长)"这条串联为真。 +""" +from __future__ import annotations + +import io + +from PIL import Image + +from windup_ai_engine.impl import CharacterGenerator +from windup_ai_engine.ports import GeneratedAction +from windup_ai_engine.strategy import ( + ROUTE_MATRIX, + DerivationStrategy, + VideoFrameStrategy, +) +from windup_common.models import ( + ActionSpec, + ActionType, + CharacterCard, + GenRoute, +) + + +def _tiny_png(color=(200, 60, 60, 255), shift=0) -> bytes: + """一张带主体的小 RGBA PNG(四周留透明边,供真实对齐 / 抠图链处理)。""" + img = Image.new("RGBA", (64, 96), (0, 0, 0, 0)) + for y in range(20, 80): + for x in range(24 + shift, 40 + shift): + img.putpixel((x, y), color) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +class _NullProgress: + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + pass + + +class _MockWalkStrategy(DerivationStrategy): + """顶替真实 VideoFrameStrategy:返回 N 张真 PNG,让对齐真跑。""" + + route = GenRoute.VIDEO_I2V + + def derive(self, card, action, master, progress) -> list[bytes]: + return [_tiny_png() for _ in range(action.n_frames)] + + +def _make_generator() -> CharacterGenerator: + return CharacterGenerator({GenRoute.VIDEO_I2V: _MockWalkStrategy()}) + + +def test_route_matrix_is_the_measured_contract(): + # 实测挣得的架构决策:走路/跑/攻击走视频,受击逐帧,待机程序化 + assert ROUTE_MATRIX[ActionType.WALK] is GenRoute.VIDEO_I2V + assert ROUTE_MATRIX[ActionType.RUN] is GenRoute.VIDEO_I2V + assert ROUTE_MATRIX[ActionType.ATTACK] is GenRoute.VIDEO_I2V + assert ROUTE_MATRIX[ActionType.JUMP] is GenRoute.VIDEO_I2V + assert ROUTE_MATRIX[ActionType.HIT] is GenRoute.PER_FRAME + assert ROUTE_MATRIX[ActionType.IDLE] is GenRoute.VIDEO_I2V + + +def test_generate_walk_is_wired_end_to_end(): + card = CharacterCard(name="rogue", desc="hooded ranger, dual daggers") + action = ActionSpec(action=ActionType.WALK, poses=["p"] * 8) + out = _make_generator().generate(card, action, master=_tiny_png(), progress=_NullProgress()) + assert isinstance(out, GeneratedAction) + assert len(out.frames) == 8 # 选路线→derive→对齐 全串通 + assert len(out.durations) == 8 # 逐帧时长与帧等长 + assert out.fps == action.fps + assert all(f and f[:8] == b"\x89PNG\r\n\x1a\n" for f in out.frames) # 真 PNG + + +def test_action_spec_stylize_defaults_and_toggle(): + # 像素化是开关(默认 pixel),可关成 none 保留 i2v 画风 + assert ActionSpec(action=ActionType.WALK).stylize == "pixel" + a = ActionSpec(action=ActionType.WALK, stylize="none") + assert a.stylize == "none" + + +def test_video_strategy_derive_runs_offline(monkeypatch): + """真实 VideoFrameStrategy.derive 离线跑通(抽帧被顶替,不解码 mp4 / 不联网)。 + + 证明 derive 的真实链路:i2v → 抽帧 → 抠图 → 选帧 → 出帧,产物是合法 RGBA PNG。 + """ + dense = [Image.open(io.BytesIO(_tiny_png(shift=i % 6))).convert("RGBA") for i in range(24)] + monkeypatch.setattr( + "windup_ai_engine.strategy.concrete.extract_all_frames_bytes", + lambda video, cap=150: dense, + ) + + class _StubVideo: + def i2v(self, first_frame, prompt, seconds=5, size="1280x720"): + return b"fake-mp4" + + class _StubMatte: + def cutout(self, frame): # 透传:合成帧已带 alpha + return frame + + strat = VideoFrameStrategy(_StubVideo(), _StubMatte()) + card = CharacterCard(name="knight", desc="plate armor, sword") + action = ActionSpec(action=ActionType.WALK, stylize="none", poses=["p"] * 8) + out = strat.derive(card, action, master=_tiny_png(), progress=_NullProgress()) + assert out and all(f[:8] == b"\x89PNG\r\n\x1a\n" for f in out) + + +def test_real_video_strategy_is_registered_for_video_route(): + # 真实 VideoFrameStrategy 可构造且声明视频路线(derive 联网,不在此跑) + class _V: + def i2v(self, first_frame, prompt, seconds=5, size="1280x720"): + return b"" + + class _M: + def cutout(self, frame): + return frame + + strat = VideoFrameStrategy(_V(), _M()) + assert strat.route is GenRoute.VIDEO_I2V + + +# ── 未实现的路线必须炸,不能吐空帧(2026-08-07)───────────────────────────── +# +# 旧行为:PerFrameStrategy.derive 返回 [b""] * n_frames,CharacterGenerator._lastmile +# 见到空帧就静默跳过对齐、原样返回。调用方拿到的 GeneratedAction 帧数对、时长对、 +# 无异常 —— 完全像一次成功的生成。server 会把 N 个 0 字节文件传上对象存储、写进 +# character_data,用户看到 N 张裂图,且排查时不会想到是"路线没实现"。 +# +# 新行为:在最早能判定的边界上抛错。下面三条分别覆盖三个入口。 + + +def test_unimplemented_route_raises_instead_of_returning_empty_frames(): + """PerFrameStrategy 调用即抛,不返回空帧。""" + import pytest + + from windup_ai_engine.strategy import PerFrameStrategy + + s = PerFrameStrategy(image=None, matte=None) + card = CharacterCard(name="t", desc="t") + action = ActionSpec(action=ActionType.HIT, poses=["a", "b", "c"]) + with pytest.raises(NotImplementedError, match="per_frame"): + s.derive(card, action, _tiny_png(), _NullProgress()) + + +def test_missing_strategy_for_route_raises_with_what_is_wired(): + """装配表里没有该路线时抛错,并报出已装配了哪些 —— 便于定位是漏注入还是没实现。""" + import pytest + + # 只装 VIDEO_I2V,请求 hit(分流到 PER_FRAME) + gen = CharacterGenerator({GenRoute.VIDEO_I2V: _MockWalkStrategy()}) + card = CharacterCard(name="t", desc="t") + action = ActionSpec(action=ActionType.HIT, poses=["a", "b"]) + with pytest.raises(NotImplementedError, match="video_i2v"): + gen.generate(card, action, _tiny_png(), _NullProgress()) + + +def test_empty_frames_from_strategy_are_rejected(): + """strategy 吐出空帧(provider / 抠图坏了)时同样要炸,不原样放行。""" + import pytest + + class _EmptyStrategy(DerivationStrategy): + route = GenRoute.VIDEO_I2V + + def derive(self, card, action, master, progress) -> list[bytes]: + return [b"", b"", b""] + + gen = CharacterGenerator({GenRoute.VIDEO_I2V: _EmptyStrategy()}) + card = CharacterCard(name="t", desc="t") + action = ActionSpec(action=ActionType.WALK, poses=["a", "b", "c"]) + with pytest.raises(ValueError, match="空帧"): + gen.generate(card, action, _tiny_png(), _NullProgress()) + + +def test_genroute_only_lists_implemented_routes(): + """GenRoute 只列有实现的路线 —— 没有实现的枚举值等于死代码。 + + 这条同时管住两个方向: + - PROC_IDLE(程序化待机,#53 原设计)已证否,连同 ProcIdleStrategy 一并移除; + - 未来路线(三渲二渲染出帧)**不提前留位**,契约需求记在 Issue,随实现一起加成员。 + 枚举加成员是纯加法,不构成破坏性变更,所以"提前留位免得二次改形"不成立。 + """ + assert {r.value for r in GenRoute} == {"video_i2v", "per_frame"} + import windup_ai_engine.strategy as strat + assert not hasattr(strat, "ProcIdleStrategy") diff --git a/backend/uv.lock b/backend/uv.lock index 5e1a6cfd..d817fb61 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -14,7 +14,6 @@ members = [ dev = [ { name = "import-linter", specifier = ">=2.0" }, { name = "pytest", specifier = ">=8.0" }, - { name = "pytest-cov", specifier = ">=5.0" }, { name = "ruff", specifier = ">=0.6" }, ] @@ -75,72 +74,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c" }, ] -[[package]] -name = "bcrypt" -version = "5.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be" }, - { url = "https://mirrors.aliyun.com/pypi/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493" }, - { url = "https://mirrors.aliyun.com/pypi/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10" }, - { url = "https://mirrors.aliyun.com/pypi/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927" }, -] - [[package]] name = "certifi" version = "2026.7.22" @@ -244,105 +177,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934" }, ] -[[package]] -name = "coverage" -version = "7.15.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921" }, - { url = "https://mirrors.aliyun.com/pypi/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734" }, - { url = "https://mirrors.aliyun.com/pypi/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645" }, - { url = "https://mirrors.aliyun.com/pypi/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" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84" }, -] - [[package]] name = "distro" version = "1.9.0" @@ -352,28 +186,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, ] -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af" }, -] - -[[package]] -name = "email-validator" -version = "2.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "dnspython" }, - { name = "idna" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4" }, -] - [[package]] name = "fastapi" version = "0.139.2" @@ -1095,20 +907,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, ] -[[package]] -name = "passlib" -version = "1.7.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1" }, -] - -[package.optional-dependencies] -bcrypt = [ - { name = "bcrypt" }, -] - [[package]] name = "pillow" version = "12.3.0" @@ -1277,11 +1075,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" }, ] -[package.optional-dependencies] -email = [ - { name = "email-validator" }, -] - [[package]] name = "pydantic-core" version = "2.46.4" @@ -1414,20 +1207,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, ] -[[package]] -name = "pytest-cov" -version = "7.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "coverage" }, - { name = "pluggy" }, - { name = "pytest" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" @@ -1504,15 +1283,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/1d/56/9368cd96d2132017f5748a812cb20a87263498de314e1823472cb4bdbad9/qiniu-7.18.0-py3-none-any.whl", hash = "sha256:0f1be608ac6800ad5f32690d1aa02353b6b2ff5edc78f32a2318859d375a27df" }, ] -[[package]] -name = "redis" -version = "8.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb" }, -] - [[package]] name = "regex" version = "2026.7.19" @@ -1628,19 +1398,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, ] -[[package]] -name = "resend" -version = "2.35.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f0/b9/e07f2f2bd992ef4565b9c315dfd46b3df66ce89df1d928f442b9b39cbe3b/resend-2.35.0.tar.gz", hash = "sha256:26ced7b22cbd89f7b8c7ba9719d0708ab10c06ddd0f91ba6ec861f3a328101b3" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/cc/92/1c0912b68ae082a55dfdd32e4b5117905ae9fd1b6efc5f5e7c69a4ee6894/resend-2.35.0-py2.py3-none-any.whl", hash = "sha256:cd75299d626f4735af52910989b3f51919032ef316e2d60563c79c319e7b24a6" }, -] - [[package]] name = "rich" version = "15.0.0" @@ -2132,7 +1889,7 @@ version = "0.1.0" source = { editable = "packages/app" } dependencies = [ { name = "fastapi" }, - { name = "pydantic", extra = ["email"] }, + { name = "pydantic" }, { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, @@ -2144,7 +1901,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, - { name = "pydantic", extras = ["email"], specifier = ">=2.7" }, + { name = "pydantic", specifier = ">=2.7" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, @@ -2174,15 +1931,12 @@ dependencies = [ { name = "langchain-openai" }, { name = "numpy" }, { name = "onnxruntime" }, - { name = "passlib", extra = ["bcrypt"] }, { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "qiniu" }, - { name = "redis" }, - { name = "resend" }, { name = "sqlalchemy" }, { name = "windup-common" }, ] @@ -2194,15 +1948,12 @@ requires-dist = [ { name = "langchain-openai", specifier = ">=0.3" }, { name = "numpy", specifier = ">=1.26" }, { name = "onnxruntime", specifier = ">=1.17,<1.24" }, - { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7" }, { name = "pillow", specifier = ">=10.4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.4" }, { name = "pyjwt", specifier = ">=2.9" }, { name = "qiniu", specifier = ">=7.14" }, - { name = "redis", specifier = ">=5.0" }, - { name = "resend", specifier = ">=2.0" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "windup-common", editable = "packages/common" }, ] From 7df44dbb57ef2ebff24aafe7a205322b1464f3fd Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Sat, 8 Aug 2026 00:24:20 +0800 Subject: [PATCH 22/33] =?UTF-8?q?refactor(ai=5Fengine):=20=E6=94=B6?= =?UTF-8?q?=E6=8B=A2=20PNG=20bytes=20=E2=86=94=20PIL=20=E7=9A=84=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=EF=BC=8C=E5=B9=B6=E9=87=8D=E6=96=B0=20stack=20?= =?UTF-8?q?=E5=88=B0=E4=B8=89=E4=B8=AA=E5=89=8D=E7=BD=AE=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管线内部按 PIL.Image 处理,跨模块边界(strategy → generator → ports 出参)按 PNG bytes 传递。这对转换此前在 strategy/concrete.py 与 impl/character_generator.py 各写了一份完整 拷贝,收成 _imgio.py 唯一定义(to_png / from_png)。编码参数一旦分叉,会在"某些帧丢了 alpha"这类只在画面上体现、不报错的地方出问题。 同步 stack:本分支重新对齐到 feat/character-domain-models、feat/provider-interfaces-and-matte、 feat/ai-engine-frame-toolkit 的当前终态,同名文件与三者逐字节一致。 --- .../ai_engine/src/windup_ai_engine/_imgio.py | 26 +++++++++++++++++++ .../impl/character_generator.py | 12 ++------- .../src/windup_ai_engine/strategy/concrete.py | 12 ++------- 3 files changed, 30 insertions(+), 20 deletions(-) create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/_imgio.py diff --git a/backend/packages/ai_engine/src/windup_ai_engine/_imgio.py b/backend/packages/ai_engine/src/windup_ai_engine/_imgio.py new file mode 100644 index 00000000..acec47d6 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/_imgio.py @@ -0,0 +1,26 @@ +"""PNG bytes ↔ PIL 的唯一转换口。 + +管线内部按 ``PIL.Image`` 处理,跨模块边界(strategy → generator → ports 出参)按 PNG +bytes 传递。这对转换此前在 ``strategy.concrete`` 与 ``impl.character_generator`` 各写 +了一份,收成一处 —— 编码参数(如是否强制 RGBA)一旦分叉,会在"某些帧丢了 alpha"这类 +只在画面上体现、不报错的地方出问题。 +""" +from __future__ import annotations + +import io + +from PIL import Image + +__all__ = ["to_png", "from_png"] + + +def to_png(img: Image.Image) -> bytes: + """PIL → PNG bytes。统一转 RGBA:下游脚线对齐靠 alpha 求包围盒。""" + buf = io.BytesIO() + img.convert("RGBA").save(buf, "PNG") + return buf.getvalue() + + +def from_png(png: bytes) -> Image.Image: + """PNG bytes → RGBA 图。""" + return Image.open(io.BytesIO(png)).convert("RGBA") diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py index 13051393..bb844684 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -8,12 +8,12 @@ """ from __future__ import annotations -import io -from PIL import Image from windup_common.models import ActionSpec, CharacterCard, GenRoute +from windup_ai_engine._imgio import from_png as _img +from windup_ai_engine._imgio import to_png as _png from windup_ai_engine.ports import ( CharacterGeneratorPort, GeneratedAction, @@ -23,14 +23,6 @@ from windup_ai_engine.strategy.base import ROUTE_MATRIX, DerivationStrategy -def _png(img: Image.Image) -> bytes: - buf = io.BytesIO() - img.convert("RGBA").save(buf, "PNG") - return buf.getvalue() - - -def _img(png: bytes) -> Image.Image: - return Image.open(io.BytesIO(png)).convert("RGBA") class CharacterGenerator(CharacterGeneratorPort): diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py index 43935f76..8da638a8 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py @@ -9,14 +9,14 @@ """ from __future__ import annotations -import io import numpy as np -from PIL import Image from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute from windup_framework.providers import ImageProvider, MatteProvider, VideoProvider +from windup_ai_engine._imgio import from_png as _img +from windup_ai_engine._imgio import to_png as _png from windup_ai_engine.master_prep import prepare_master from windup_ai_engine.ports import ProgressPort from windup_ai_engine.postprocess import master_pixel_spec, pixelate_frames @@ -30,14 +30,6 @@ from windup_ai_engine.strategy.base import DerivationStrategy -def _png(img: Image.Image) -> bytes: - buf = io.BytesIO() - img.convert("RGBA").save(buf, "PNG") - return buf.getvalue() - - -def _img(png: bytes) -> Image.Image: - return Image.open(io.BytesIO(png)).convert("RGBA") # 循环类动作走"步态周期抽单周期闭环";一次性动作**不能闭环**(首尾姿态不同,强行闭环 From 6f3986563deaf190b11d7ce4c95b5940275b70cd Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Sat, 8 Aug 2026 01:25:09 +0800 Subject: [PATCH 23/33] =?UTF-8?q?refactor(common):=20=E6=8A=8A=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E5=A5=91=E7=BA=A6=E9=87=8C=E9=9D=A0=E5=AD=97=E7=AC=A6?= =?UTF-8?q?=E4=B8=B2=E4=BC=A0=E9=80=92=E7=9A=84=E7=BA=A6=E6=9D=9F=E6=94=B6?= =?UTF-8?q?=E6=88=90=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit facing / loop / stylize / view 四个受限取值原先是裸 str,合法值只写在行尾注释里: 写成 "Side" / "sidee" 不报错、不告警,一路放行到 i2v 出片后才在画面上看出角色转了身, 一次误判的成本是一次付费生成。改成 Enum 后这类错误在构造 ActionSpec 时就是 ValidationError。prompt.build_* 是普通函数,注解不构成运行期约束,故各自显式过一遍 Facing() 构造(否则非法值会静默落到 FRONT 模板)。 n_frames 改成显式字段,不再由 len(poses) 推导 —— 视频路线根本不读 poses,推导等于 "想要 16 帧先编 16 条用不上的姿势描述",而读代码的人会以为那 16 条真的进了提示词。 只传 poses 的旧调用方行为不变;两者打架时抛错而不是猜哪个说了算。 CharacterCard.palette 删除:零消费方、无格式约定,而真正锁色的色板由 postprocess.master_pixel_spec 从母版像素量出来。留着它 = 调用方填了色板、管线照旧 用母版色板、不报错也不生效。CharacterCard 六个字段在 ai_engine 全零读取,不删, 但在 ports docstring 写清它是给未实现路线预留的入参。 顺带修掉三处同源问题: - 帧数对账。pick_cycle / pick_oneshot 在源帧不足时静默少给,时长表按 len(frames) 现算所以产物自洽,server 看不出异常,用户拿到一段步子没走完的循环。 - 进度文案里的枚举取 .value。Python 3.11+ 改了 str-mixin 枚举 __format__, f"{action.action}" 给的是 "ActionType.WALK"(3.12.13 实测),而这串字会变成 用户看到的 SSE 进度。 - fps/pixel_h/palette_size 的下界抄实现里已有的真实取值域,把"实现悄悄纠正入参" (palette_size=1 被 max(2,…) 抬成 2)提前成入参报错。 测试全部做过变异验证:24 个变异逐个确认打红对应用例(其中一个变异抓出本次新写的 no-mutation 用例只覆盖了校验器三条出口中的一条,已补全参数化)。 Co-Authored-By: Claude Opus 5 --- .../impl/character_generator.py | 18 +- .../src/windup_ai_engine/ports/__init__.py | 13 +- .../src/windup_ai_engine/prompt/actions.py | 16 +- .../src/windup_ai_engine/prompt/jump.py | 11 +- .../src/windup_ai_engine/prompt/walk.py | 16 +- .../src/windup_ai_engine/strategy/concrete.py | 13 +- backend/tests/test_ai_engine_skeleton.py | 154 ++++++++++++++++-- 7 files changed, 206 insertions(+), 35 deletions(-) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py index bb844684..fee9a656 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -41,7 +41,9 @@ def generate( # ① 选路线(架构决策矩阵)。装配表里没有 = 该路线未实现,在边界上炸, # 不要让"看着成功、内容是空"的结果流到 server 去落库。 route = ROUTE_MATRIX[action.action] - progress.step("route", 0, 3, f"{action.action} → {route.value}") + # .value 而不是枚举本身:Python 3.11+ 的 str-mixin 枚举 __format__ 会给出 + # "ActionType.WALK",这串字最终是用户看到的进度文案(3.12.13 实测)。 + progress.step("route", 0, 3, f"{action.action.value} → {route.value}") strategy = self._by_route.get(route) if strategy is None: raise NotImplementedError( @@ -52,6 +54,20 @@ def generate( # ② 生成帧(交给 strategy —— 串联) frames = strategy.derive(card, action, master, progress) + # ②.5 帧数必须与契约相符。A2 把 n_frames 从 len(poses) 的推导值改成调用方直接声明的 + # 承诺,而抽帧那两个函数都会**静默少给**:slicing.pick_cycle / pick_oneshot 在 + # `len(dense) <= n`(或动作区间比 n 短)时 return frames/span,长度不足且不报错 + # (2026-08-08 读码复核)。少给的后果不是崩溃而是"短一截的动作":时长表由 + # frame_durations(…, len(frames)) 现算,长度自洽,server 看不出异常,用户拿到 + # 一段步子没走完的循环。故在此对账 —— 钱已经花了,但至少不让错产物流下去。 + # 放在 generator 而不是某个 strategy 里:这样将来任何新路线都受同一条约束。 + if len(frames) != action.n_frames: + raise ValueError( + f"{route.value} 要 {action.n_frames} 帧,实际产出 {len(frames)} 帧。" + "抽帧源帧数不足(i2v 视频太短 / 动作区间过窄)时会静默少给," + "请调小 n_frames 或加长视频。" + ) + # ③ 最后一公里:脚线对齐成原地序列帧 frames = self._lastmile(frames, progress) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py index efe91c64..1461cbbb 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py @@ -44,9 +44,16 @@ class CharacterGeneratorPort(Protocol): 不关心租户 / 配额 / 任务状态 / 存储(那些在 app.server)。 Args: - card: 角色卡(身份 / 画风 / 朝向)。 - action: 动作规格(类型 / 帧数 / 风格化 / 朝向)。 - master: 定妆母版图 bytes(server 从 reference_image_url 取)。 + card: 角色卡。**当前唯一实现的视频路线一个字段都不读**——``git grep 'card\\.'`` + 在 ai_engine 下零命中(2026-08-08 复核)。这不是遗漏:i2v 的角色身份完全由 + ``master`` 这张母版图像承载,身份描述再写一遍反而会和母版打架。本参数是给 + 未实现路线预留的入参:逐帧图生图(#53)要靠 ``name`` / ``desc`` 在每帧提示词里 + 锁一致性,渲染出帧(#81 #122)要靠 ``master_ref`` / ``version`` 定位 3D 资产。 + **调用方不要指望改 card 能影响视频路线的产出。** + action: 动作规格(类型 / 帧数 / 风格化 / 朝向)。视频路线的实际入参在这里: + ``action``、``n_frames``、``facing``、``stylize`` 等。 + master: 定妆母版图 bytes(server 从 reference_image_url 取)。**视频路线的 + 角色一致性靠它,不靠 card。** progress: 进度回调。 """ diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py index 7a6538c2..07116470 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py @@ -11,6 +11,8 @@ from __future__ import annotations +from windup_common.models import Facing + __all__ = ["build_idle_prompt", "build_attack_prompt"] _IDLE_SIDE = ( @@ -56,10 +58,12 @@ DEFAULT_GARMENT = "the cape" -def _build(side: str, front: str, weapon: str, garment: str, feet: str, facing: str) -> str: - if facing not in ("side", "front"): - raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") - body = (side if facing == "side" else front).format(weapon=weapon, garment=garment) +def _build( + side: str, front: str, weapon: str, garment: str, feet: str, facing: Facing | str +) -> str: + # 非法值在此炸掉,别静默落到 FRONT 模板(理由见 walk.py 同处注释)。 + tpl = side if Facing(facing) is Facing.SIDE else front + body = tpl.format(weapon=weapon, garment=garment) return body.replace("boot", feet) if feet != "boot" else body @@ -67,7 +71,7 @@ def build_idle_prompt( weapon: str = DEFAULT_WEAPON, garment: str = DEFAULT_GARMENT, feet: str = "boot", - facing: str = "side", + facing: Facing | str = Facing.SIDE, ) -> str: """待机正文(循环类)。``facing`` 须与母版朝向一致。""" return _build(_IDLE_SIDE, _IDLE_FRONT, weapon, garment, feet, facing) @@ -77,7 +81,7 @@ def build_attack_prompt( weapon: str = DEFAULT_WEAPON, garment: str = DEFAULT_GARMENT, feet: str = "boot", - facing: str = "side", + facing: Facing | str = Facing.SIDE, ) -> str: """攻击正文(一次性类)。``facing`` 须与母版朝向一致。""" return _build(_ATTACK_SIDE, _ATTACK_FRONT, weapon, garment, feet, facing) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py index dac08595..b60cdd2a 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py @@ -14,6 +14,8 @@ from __future__ import annotations +from windup_common.models import Facing + __all__ = ["JUMP_BODY_SIDE", "JUMP_BODY_FRONT", "JUMP_PHASES", "build_jump_prompt"] # 跳跃的五个状态(引擎侧按这个切段;顺序即时间顺序)。 @@ -45,18 +47,17 @@ def build_jump_prompt( - garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: str = "side" + garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: Facing | str = Facing.SIDE ) -> str: """按角色装备 + 母版朝向生成跳跃正文。 Args: garment: 起跳时上飘的衣饰。 feet: 落脚部件用词(替换 boot)。 - facing: "side" 或 "front",**必须与母版朝向一致**。 + facing: :class:`Facing` 成员(或其等价字符串),**必须与母版朝向一致**。 """ - if facing not in ("side", "front"): - raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") - template = JUMP_BODY_SIDE if facing == "side" else JUMP_BODY_FRONT + facing = Facing(facing) # 非法值在此炸掉,别静默落到 FRONT 模板(理由见 walk.py 同处注释) + template = JUMP_BODY_SIDE if facing is Facing.SIDE else JUMP_BODY_FRONT body = template.format(garment=garment) if feet != "boot": body = body.replace("boot", feet) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py index 5d3c4a6e..3597e77c 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py @@ -11,6 +11,8 @@ from __future__ import annotations +from windup_common.models import Facing + __all__ = ["WALK_BODY_SIDE", "WALK_BODY_FRONT", "DEFAULT_GARMENT", "build_walk_prompt"] # 侧走(横版):整体向右推进 + 锁侧视。 @@ -38,19 +40,21 @@ def build_walk_prompt( - garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: str = "side" + garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: Facing | str = Facing.SIDE ) -> str: """按角色装备 + 母版朝向生成走路正文。 Args: garment: 随步伐摆动的衣饰(如 "the cape and tabard" / "the red scarf and tabard")。 feet: 落脚部件用词(如 "boot" / "bare bony foot"),替换机制句里的 boot。 - facing: "side"(横版侧走,母版朝侧向)或 "front"(俯视/2.5D,母版朝观者)。 - **必须与母版朝向一致**,否则模型会靠转身调和矛盾。 + facing: :class:`Facing` 成员(或其等价字符串)。**必须与母版朝向一致**, + 否则模型会靠转身调和矛盾。 """ - if facing not in ("side", "front"): - raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") - template = WALK_BODY_SIDE if facing == "side" else WALK_BODY_FRONT + # 注解不是运行期约束:build_* 是普通函数,传 "sidee" 仍进得来。这里显式过一遍 + # Facing() 构造,非法值抛 ValueError —— 若改成 `if facing == Facing.SIDE else FRONT` + # 的二分,"sidee" 会静默落到 FRONT 模板,拿到一段正面走的视频却没有任何报错。 + facing = Facing(facing) + template = WALK_BODY_SIDE if facing is Facing.SIDE else WALK_BODY_FRONT body = template.format(garment=garment) if feet != "boot": body = body.replace("boot", feet) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py index 8da638a8..4a781e52 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py @@ -12,7 +12,7 @@ import numpy as np -from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute +from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute, Stylize from windup_framework.providers import ImageProvider, MatteProvider, VideoProvider from windup_ai_engine._imgio import from_png as _img @@ -68,8 +68,13 @@ def derive( master: bytes, progress: ProgressPort, ) -> list[bytes]: - n = action.n_frames or 8 - progress.step("derive", 0, 3, f"{action.action}: i2v 生成视频") + # 帧数直接读契约字段:缺省值已收进 ActionSpec(DEFAULT_N_FRAMES),不再由本层 + # 用 `or 8` 兜底 —— 那等于把契约的缺省值写在实现里,换条 strategy 就换个默认值。 + n = action.n_frames + # 进度文案里的枚举一律取 .value:Python 3.11+ 改了 str-mixin 枚举的 __format__, + # f"{action.action}" 现在给的是 "ActionType.WALK" 而不是 "walk"(3.12.13 实测), + # 而这串字会经 server 变成用户看到的 SSE 进度。 + progress.step("derive", 0, 3, f"{action.action.value}: i2v 生成视频") # 母版按动作预处理:jump 要在顶部补空间,否则角色腾空时头顶顶出视频画面被裁 framed = prepare_master(master, action.action.value) video = self._video.i2v(framed, self._build_prompt(action), seconds=5) @@ -94,7 +99,7 @@ def derive( # 风格化按需(见 ActionSpec.stylize):none=保留 i2v 画风(插画/伪 3D 角色); # pixel=像素化。原生像素角色**按母版规格**做:吸附母版像素网格 + 锁母版色板, # 顺带消掉首帧 JPG / H.264 在硬边留下的灰颗粒(实测:通用降采样+量化反而更糊)。 - if action.stylize == "none": + if action.stylize is Stylize.NONE: progress.step("derive", 2, 3, "保留 i2v 画风(不像素化)") return [_png(im) for im in cut] diff --git a/backend/tests/test_ai_engine_skeleton.py b/backend/tests/test_ai_engine_skeleton.py index 1978d0ba..c0484957 100644 --- a/backend/tests/test_ai_engine_skeleton.py +++ b/backend/tests/test_ai_engine_skeleton.py @@ -20,7 +20,10 @@ ActionSpec, ActionType, CharacterCard, + Facing, GenRoute, + LoopMode, + Stylize, ) @@ -65,7 +68,8 @@ def test_route_matrix_is_the_measured_contract(): def test_generate_walk_is_wired_end_to_end(): card = CharacterCard(name="rogue", desc="hooded ranger, dual daggers") - action = ActionSpec(action=ActionType.WALK, poses=["p"] * 8) + # 视频路线只需声明帧数;poses 是逐帧路线的入参,这里不传(以前必须编 8 条假描述)。 + action = ActionSpec(action=ActionType.WALK, n_frames=8) out = _make_generator().generate(card, action, master=_tiny_png(), progress=_NullProgress()) assert isinstance(out, GeneratedAction) assert len(out.frames) == 8 # 选路线→derive→对齐 全串通 @@ -76,16 +80,13 @@ def test_generate_walk_is_wired_end_to_end(): def test_action_spec_stylize_defaults_and_toggle(): # 像素化是开关(默认 pixel),可关成 none 保留 i2v 画风 - assert ActionSpec(action=ActionType.WALK).stylize == "pixel" + assert ActionSpec(action=ActionType.WALK).stylize is Stylize.PIXEL a = ActionSpec(action=ActionType.WALK, stylize="none") - assert a.stylize == "none" + assert a.stylize is Stylize.NONE -def test_video_strategy_derive_runs_offline(monkeypatch): - """真实 VideoFrameStrategy.derive 离线跑通(抽帧被顶替,不解码 mp4 / 不联网)。 - - 证明 derive 的真实链路:i2v → 抽帧 → 抠图 → 选帧 → 出帧,产物是合法 RGBA PNG。 - """ +def _offline_video_strategy(monkeypatch, video=None) -> VideoFrameStrategy: + """离线版 VideoFrameStrategy:抽帧被顶替,不解码 mp4 / 不联网 / 不花钱。""" dense = [Image.open(io.BytesIO(_tiny_png(shift=i % 6))).convert("RGBA") for i in range(24)] monkeypatch.setattr( "windup_ai_engine.strategy.concrete.extract_all_frames_bytes", @@ -100,13 +101,78 @@ class _StubMatte: def cutout(self, frame): # 透传:合成帧已带 alpha return frame - strat = VideoFrameStrategy(_StubVideo(), _StubMatte()) + return VideoFrameStrategy(video or _StubVideo(), _StubMatte()) + + +def test_video_strategy_derive_runs_offline(monkeypatch): + """真实 VideoFrameStrategy.derive 离线跑通。 + + 证明 derive 的真实链路:i2v → 抽帧 → 抠图 → 选帧 → 出帧,产物是合法 RGBA PNG。 + """ + strat = _offline_video_strategy(monkeypatch) card = CharacterCard(name="knight", desc="plate armor, sword") - action = ActionSpec(action=ActionType.WALK, stylize="none", poses=["p"] * 8) + action = ActionSpec(action=ActionType.WALK, stylize="none", n_frames=8) out = strat.derive(card, action, master=_tiny_png(), progress=_NullProgress()) assert out and all(f[:8] == b"\x89PNG\r\n\x1a\n" for f in out) +def test_video_strategy_honours_n_frames_without_any_poses(monkeypatch): + """帧数由 ActionSpec.n_frames 决定,**不必传 poses** —— A2 的落地验证。 + + 以前只能靠 len(poses) 表达帧数,于是"要 6 帧"得先编 6 条视频路线根本不读的姿势描述; + 读代码的人会以为那 6 条描述真的进了提示词。 + """ + strat = _offline_video_strategy(monkeypatch) + card = CharacterCard(name="knight", desc="plate armor, sword") + for n in (4, 6, 11): + action = ActionSpec(action=ActionType.WALK, stylize="none", n_frames=n) + out = strat.derive(card, action, master=_tiny_png(), progress=_NullProgress()) + assert len(out) == n, f"要 {n} 帧,实得 {len(out)} 帧" + + +def test_video_strategy_stylize_switch_actually_changes_the_pixels(monkeypatch): + """stylize 分支不能接反 —— 只验"两条分支都不抛错"验不出接反。 + + none=原样出帧(与输入同尺寸);pixel=裁包围盒 + 重采样到目标像素高,尺寸必然不同。 + """ + strat = _offline_video_strategy(monkeypatch) + card = CharacterCard(name="knight", desc="plate armor, sword") + plain = strat.derive( + card, ActionSpec(action=ActionType.WALK, stylize=Stylize.NONE, n_frames=4), + master=_tiny_png(), progress=_NullProgress(), + ) + pixel = strat.derive( + card, ActionSpec(action=ActionType.WALK, stylize=Stylize.PIXEL, n_frames=4), + master=_tiny_png(), progress=_NullProgress(), + ) + assert Image.open(io.BytesIO(plain[0])).size == (64, 96) # 未像素化:原尺寸 + assert Image.open(io.BytesIO(pixel[0])).size != (64, 96) # 像素化:重采样过 + + +def test_video_strategy_prompt_follows_facing(monkeypatch): + """喂给 i2v 的提示词随 ActionSpec.facing 走 —— 朝向约束真的传到了付费调用那一层。 + + 这是 facing 枚举化要保护的东西:枚举保证值合法,本测保证合法值被用对。 + """ + seen: list[str] = [] + + class _SpyVideo: + def i2v(self, first_frame, prompt, seconds=5, size="1280x720"): + seen.append(prompt) + return b"fake-mp4" + + strat = _offline_video_strategy(monkeypatch, video=_SpyVideo()) + card = CharacterCard(name="knight", desc="plate armor, sword") + for facing in (Facing.SIDE, Facing.FRONT): + strat.derive( + card, + ActionSpec(action=ActionType.WALK, stylize=Stylize.NONE, n_frames=4, facing=facing), + master=_tiny_png(), progress=_NullProgress(), + ) + assert "SIDE VIEW facing right" in seen[0] + assert "FACING THE VIEWER" in seen[1] + + def test_real_video_strategy_is_registered_for_video_route(): # 真实 VideoFrameStrategy 可构造且声明视频路线(derive 联网,不在此跑) class _V: @@ -173,6 +239,74 @@ def derive(self, card, action, master, progress) -> list[bytes]: gen.generate(card, action, _tiny_png(), _NullProgress()) +def test_short_frame_count_from_strategy_is_rejected(): + """产出帧数少于 ``n_frames`` 时要炸 —— 少给不会崩,只会"短一截"。 + + 这不是假想:slicing.pick_cycle / pick_oneshot 在源帧不足(i2v 视频太短 / 动作区间 + 过窄)时 ``return frames`` / ``return span``,长度不足且不报错。时长表由 + frame_durations(…, len(frames)) 现算,所以产物内部自洽 —— server 看不出异常, + 用户拿到一段步子没走完的循环。A2 之后 n_frames 是调用方的明确承诺,必须对账。 + """ + import pytest + + class _ShortStrategy(DerivationStrategy): + route = GenRoute.VIDEO_I2V + + def derive(self, card, action, master, progress) -> list[bytes]: + return [_tiny_png() for _ in range(action.n_frames - 1)] # 少给一帧 + + gen = CharacterGenerator({GenRoute.VIDEO_I2V: _ShortStrategy()}) + card = CharacterCard(name="t", desc="t") + with pytest.raises(ValueError, match="要 8 帧,实际产出 7 帧"): + gen.generate( + card, ActionSpec(action=ActionType.WALK, n_frames=8), + _tiny_png(), _NullProgress(), + ) + + +def test_progress_notes_carry_enum_values_not_python_reprs(): + """进度文案里不能出现 "ActionType.WALK"。 + + Python 3.11 改了 str-mixin 枚举的 __format__:f"{ActionType.WALK}" 从 "walk" 变成 + "ActionType.WALK"(3.12.13 实测)。这串字经 server 变成用户看到的 SSE 进度文案, + 没有任何测试会因此变红 —— 属于"跑得通但对外是错的"那一类。 + """ + notes: list[str] = [] + + class _SpyProgress: + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + notes.append(note) + + _make_generator().generate( + CharacterCard(name="t", desc="t"), + ActionSpec(action=ActionType.WALK, n_frames=4), + _tiny_png(), _SpyProgress(), + ) + assert notes, "没收到任何进度上报" + assert not any("ActionType." in n for n in notes), notes + assert any("walk" in n for n in notes), notes + + +def test_loop_mode_currently_changes_nothing(monkeypatch): + """现状锁定:``ActionSpec.loop`` 今天零消费方,改它不会改变任何一帧。 + + 闭环行为写死在 slicing.pick_cycle(循环类动作一律抽单周期)。本测把这条"设了不生效" + 钉成可执行事实——将来真把 pingpong 接进抽帧时它会变红,提醒同步删掉 character.py + 里那段"别指望它生效"的注释。 + """ + strat = _offline_video_strategy(monkeypatch) + card = CharacterCard(name="knight", desc="plate armor, sword") + + def _run(loop): + return strat.derive( + card, + ActionSpec(action=ActionType.WALK, stylize=Stylize.NONE, n_frames=4, loop=loop), + master=_tiny_png(), progress=_NullProgress(), + ) + + assert _run(LoopMode.PINGPONG) == _run(LoopMode.LINEAR) == _run(LoopMode.NONE) + + def test_genroute_only_lists_implemented_routes(): """GenRoute 只列有实现的路线 —— 没有实现的枚举值等于死代码。 From 68d618bb1976497fcd28d2d5d6bb797d1ae86cf1 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 14:10:40 +0800 Subject: [PATCH 24/33] =?UTF-8?q?feat(ai=5Fengine):=20=E6=AF=8D=E7=89=88?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E9=A2=84=E6=A3=80=20+=20=E5=87=BA=E5=8F=82?= =?UTF-8?q?=E6=88=90=E8=89=B2=E4=BF=A1=E5=8F=B7=20+=20=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E7=BC=96=E7=A0=81=E5=99=A8=E8=BE=B9=E7=BC=98?= =?UTF-8?q?=E4=BC=AA=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两头各加一道闸,方向相反:进门那道在**花钱之前**挡住不可能生成好的输入; 出门那道在钱已花完之后,让上层看得出"这次生成得怎么样"。 此前 ports 与 impl 里所有 raise 都在输出侧,对 master 不做任何前置判定。 2026-08-07 实测:喂一张"人物在画板前作画"的图请求 walk,全程无一处报错, 16 帧构图完整的错角色出完、钱花完。 check_master 判三类**本地零成本可判**的形态问题,不通过抛 MasterRejected: - UNDECODABLE 不是图 / 截断 - NO_SUBJECT 全透明或全同色,没有可动的东西 - SUBJECT_TOO_SMALL 包围盒最短边 < 8px(放大 20 倍是色块不是角色), 或主体占比 < 0.1%(对角散落两粒噪点会把包围盒撑到整幅,边长检查全过) - ASPECT_TOO_WIDE 主体 w/h 超阈值,方形画布只能把角色硬缩成一条 REJECT_ASPECT 由交付画布几何推出(2*FILL_W/FILL_H)而非拍脑袋,并有测试锁住 这个推导关系——改了 pack.py 的填充比而这里不动,预检会放行一批下游装不下的母版。 MasterRejected 带机器可读的 code:server 据此选文案、判 4xx-不重试,与 NotImplementedError / 其他 ValueError(引擎侧问题,5xx,要人介入)分工明确。 判不了的(画的是不是角色、朝向对不对)不在此列,模块 docstring 写清"本层不判什么"。 GeneratedAction 此前只能表达"生成完了",不能表达"生成得怎么样":一段每帧都一样的 walk 与一段步态干净的 walk,帧数 / 时长 / fps 完全相同,调用方分辨不出。 三个字段各自不可由其他两个推导: - motion_scale 相邻帧差的**绝对**尺度。必须单独给:dead_frame_mask 两条判据都是 相对的,整段冻结时 d 全为 0、两条不等式变成 0<0,一帧死帧都报不出(实测 12 帧 全同报 0 死帧)——相对判据天生看不见"整体没动"。 - dead_frames 死帧下标(不是 numpy 掩码:跨出 ai_engine 的契约要"哪几帧") - loop_seam 末帧接回首帧的跳幅 ÷ 相邻帧平均步长。在**对齐之后**量,量的是用户真正 看到的那组帧;分母为 0 返回 None 而不是 0.0——0.0 会被读成"完美闭环"。 一次性动作(jump/attack)不给:首尾姿态本就不同,给个必然难看的数会诱导错误决定。 刻意没有糊帧率:2026-08-05 实测 6 段真 i2v 没有一帧糊帧,加进来是恒等于 1 的常数。 引擎只如实报数、不代替上层判决:交付 / 重试 / 换母版是产品决策,阈值该由 server 按 场景定;且到这一步钱已花完,引擎单方面丢弃产物只是把损失变成两份。 底色采样此前贴边取。视频帧最外一两行/列常是**编码器边缘伪影**而非底色:实测 9 段真 i2v × 16 帧 = 144 帧,贴边采样时 26 帧(18%)被判"底不均匀"而跳过清理——底色清理在 真实路径上等于从不生效。逐一查证全部由最外圈造成(某视频最右一列整列纯黑 std 50.4, 待机视频最顶一行 std 8.4 恰好压线越过 8)。往里让 2px 后 144 帧零误跳,三张静态母版的 取样中位色一个字节未变。 17 条新用例。变异测试 6/6 全部被捕获:阈值改成硬编码、去掉占比检查、去掉最短边检查、 motion_scale 恒返回 1、loop_seam 分母为 0 时返回 0.0、贴边采样。 其中"去掉最短边检查"最初**没被杀**——样本用的小方块占比也不达标,占比那条接住了它。 换成细长条(占比 1.3% 远超下限,只有边长这条能拦)后才真正独立。写完就绿的测试等于没写。 --- .../src/windup_ai_engine/_subject.py | 68 +++++++ .../impl/character_generator.py | 93 ++++++--- .../src/windup_ai_engine/master_check.py | 139 ++++++++++++++ .../src/windup_ai_engine/master_prep.py | 10 +- .../src/windup_ai_engine/ports/__init__.py | 99 +++++++++- .../src/windup_ai_engine/postprocess/pack.py | 19 +- .../src/windup_ai_engine/slicing/__init__.py | 9 + .../src/windup_ai_engine/slicing/loop.py | 15 +- .../src/windup_ai_engine/slicing/oneshot.py | 17 +- .../src/windup_ai_engine/slicing/quality.py | 46 ++++- .../src/windup_ai_engine/strategy/__init__.py | 3 +- .../src/windup_ai_engine/strategy/base.py | 12 +- .../src/windup_ai_engine/strategy/concrete.py | 9 +- .../src/windup_framework/providers/matte.py | 38 +++- .../tests/test_master_check_and_quality.py | 177 ++++++++++++++++++ backend/tests/test_matte_provider.py | 40 ++++ 16 files changed, 726 insertions(+), 68 deletions(-) create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/_subject.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/master_check.py create mode 100644 backend/tests/test_master_check_and_quality.py diff --git a/backend/packages/ai_engine/src/windup_ai_engine/_subject.py b/backend/packages/ai_engine/src/windup_ai_engine/_subject.py new file mode 100644 index 00000000..bf83ab29 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/_subject.py @@ -0,0 +1,68 @@ +"""「哪些像素是主体」的唯一定义(母版预检 / 脚线 / 补边背景色共用)。 + +此前这套判据有两份:``master_prep._bg_color`` 取四角中位色补边, +``slicing.oneshot._subject_rows`` 用同一套四角中位色 + 容差找脚线。入口预检 +(:mod:`.master_check`)必须与下游用**同一个**主体定义 —— 判据一旦分叉就会出现 +"预检说有主体、下游找不到主体"这种只在画面上体现、不报错的分歧,和 +:mod:`._imgio` / :mod:`.slicing._frames` 当初被收拢是同一个理由。 + +判据本身:有真 alpha(存在低于阈值的像素)就用 alpha;整幅不透明(原始视频帧 / +RGB 母版)则按四角中位背景色的差值。**这是颜色启发式,不是抠图模型** —— +背景带渐变、或角色与背景同色时判不准,见 :func:`subject_mask`。 +""" +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = ["bg_color", "subject_bbox", "subject_mask"] + +ALPHA_THR = 128 # alpha 高于此值算不透明(与 postprocess.pack 求包围盒的口径一致) +BG_TOL = 60 # 与背景色的 RGB 绝对差之和,超过才算主体 + + +def _bg_median(rgb: np.ndarray) -> np.ndarray: + """四角中位色(float)。母版 / 视频帧通常是纯色底,四角取中位比取均值抗单角污染。""" + corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) + return np.median(corners, axis=0) + + +def bg_color(img: Image.Image) -> tuple[int, int, int]: + """背景色(取整),给补边用。""" + rgb = np.asarray(img.convert("RGB")) + return tuple(int(v) for v in _bg_median(rgb)) + + +def subject_mask( + img: Image.Image, alpha_thr: int = ALPHA_THR, bg_tol: int = BG_TOL +) -> np.ndarray: + """主体像素的二维布尔掩码。 + + 必须兼容**不透明**输入:抽帧阶段拿到的是原始视频帧,还没抠图,只看 alpha 会把 + 整幅当主体、脚线恒定,腾空判据立刻误判"已落地"(实测踩过,跳跃被裁在起跳前)。 + + 判不准的已知情形(调用方别当成抠图):背景有渐变 → 整幅都超容差,掩码≈全 True; + 角色主色与背景色接近 → 那部分身体被判成背景。要真分割请走 MatteProvider。 + """ + arr = np.asarray(img.convert("RGBA")) + alpha = arr[:, :, 3] + if not alpha.min() > alpha_thr: # 存在透明像素 = 有真 alpha,直接用 + return alpha > alpha_thr + rgb = arr[:, :, :3].astype(np.int16) + return np.abs(rgb - _bg_median(rgb)).sum(axis=2) > bg_tol + + +def subject_bbox( + img: Image.Image, alpha_thr: int = ALPHA_THR, bg_tol: int = BG_TOL +) -> tuple[tuple[int, int, int, int], int] | None: + """主体包围盒 ``(x0, y0, x1, y1)``(半开,同 PIL crop)+ 主体像素数;无主体返回 None。 + + 包围盒与像素数一起返回:两者判的不是同一件事 —— 包围盒管"主体有多大", + 像素数管"包围盒里是不是真有东西"(散落的几粒噪点能把包围盒撑满整幅)。 + """ + m = subject_mask(img, alpha_thr, bg_tol) + ys, xs = np.where(m) + if not len(ys): + return None + box = (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1) + return box, int(m.sum()) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py index fee9a656..7243fe51 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -1,26 +1,38 @@ """CharacterGenerator —— 装配 strategy + 最后一公里,串起整条生产线(架构串联点)。 这是 CharacterGeneratorPort 的实现;server 经 port 调它、不碰这里。 -串联:选路线(ROUTE_MATRIX)→ strategy.derive 出帧 → 最后一公里(脚线对齐)→ GeneratedAction。 +串联:母版预检(可拒绝)→ 选路线(ROUTE_MATRIX)→ strategy.derive 出帧 → +最后一公里(脚线对齐)→ 量交付成色 → GeneratedAction。 + +两头各有一道闸,方向相反:进门那道(master_check)在**花钱之前**挡住不可能生成好的 +输入;出门那几道(空帧 / 帧数 / 成色)在钱已经花完之后,挡住"看起来成功的错产物"。 MVP 边界(与作者对齐):**只出帧 bytes + 逐帧时长**,不打包 sprite sheet、不落存储—— 上传对象存储、写 character_data、拼图集/多格式导出由 server / export 侧做(#22)。 """ from __future__ import annotations - +import numpy as np +from PIL import Image from windup_common.models import ActionSpec, CharacterCard, GenRoute from windup_ai_engine._imgio import from_png as _img from windup_ai_engine._imgio import to_png as _png +from windup_ai_engine.master_check import check_master from windup_ai_engine.ports import ( + ActionQuality, CharacterGeneratorPort, GeneratedAction, ProgressPort, ) from windup_ai_engine.postprocess import align_bottom_center, frame_durations -from windup_ai_engine.strategy.base import ROUTE_MATRIX, DerivationStrategy +from windup_ai_engine.slicing import dead_frame_indices, loop_seam, motion_scale +from windup_ai_engine.strategy.base import ( + CYCLIC_ACTIONS, + ROUTE_MATRIX, + DerivationStrategy, +) @@ -38,12 +50,19 @@ def generate( master: bytes, progress: ProgressPort, ) -> GeneratedAction: - # ① 选路线(架构决策矩阵)。装配表里没有 = 该路线未实现,在边界上炸, + # ① 入口预检 —— 唯一一道在**花钱之前**的闸,故排在选路线之前。 + # 之前这里什么都不判:一张"人物在画板前作画"的图请求 walk,全程无一处报错, + # 16 帧构图完整的错角色出完、钱花完(2026-08-07 实测)。预检拦不住"内容画错" + # (那要视觉模型),但坏图 / 空图 / 极端比例这几类不必等到出帧才发现。 + facts = check_master(master) + progress.step("precheck", 0, 4, facts.note()) + + # ② 选路线(架构决策矩阵)。装配表里没有 = 该路线未实现,在边界上炸, # 不要让"看着成功、内容是空"的结果流到 server 去落库。 route = ROUTE_MATRIX[action.action] # .value 而不是枚举本身:Python 3.11+ 的 str-mixin 枚举 __format__ 会给出 # "ActionType.WALK",这串字最终是用户看到的进度文案(3.12.13 实测)。 - progress.step("route", 0, 3, f"{action.action.value} → {route.value}") + progress.step("route", 1, 4, f"{action.action.value} → {route.value}") strategy = self._by_route.get(route) if strategy is None: raise NotImplementedError( @@ -51,10 +70,10 @@ def generate( f"已装配:{sorted(r.value for r in self._by_route)}。" ) - # ② 生成帧(交给 strategy —— 串联) + # ③ 生成帧(交给 strategy —— 串联) frames = strategy.derive(card, action, master, progress) - # ②.5 帧数必须与契约相符。A2 把 n_frames 从 len(poses) 的推导值改成调用方直接声明的 + # ③.5 帧数必须与契约相符。A2 把 n_frames 从 len(poses) 的推导值改成调用方直接声明的 # 承诺,而抽帧那两个函数都会**静默少给**:slicing.pick_cycle / pick_oneshot 在 # `len(dense) <= n`(或动作区间比 n 短)时 return frames/span,长度不足且不报错 # (2026-08-08 读码复核)。少给的后果不是崩溃而是"短一截的动作":时长表由 @@ -68,24 +87,54 @@ def generate( "请调小 n_frames 或加长视频。" ) - # ③ 最后一公里:脚线对齐成原地序列帧 - frames = self._lastmile(frames, progress) + # ④ 最后一公里:脚线对齐成原地序列帧 + aligned = self._lastmile(frames, progress) - # ④ 出参:帧 + 逐帧时长(上传 / 落库在 server 侧) - progress.step("package", 2, 3, f"{len(frames)} 帧 + 逐帧时长") + # ⑤ 量交付成色。在**对齐之后**量,量的是用户真正会看到的那组帧:抠图 / 像素化 / + # 对齐都会改像素,在中间任何一步量出来的数都描述不了交付物。 + quality = self._assess(aligned, action) + + # ⑥ 出参:帧 + 逐帧时长 + 成色(上传 / 落库在 server 侧) + progress.step( + "package", 3, 4, + f"{len(aligned)} 帧 + 逐帧时长(动量 {quality.motion_scale:.2f}," + f"死帧 {len(quality.dead_frames)}/{len(aligned)})", + ) return GeneratedAction( - frames=frames, - durations=frame_durations(action.action.value, len(frames)), + frames=[_png(im) for im in aligned], + durations=frame_durations(action.action.value, len(aligned)), fps=action.fps, + quality=quality, ) - def _lastmile(self, frames: list[bytes], progress: ProgressPort) -> list[bytes]: + def _assess(self, frames: list[Image.Image], action: ActionSpec) -> ActionQuality: + """量交付帧的成色。这些数只上报、**不改动产物**,也不在此处代替调用方做判决。 + + 为什么不在这里直接对着阈值抛错:交付 / 重试 / 让用户换母版是产品决策,阈值该由 + server 按场景定;而且到这一步钱已经花完,引擎单方面丢弃产物只是把损失变成两份。 + 引擎负责"如实报数",不负责"替上层决定这次算不算数"。 + + ``loop_seam`` 只对循环类动作量:一次性动作(jump/attack)首尾姿态本就不同, + 给它算一个"接缝"再交出去,等于发一个必然难看的数让上层照着做错误决定。 + """ + return ActionQuality( + motion_scale=motion_scale(frames), + dead_frames=dead_frame_indices(frames), + loop_seam=loop_seam(frames) if action.action in CYCLIC_ACTIONS else None, + ) + + def _lastmile( + self, frames: list[bytes], progress: ProgressPort + ) -> list[Image.Image]: """脚线对齐:把各帧对齐成原地序列帧(消除逐帧画布漂移,Issue #21)。 + 返回 PIL 而不是 PNG bytes:紧接着的成色测量要按图看帧,再编码回 PNG 只为了 + 让上一句话好听、下一句话又得解码回来。编码统一在 ``generate`` 出参那一步做。 + 位移轨道(root_motion)MVP 先不做(见 #63 / character_data.frames 暂无该字段): 序列帧保持原地即可,位移留给后续 export / playtest 阶段再算。 """ - progress.step("lastmile", 1, 3, "脚线对齐(原地)") + progress.step("lastmile", 2, 4, "脚线对齐(原地)") # 空帧不再静默跳过:未实现的路线现在在 strategy / 装配表处就抛错(见 generate), # 走到这里还有空帧说明 provider 或抠图吐了坏数据,同样要炸而不是原样放行。 if not frames: @@ -96,12 +145,10 @@ def _lastmile(self, frames: list[bytes], progress: ProgressPort) -> list[bytes]: imgs = [_img(f) for f in frames] # 参考姿态高 = 各帧包围盒高的中位数:比"最高帧"稳(不被举过头顶的武器带偏), # 各动作都以自身中位姿态定标,本体尺寸跨动作一致。 - import numpy as _np - _hs = [] - for _im in imgs: - _ys, _ = _np.where(_np.asarray(_im)[:, :, 3] > 128) - if len(_ys): - _hs.append(float(_ys.max() - _ys.min())) - aligned = align_bottom_center(imgs, ref_height=(float(_np.median(_hs)) if _hs else None)) + hs = [] + for im in imgs: + ys, _ = np.where(np.asarray(im)[:, :, 3] > 128) + if len(ys): + hs.append(float(ys.max() - ys.min())) # TODO(dev, #21): tail_match 循环闭合(净位移动作先锚点再匹配帧) - return [_png(im) for im in aligned] + return align_bottom_center(imgs, ref_height=(float(np.median(hs)) if hs else None)) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_check.py b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py new file mode 100644 index 00000000..f91a4591 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py @@ -0,0 +1,139 @@ +"""母版可生成性预检 —— 入口处**允许拒绝**的那道闸,在花钱之前。 + +为什么有这个模块:ai_engine 此前所有 ``raise`` 都在输出侧,``generate(card, action, +master, progress)`` 对 ``master`` 一个前置判定都没有。2026-08-07 实测:喂一张"人物在 +画板前作画"的图请求 walk,全程无一处报错,最终产出 16 帧构图完整的序列帧,画面是个 +不会走路的错角色 —— 钱已花完才发现。 + +**本层判什么(三条,全部本地零成本、可复现):** + ① 能否解码 —— 坏 bytes / 截断文件不必等 i2v 跑完再发现; + ② 有没有可动的主体 —— 全透明 / 全同色 = 画面里没有东西可动; + ③ 主体宽高比下游装不装得下 —— 见 :data:`REJECT_ASPECT`。 + +**本层不判什么、为什么 —— 别把下面这些当成已经守住了:** + - **画的是不是一个角色、是不是该动作要的姿态**(walk 要侧向、attack 要蓄力,见 + :data:`master_prep.MASTER_POSES`)。需要视觉模型读画面语义,本层只有 numpy。 + **开头那张"人物在画板前作画"的图,本预检拦不住**:它能解码、有主体、比例正常。 + 本层挡的是它的近邻(空图 / 坏图 / 极端比例),挡不住"内容画错"。要真正堵住这个, + 得在预检里接一次廉价的视觉判定(便宜的 VLM 问一句"这是不是一个可行走的角色、 + 朝向是不是侧面"),那是另一件事、要另外的实测与预算。 + - **朝向与 ``ActionSpec.facing`` 是否一致** —— 同上,需要视觉模型。 + - **背景干不干净到能抠图** —— 抠图是 ``MatteProvider``(rembg/u2net)的事;本层的 + 四角中位色启发式判不出"这块背景 rembg 能不能抠掉"。 + - **分辨率下限** —— 故意不判。i2v 供应商对首帧分辨率的真实下限我没有实测数据, + 拍一个阈值就是拿没验证的判据挡掉用户的钱。:data:`MIN_SUBJECT_SIDE` 只挡退化端 + (小到与噪点无从区分),不是画质阈值。 + +纯 PIL / numpy,零 API,不联网。 +""" +from __future__ import annotations + +import io +from dataclasses import dataclass + +from PIL import Image, UnidentifiedImageError + +from windup_ai_engine._subject import subject_bbox +from windup_ai_engine.ports import MasterRejectCode, MasterRejected +from windup_ai_engine.postprocess.pack import FILL_H, FILL_W + +__all__ = ["MIN_SUBJECT_AREA_RATIO", "MIN_SUBJECT_SIDE", "REJECT_ASPECT", + "MasterFacts", "check_master"] + +# 主体宽高比上限。**由交付画布的几何推出,不是拍的**:align_bottom_center 按高定标 +# (cell*FILL_H);主体 w/h 超过 FILL_W/FILL_H(≈1.55)后宽度兜底接管,交付主体高度 +# 退化成 cell*FILL_W/(w/h)。取"退化到目标高度的一半"为界: +# FILL_W / R < FILL_H / 2 ⇒ R > 2*FILL_W/FILL_H ≈ 3.1 +# 再宽就不是"缩小了一点",是把角色压成一条。pack.py 记的实测(2026-08-05):w/h=1.78 +# 的狐狸母版丢 27px、w/h=2.0 只剩 79.9% 内容 —— 那还在兜底能救的区间内(交付变矮), +# 3.1 以上则是"硬缩到没法看"。与其硬缩出一个能落库的错产物,不如在花钱前退回去。 +REJECT_ASPECT = 2 * FILL_W / FILL_H + +# 主体包围盒的最短边下限。下游 align_bottom_center 会把包围盒裁出来、NEAREST 放大到 +# cell*FILL_H≈159px;8px 放大 20 倍是色块不是角色。更要紧的是:这么小的一块,四角 +# 中位色启发式**区分不了它是主体还是一粒压缩噪点/水印**,判"有主体"本身就不成立。 +MIN_SUBJECT_SIDE = 8 + +# 主体像素占画幅的下限。与上一条判的不是同一件事:包围盒管"主体有多大",占比管 +# "包围盒里是不是真有东西" —— 画面对角散落两粒噪点会把包围盒撑到整幅,边长检查全过, +# 占比只有百万分之几。千分之一对真角色是极宽松的下限(侧视角色通常占百分之几以上)。 +MIN_SUBJECT_AREA_RATIO = 0.001 + + +@dataclass(frozen=True) +class MasterFacts: + """预检**量到**的母版形态。返回它而不是只返 None:通过时这些数进进度文案, + 出问题时(比如误拒)一眼看得出引擎当时把什么当成了主体。""" + + size: tuple[int, int] # 母版画布 (w, h) + subject_box: tuple[int, int, int, int] # 主体包围盒 (x0, y0, x1, y1),半开 + subject_ratio: float # 主体 w/h + subject_area_ratio: float # 主体像素 / 画幅像素 + + def note(self) -> str: + """给 ProgressPort 的一行摘要(会经 server 变成用户看到的进度文案)。""" + w, h = self.size + x0, y0, x1, y1 = self.subject_box + return (f"母版 {w}×{h},主体 {x1 - x0}×{y1 - y0}" + f"(w/h {self.subject_ratio:.2f},占幅 {self.subject_area_ratio:.1%})") + + +def _decode(master: bytes) -> Image.Image: + """解码母版;坏 bytes 直接拒。 + + 必须 ``load()`` 强制解完:``Image.open`` 只读文件头,截断的 PNG 在 open 处不报错, + 要到下游某个 ``convert`` / ``np.asarray`` 才炸 —— 那时 i2v 的钱已经花了。 + """ + if not master: + raise MasterRejected(MasterRejectCode.UNDECODABLE, "母版为空 bytes") + try: + img = Image.open(io.BytesIO(master)) + img.load() + return img.convert("RGBA") + except (UnidentifiedImageError, OSError, ValueError) as exc: + raise MasterRejected( + MasterRejectCode.UNDECODABLE, f"解不开这张图({type(exc).__name__}: {exc})" + ) from exc + + +def check_master(master: bytes) -> MasterFacts: + """母版可生成性预检。通过返回量到的形态,不通过抛 :class:`MasterRejected`。 + + 只看母版本身,不看 ``ActionSpec``:三条判据都是"下游画布装不装得下 / 有没有东西可 + 动",与动作类型无关。动作相关的母版要求(侧向 / 蓄力姿态)本层判不了,见模块 docstring。 + """ + img = _decode(master) + w, h = img.size + found = subject_bbox(img) + if found is None: + raise MasterRejected( + MasterRejectCode.NO_SUBJECT, + f"{w}×{h} 的图里找不到主体(全透明或全同色),没有可动的东西", + ) + box, pixels = found + bw, bh = box[2] - box[0], box[3] - box[1] + facts = MasterFacts( + size=(w, h), + subject_box=box, + subject_ratio=bw / bh, + subject_area_ratio=pixels / max(1, w * h), + ) + if min(bw, bh) < MIN_SUBJECT_SIDE: + raise MasterRejected( + MasterRejectCode.SUBJECT_TOO_SMALL, + f"主体包围盒只有 {bw}×{bh}px(下限 {MIN_SUBJECT_SIDE}px)," + "与一粒噪点/水印无从区分", + ) + if facts.subject_area_ratio < MIN_SUBJECT_AREA_RATIO: + raise MasterRejected( + MasterRejectCode.SUBJECT_TOO_SMALL, + f"主体只占画幅 {facts.subject_area_ratio:.4%}" + f"(下限 {MIN_SUBJECT_AREA_RATIO:.1%}),像散落的噪点而不是角色", + ) + if facts.subject_ratio > REJECT_ASPECT: + raise MasterRejected( + MasterRejectCode.ASPECT_TOO_WIDE, + f"主体 w/h={facts.subject_ratio:.2f} 超过 {REJECT_ASPECT:.2f};" + "下游是方形画布,再宽只能把角色硬缩成一条,请换一张主体没这么扁的母版", + ) + return facts diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py index ed2652e3..b4e6d3fe 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py @@ -20,9 +20,10 @@ import io -import numpy as np from PIL import Image +from windup_ai_engine._subject import bg_color as _bg_color + __all__ = ["add_headroom", "prepare_master", "MASTER_POSES"] # 各动作所需的母版姿态(生成专用母版时的姿势描述)。空=可直接用中性站立母版。 @@ -48,13 +49,6 @@ } -def _bg_color(img: Image.Image) -> tuple[int, int, int]: - """取四角中位色当背景色(母版通常是纯色底)。""" - rgb = np.asarray(img.convert("RGB")) - corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) - return tuple(int(v) for v in np.median(corners, axis=0)) - - def add_headroom(master: bytes, ratio: float = 0.6) -> bytes: """在母版上方补空间,让角色坐到画面下部,给腾空留出余量。 diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py index 1461cbbb..44d36355 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py @@ -10,6 +10,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from enum import Enum from typing import Protocol, runtime_checkable from windup_common.models import ActionSpec, CharacterCard @@ -22,10 +23,90 @@ class ProgressPort(Protocol): def step(self, stage: str, i: int, total: int, note: str = "") -> None: ... +# ---- 入口拒绝(在花钱之前)---- +class MasterRejectCode(str, Enum): + """母版被拒的原因 —— server 据此选文案,别用异常消息做分支(消息会改)。 + + 取值全部是**本地零成本可判**的形态问题;判不了的(画的是不是角色、朝向对不对) + 不在此列,见 :mod:`windup_ai_engine.master_check` 的"本层不判什么"。 + """ + + UNDECODABLE = "undecodable" # 不是图 / 截断 / 编码不支持 + NO_SUBJECT = "no_subject" # 全透明或全同色:没有可动的东西 + SUBJECT_TOO_SMALL = "subject_too_small" # 主体小到与噪点/水印无从区分 + ASPECT_TOO_WIDE = "aspect_too_wide" # 主体太扁,方形 cell 里只能压成一条 + + +class MasterRejected(ValueError): + """母版不具备可生成性,在**调用付费模型之前**拒绝。 + + 与 ai_engine 其他异常的分工(这条分工是给 server 用的): + - ``MasterRejected`` = **调用方的输入不行**,同一张母版重试多少次都一样。 + server 应映射成 4xx、把 ``code`` 翻成"请换一张母版"类文案,**不要重试**。 + - ``NotImplementedError`` / 其他 ``ValueError`` = 引擎侧装配或产出出了问题 + (路线没注入、strategy 吐空帧、帧数对不上),属于 5xx、要人介入, + 让用户换母版是把锅甩错地方。 + """ + + def __init__(self, code: MasterRejectCode, detail: str) -> None: + super().__init__(f"母版不可用({code.value}):{detail}") + self.code = code + self.detail = detail + + # ---- ai_engine 出参(不含存储引用:上传 / 落库在 server 侧)---- +@dataclass(frozen=True) +class ActionQuality: + """这一次出帧的成色 —— 让上层能判"交付 / 重试 / 让用户换母版"。 + + 没有这个,``GeneratedAction`` 只能表达"生成完了",不能表达"生成得怎么样": + 一段**每帧都一样**的 walk 和一段步态干净的 walk,帧数、时长、fps 完全相同, + 调用方分辨不出 —— 本仓吃过四次的正是这类"看起来成功的错结果"。 + + 三个字段各自不可由其他两个推导(下面逐条说明必要性)。刻意**没有**的字段: + - 糊帧率(``slicing.quality.blur_ratio``):2026-08-05 实测 6 段真 i2v + **没有一帧糊帧**,加进来是个恒等于 1 的常数,上层拿它做不了任何决定。 + 真出现糊帧再加,那时才有阈值可依。 + - 抽帧降级原因(``slicing.pick_cycle`` 的三条退化路径):见该函数 docstring 里 + 记的缺口。降级**对交付物的后果**由 ``loop_seam`` 直接测得,而"降级的原因" + 今天没有任何调用方会据此改变行为,故不塞进出参。 + """ + + motion_scale: float + """交付帧的相邻帧平均差异(48×48 灰度绝对尺度)。**0.0 = N 张同一张图。** + + 上层拿它做的决定:接近 0 → 这不是动画,**不要交付**(退款 / 重试 / 提示母版 + 姿态不适合该动作)。它与 ``dead_frames`` 不重复而是互补 —— ``dead_frames`` + 的两条判据都是相对的(比邻居、比自身 p75),整段完全冻结时全部不成立、 + 一帧死帧都报不出(见 ``slicing.quality.motion_scale`` 的实测说明)。 + """ + + dead_frames: tuple[int, ...] + """与前一帧几乎无变化的帧下标(下标 0 不参与判定:它没有前一帧)。 + + 上层拿它做的决定:``len(dead_frames)/len(frames)`` 偏高 → 用户花 N 帧的钱只拿到 + N-K 个不同姿态,提示重试或换母版。给**下标**而不是个数,是因为分布形态对应两种 + 不同的病、修法不同:连续一段 = 动作停住(母版姿态不对 / 视频后半段衰减), + 隔帧散布 = 有效帧率减半(i2v 复制帧),前者换母版、后者调抽帧密度。 + """ + + loop_seam: float | None + """末帧接回首帧的跳幅 ÷ 相邻帧平均步长。1.0 ≈ 接缝与一个正常帧间步长同量级。 + + 上层拿它做的决定:循环类动作(idle/walk/run)会被引擎反复播放,接缝大就是肉眼 + 可见的"跳一下";超过约 1.2 → 提示重试。取归一化值而不是原始差,是为了让不同 + 动作幅度之间可比。 + + ``None`` = **这个数在本次生成里不可读**,两种情形:一次性动作(jump/attack/hit) + 本就不闭环;或 ``motion_scale`` 为 0(整段静止,连"一个正常步长"都没有,归一化 + 无从谈起)。调用方要区分就看 ``motion_scale``,**不要把 None 当 0.0** —— + 0.0 会被读成"完美闭环",正是本仓忌讳的"貌似合理的默认值"。 + """ + + @dataclass class GeneratedAction: - """一个动作的生成产物:对齐后的原地序列帧 + 逐帧时长。 + """一个动作的生成产物:对齐后的原地序列帧 + 逐帧时长 + 成色。 frames / durations **等长**;server 侧把每帧上传对象存储得 URL,组成 ``CharacterActionOutput.frames[{index, image_url, duration_ms}]`` 回填 character_data。 @@ -34,6 +115,10 @@ class GeneratedAction: frames: list[bytes] = field(default_factory=list) # RGBA PNG,按播放序 durations: list[int] = field(default_factory=list) # 逐帧时长(ms),与 frames 等长 fps: int = 10 + # 无默认值、且 kw_only 让它能排在有默认值的字段之后:**不给"没测"留缺省**。 + # 给个 None 缺省的话,漏测与"测出来没问题"在调用方看来一模一样,而这个出参的 + # 全部意义就是把这两者分开。 + quality: ActionQuality = field(kw_only=True) # ---- ai_engine 暴露给 server(server 调用的唯一入口)---- @@ -53,8 +138,18 @@ class CharacterGeneratorPort(Protocol): action: 动作规格(类型 / 帧数 / 风格化 / 朝向)。视频路线的实际入参在这里: ``action``、``n_frames``、``facing``、``stylize`` 等。 master: 定妆母版图 bytes(server 从 reference_image_url 取)。**视频路线的 - 角色一致性靠它,不靠 card。** + 角色一致性靠它,不靠 card。** 进付费模型之前会先过一遍可生成性预检, + 见 Raises。 progress: 进度回调。 + + Raises: + MasterRejected: 母版形态不可生成(见 :class:`MasterRejectCode`)。**在花钱 + 之前抛**,同一张母版重试无意义 → server 映射 4xx、请用户换母版。 + NotImplementedError: 该动作分流到的路线没有实现或没注入 strategy。 + ValueError: 产出对不上契约(空帧 / 帧数不足)。钱已经花了,但错产物不放行。 + + 出参的 ``GeneratedAction.quality`` 是**必填**的成色读数:帧数对、无异常并不 + 等于产物可用,调用方交付前应据它决定交付 / 重试 / 让用户换母版。 """ def generate( diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py index 53d2565b..2b6207b3 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py @@ -8,15 +8,24 @@ from PIL import Image -__all__ = ["align_bottom_center", "sprite_sheet", "save_gif"] +__all__ = ["CELL", "FILL_H", "FILL_W", "FOOT_LINE", "align_bottom_center", + "sprite_sheet", "save_gif"] + +# 交付画布的几何 —— 提成模块常量而不是只当默认参数,是因为**入口预检要按同一套几何 +# 判母版能不能装下**(见 master_check.REJECT_ASPECT)。抄一份数字过去就等于埋下 +# "改了这里、那边阈值不动"的静默分歧。 +CELL = 256 # 方形 cell 边长(交付序列帧的画布) +FOOT_LINE = 0.92 # 脚线在画布中的高度比例 +FILL_H = 0.62 # 参考姿态占画布高的比例(留余量给举过头顶的动作) +FILL_W = 0.96 # 主体占画布宽的上限(宽度兜底的天花板) def align_bottom_center( frames: list[Image.Image], - cell: int = 256, - foot_line: float = 0.92, - fill_h: float = 0.62, - fill_w: float = 0.96, + cell: int = CELL, + foot_line: float = FOOT_LINE, + fill_h: float = FILL_H, + fill_w: float = FILL_W, preserve_lift: bool = False, ref_height: float | None = None, cell_h: int | None = None, diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py index 3489de23..d00d4ed6 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py @@ -2,6 +2,10 @@ 视频路线里"从连续视频里挑出交付用的那几帧"这一步:循环类动作抽单步态周期(无缝 loop),一次性动作裁动作区间。像素化 / 对齐 / 打包在 :mod:`..postprocess`。 + +:mod:`.quality` 原本纯做诊断,现在还兼一份出参职责:交付帧的成色读数 +(``motion_scale`` / ``dead_frame_indices`` / ``loop_seam``)汇成 ``ports.ActionQuality``。 +注意它**仍然不参与选帧** —— 那条消融结论没变,见 :func:`.loop.pick_cycle`。 """ from .extract import extract_all_frames_bytes, extract_frames_bytes @@ -13,12 +17,17 @@ pick_oneshot, split_jump_phases, ) +from .quality import dead_frame_indices, loop_seam, motion_scale __all__ = [ "extract_frames_bytes", "extract_all_frames_bytes", "find_period", "pick_cycle", + # 交付成色的三个读数(汇成 ports.ActionQuality;其余 quality.* 仍是内部诊断) + "dead_frame_indices", + "loop_seam", + "motion_scale", "find_motion_span", "first_action_end", "foot_line_series", diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py index 07491daa..26a5b586 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py @@ -98,7 +98,20 @@ def pick_cycle(frames: list[Image.Image], n: int) -> list[Image.Image]: ② 取样后按 ``dead_frame_mask`` 就近避让死帧:i2v 死帧占比常达一半(24fps 容器隔帧复制, 实测 59/121 与 63/121),避让会系统性打乱相位均匀性,反而选中更多死帧(1→3)。 候选评分里的 ``a < 0.5 * scale`` 已经排掉"几乎不动"的窗口,够用。 - quality 留作诊断/报告用,不进选帧。 + quality 不进选帧(它另有一份出参职责,见 :mod:`.quality`)。 + + **已知缺口(2026-08-09 记,未修)**:本函数有四条返回路径,其中三条是 return 帧列表, + 调用方**分不清走了哪条**: + 1. ``total <= n`` 原样返回(源帧比要的还少,根本没选); + 2. 测不到可信周期 → 全片均匀取(**降级**,不闭环); + 3. 候选全被否 → 全片均匀取(**降级**,同上); + 4. 正常闭环。 + 2 与 3 的补救方式不同(2 多半是母版/动作幅度问题,该换母版;3 多半是视频里周期数 + 不够,该加长视频),但今天都表现为"一组看起来正常的帧"。**降级对交付物的后果**是可测 + 的 —— ``ports.ActionQuality.loop_seam`` 在交付帧上量归一化接缝,降级通常表现为接缝 + 偏大;但**降级的原因**测不出来。没有顺手把状态塞进返回值,是因为那要改本函数的返回 + 形状、波及所有调用方,而今天还没有任何调用方会依据"原因"改变行为。等真有调用方要按 + 原因给不同提示时,再让本函数返回 ``(frames, reason)``。 """ # n<=0 没有合法语义(要 0 帧的动画不存在),且两条出路都是坏的(2026-08-10 实测): # 检出周期时 `_offsets(P, 0)` 交出空 offsets,一路走到 `M[idx[-1], idx[0]]` 抛 IndexError; diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py index d6cb0192..dcbf1507 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py @@ -16,6 +16,8 @@ import numpy as np from PIL import Image +from windup_ai_engine._subject import subject_mask as _subject_mask + __all__ = [ "find_motion_span", "first_action_end", @@ -191,19 +193,8 @@ def pick_oneshot( def _subject_rows(frame: Image.Image, alpha_thr: int = 128, bg_tol: int = 60) -> np.ndarray: - """主体所在的行下标。有真实 alpha 用 alpha;**全不透明帧**(原始视频帧)按四角背景色判。 - - 必须兼容不透明帧:抽帧阶段拿到的是原始视频帧,还没抠图,只看 alpha 会把整幅当主体、 - 脚线恒定,导致腾空判据立刻误判"已落地"(实测踩过,跳跃被裁在起跳前)。 - """ - arr = np.asarray(frame.convert("RGBA")) - alpha = arr[:, :, 3] - if not alpha.min() > alpha_thr: - return np.where(alpha > alpha_thr)[0] - rgb = arr[:, :, :3].astype(np.int16) - corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) - bg = np.median(corners, axis=0) - return np.where(np.abs(rgb - bg).sum(axis=2) > bg_tol)[0] + """主体所在的行下标。判据本身在 :mod:`.._subject`(与母版预检共用同一个主体定义)。""" + return np.where(_subject_mask(frame, alpha_thr, bg_tol))[0] def foot_line_series(frames: list[Image.Image], alpha_thr: int = 128) -> np.ndarray: diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py index 8bd85e57..734274ec 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py @@ -12,7 +12,8 @@ from ._frames import gray as _gray -__all__ = ["active_span", "blur_ratio", "dead_frame_mask", "frame_deltas"] +__all__ = ["active_span", "blur_ratio", "dead_frame_indices", "dead_frame_mask", + "frame_deltas", "loop_seam", "motion_scale"] def frame_deltas(frames) -> np.ndarray: @@ -44,6 +45,49 @@ def dead_frame_mask(frames, ratio: float = 0.35, floor: float = 0.25) -> np.ndar return m +def dead_frame_indices(frames) -> tuple[int, ...]: + """死帧下标。:func:`dead_frame_mask` 的出参形态转换 —— 掩码是算的时候好用的形态, + 跨出 ai_engine 的契约(``ports.ActionQuality``)要的是"哪几帧",不该让调用方拿着 + 一个 numpy 掩码去自己 argwhere。""" + return tuple(int(i) for i in np.flatnonzero(dead_frame_mask(frames))) + + +def motion_scale(frames) -> float: + """相邻帧平均差异的**绝对**尺度(48×48 灰度)。0.0 = 这些帧逐像素完全一样。 + + 为什么与 :func:`dead_frame_mask` 并存、而不是从它推导:后者两条判据 + (``d[i] < ratio*max(邻居)`` 与 ``d[i] < floor*p75``)**都是相对的**,整段完全 + 冻结时 d 全为 0,两条不等式变成 ``0 < 0``,一条都不成立 —— **一帧死帧都报不出** + (2026-08-09 用全同帧序列实测:12 帧全同,死帧数 0)。相对判据天生看不见"整体 + 没动",绝对尺度必须单独给一个。 + """ + d = frame_deltas(frames) + return float(d[1:].mean()) if len(d) > 1 else 0.0 + + +def loop_seam(frames) -> float | None: + """末帧接回首帧的跳幅 ÷ 相邻帧平均步长;整段静止(分母为 0)返回 ``None``。 + + 与 :func:`.loop.pick_cycle` 选帧时的归一化接缝同式,但**测的对象不同**:pick_cycle + 在抠图 / 像素化 / 脚线对齐**之前**的密集帧上打分,而用户看到的是这三步之后的帧, + 这三步都会改动像素。要描述交付物就得在交付物上量。 + + 不套 :func:`.loop._deskew`:交付帧已被 ``align_bottom_center`` 逐帧居中,整体平移 + 早消掉了,再按差分质心对一次只是引入第二套居中口径(两套口径不一致正是本仓反复 + 踩的那类静默分歧)。 + + 分母为 0 时返回 None 而不是 0.0 —— 0.0 会被读成"完美闭环",而真相是"没有可比的 + 步长,这个数不可读"。 + """ + gs = _gray(frames) + if len(gs) < 2: + return None + step = float(np.mean([np.abs(gs[i + 1] - gs[i]).mean() for i in range(len(gs) - 1)])) + if step <= 0.0: + return None + return float(np.abs(gs[-1] - gs[0]).mean() / step) + + def active_span(frames, floor: float = 0.25, min_run: int = 3) -> tuple[int, int]: """掐掉头尾的**持续**冻结段,返回 [s, e](闭区间)。中间的隔帧死不动。""" d = frame_deltas(frames) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py index 3c45e18c..8435572e 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py @@ -1,10 +1,11 @@ """strategy:动作 → 生成路线分流(ROUTE_MATRIX)+ 三条 DerivationStrategy。""" -from .base import ROUTE_MATRIX, DerivationStrategy +from .base import CYCLIC_ACTIONS, ROUTE_MATRIX, DerivationStrategy from .concrete import PerFrameStrategy, VideoFrameStrategy __all__ = [ "ROUTE_MATRIX", + "CYCLIC_ACTIONS", "DerivationStrategy", "VideoFrameStrategy", "PerFrameStrategy", diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py index 8222deab..0a1b1c00 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py @@ -4,7 +4,7 @@ - 步态位移(walk / run):逐帧独立生成锁不住"哪条腿在前" → 踢踏舞; 必须走视频 i2v(视频模型天生连贯、腿自然交替)。 - 动作爆发(attack)与跳跃(jump):同走视频 i2v。但它们是**一次性动作**,抽帧不闭环 - (见 strategy.concrete.CYCLIC_ACTIONS);jump 还要按状态切段供引擎分段播放。 + (见本模块 CYCLIC_ACTIONS);jump 还要按状态切段供引擎分段播放。 - 受击等离散姿势(hit):逐帧图生图(单帧可编辑价值高,无连续步态)。 - 待机(idle):逐帧生成只抖不呼吸 → 程序化局部呼吸 Idle-B。 @@ -31,6 +31,16 @@ ActionType.IDLE: GenRoute.VIDEO_I2V, } +# 循环类动作:抽单步态周期闭环。一次性动作**不能闭环**(首尾姿态不同,强行闭环会把 +# 落地帧接回蓄力帧=抽搐),走"裁动作区间 + 区间内均匀取"。 +# 与 ROUTE_MATRIX 并排放在 base 而不是留在 concrete:它同样是「动作类型 → 产线行为」的 +# 契约,且现在有两个消费方 —— strategy.concrete 用它选抽帧方式,impl.CharacterGenerator +# 用它决定交付成色里的 loop_seam 该不该测(不闭环的动作没有"接缝"可言)。放在 concrete +# 会让 generator 为了问一句"这动作循环吗"去 import 一条具体路线的实现。 +CYCLIC_ACTIONS: frozenset[ActionType] = frozenset( + {ActionType.IDLE, ActionType.WALK, ActionType.RUN} +) + # 本矩阵的形状本身有个已知边界,记录在此以免后来者按错误前提扩展: # 它是「动作类型 → 路线」的一对一映射,隐含前提是"路线由动作的物理性质唯一决定"。 # 该前提对逐帧 / 视频两条路线成立(有无连续步态是动作固有属性),但对渲染出帧路线不成立 diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py index 4a781e52..eff5521c 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py @@ -27,14 +27,7 @@ build_jump_prompt, build_walk_prompt, ) -from windup_ai_engine.strategy.base import DerivationStrategy - - - - -# 循环类动作走"步态周期抽单周期闭环";一次性动作**不能闭环**(首尾姿态不同,强行闭环 -# 会把落地帧接回蓄力帧=抽搐),改走"裁动作区间 + 区间内均匀取"。 -CYCLIC_ACTIONS = frozenset({ActionType.IDLE, ActionType.WALK, ActionType.RUN}) +from windup_ai_engine.strategy.base import CYCLIC_ACTIONS, DerivationStrategy class VideoFrameStrategy(DerivationStrategy): diff --git a/backend/packages/framework/src/windup_framework/providers/matte.py b/backend/packages/framework/src/windup_framework/providers/matte.py index ffc28866..5695316f 100644 --- a/backend/packages/framework/src/windup_framework/providers/matte.py +++ b/backend/packages/framework/src/windup_framework/providers/matte.py @@ -37,6 +37,30 @@ _KEY_SOFT = 14.0 # 到 _KEY_KILL + _KEY_SOFT 之间线性过渡,避免硬边锯齿 _BG_FLAT_STD = 8.0 # 四角色标准差上限;超过说明底不是纯色,不做任何清理 +# 采样前先丢掉最外圈像素。视频帧的最外一两行/列常是**编码器边缘伪影**,不是底色: +# 2026-08-10 实测 9 段真 i2v 视频 × 16 帧 = 144 帧,贴边采样时 26 帧(18%)判"底不均匀" +# 而跳过清理,逐一查证全部由最外圈造成 —— 白底母版视频最右一列整列纯黑(std 50.4), +# 待机视频最顶一行偏暗(std 8.4,恰好压线越过 8)。往里让 1 px 就降到 1.9、让 2 px 降到 1.88, +# 144 帧零误跳;三张静态母版的取样中位色一个字节都没变(220/64/135、222/39/130、222/41/124)。 +# 取 2 是为容下 2 px 宽的边框;真正不均匀的底(噪声/渐变/拼色)让多少都照样超阈值,守卫不松。 +_EDGE_SKIP = 2 +_CORNER = 12 # 每个角的采样块边长 + + +def _corner_pixels(rgb: np.ndarray) -> np.ndarray: + """四角采样块(跳过最外圈 ``_EDGE_SKIP`` 像素)拼成的 (N, 3) 像素表。 + + 图太小时(四角会互相重叠)不让,退回贴边取 —— 合成测试图和缩略图走这条路。 + """ + k = _CORNER + s = _EDGE_SKIP if min(rgb.shape[:2]) > 2 * (_EDGE_SKIP + k) else 0 + r = rgb[s : rgb.shape[0] - s, s : rgb.shape[1] - s] if s else rgb + return np.concatenate([ + r[:k, :k].reshape(-1, 3), r[:k, -k:].reshape(-1, 3), + r[-k:, :k].reshape(-1, 3), r[-k:, -k:].reshape(-1, 3), + ]) + + # 空洞填充用。_HOLE_ALPHA:低于此 alpha 才算"透明",参与空洞判定。 # _HOLE_BG_TOL:到底色的距离低于此值 → 判为"确实是底色"。取值依据(2026-08-11 实测, # 1280×720 真实视频帧):纯背景区域的色距 p99.9≈6.5、最大 11.1(视频压缩噪点); @@ -50,12 +74,10 @@ def _bg_key(rgb: np.ndarray) -> np.ndarray | None: 抽成独立函数是为了让"底色是什么"只有一个真相源 —— 键控清理(``_flat_bg_penalty``) 和空洞填充(``_fill_enclosed_holes``)必须按同一个 key 判断,否则一个把某块当背景 - 清掉、另一个又把它当主体填回来,互相打架。 + 清掉、另一个又把它当主体填回来,互相打架。取样统一走 :func:`_corner_pixels`, + 连"跳过最外圈编码器伪影"这条也只有一份实现。 """ - corners = np.concatenate([ - rgb[:12, :12].reshape(-1, 3), rgb[:12, -12:].reshape(-1, 3), - rgb[-12:, :12].reshape(-1, 3), rgb[-12:, -12:].reshape(-1, 3), - ]) + corners = _corner_pixels(rgb) # 跳过编码器边缘伪影,见 _EDGE_SKIP if float(corners.std(axis=0).max()) > _BG_FLAT_STD: return None return np.median(corners, axis=0).astype(np.float32) @@ -137,6 +159,12 @@ def _flat_bg_penalty(rgb: np.ndarray) -> np.ndarray: 会被抠穿)。这里主体判据仍然是 u2netp,颜色只用来**做减法** —— 绝不新增主体像素, 最坏情况是少清理一点,不会抠穿角色。底色不够均匀时(std 超阈值)直接返回全 1, 等于不清理。 + + **逐帧独立采样是安全的**(2026-08-10 在真视频帧上验证):同一段视频里逐帧算出的 key 色 + 几乎不动(9 段 i2v 实测帧间位移 <= 1.73/255),故不需要跨帧共享一次采样。序列帧真正的 + 闪烁源是**守卫在序列中途翻转**(部分帧清、部分帧不清):待机那段 16 帧里前 6 帧清、后 10 帧 + 不清,主体面积逐帧变化 CV 从 0.0036 跳到 0.0197、第 6 帧单帧跳 4.25%。跳过最外圈后 + 守卫不再翻转,CV 回到 0.0028 —— 比完全不清理还稳(清理同时抹掉了会自己抖的底色描边)。 """ key = _bg_key(rgb) if key is None: diff --git a/backend/tests/test_master_check_and_quality.py b/backend/tests/test_master_check_and_quality.py new file mode 100644 index 00000000..bb5ada37 --- /dev/null +++ b/backend/tests/test_master_check_and_quality.py @@ -0,0 +1,177 @@ +"""母版入口预检 + 出参成色信号。 + +两头各一道闸,方向相反:进门那道在**花钱之前**挡住不可能生成好的输入; +出门那道在钱已花完之后,让上层看得出"这次生成得怎么样"。 + +2026-08-07 的教训:喂一张"人物在画板前作画"的图请求 walk,全程无一处报错, +16 帧构图完整的错角色出完、钱花完。而一段每帧都一样的 walk 与一段步态干净的 walk, +帧数 / 时长 / fps 完全相同,调用方分辨不出。 +""" +from __future__ import annotations + +import io + +import pytest +from PIL import Image + +from windup_ai_engine.master_check import ( + MIN_SUBJECT_SIDE, + REJECT_ASPECT, + check_master, +) +from windup_ai_engine.ports import ActionQuality, MasterRejectCode, MasterRejected +from windup_ai_engine.slicing import dead_frame_indices, loop_seam, motion_scale +from windup_ai_engine.postprocess.pack import FILL_H, FILL_W + + +def _png(w: int, h: int, blob: tuple[tuple[int, int, int, int], tuple] | None = None, + bg=(0, 0, 0, 0)) -> bytes: + img = Image.new("RGBA", (w, h), bg) + if blob: + (x0, y0, x1, y1), color = blob + for y in range(y0, y1): + for x in range(x0, x1): + img.putpixel((x, y), color) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +# ── 入口预检:四种拒绝码 ────────────────────────────────────────────────────── + + +def test_undecodable_bytes_rejected_before_spending(): + """坏 bytes 直接炸,不要等 i2v 花完钱才发现输入根本不是图。""" + with pytest.raises(MasterRejected) as e: + check_master(b"not an image at all") + assert e.value.code is MasterRejectCode.UNDECODABLE + + +def test_fully_transparent_has_no_subject(): + with pytest.raises(MasterRejected) as e: + check_master(_png(200, 200)) + assert e.value.code is MasterRejectCode.NO_SUBJECT + + +def test_flat_single_color_has_no_subject(): + """全同色 = 没有可动的东西。不透明但一片死板的图同样该拒。""" + with pytest.raises(MasterRejected) as e: + check_master(_png(200, 200, bg=(120, 90, 60, 255))) + assert e.value.code is MasterRejectCode.NO_SUBJECT + + +def test_subject_smaller_than_min_side_rejected(): + """包围盒最短边不足 → 下游会把它 NEAREST 放大 20 倍,那是色块不是角色。 + + 刻意用**细长条**而不是小方块:细长条的像素占比高达 1.3%(远超 0.1% 下限), + 所以占比那条拦不住它,只有最短边这条能拦。用小方块的话两条判据都会触发, + 删掉任何一条测试都照样绿——那种测试等于没写(2026-08-09 变异测试逮到)。 + """ + thin = MIN_SUBJECT_SIDE - 2 # 6px 宽 + with pytest.raises(MasterRejected) as e: + check_master(_png(300, 300, blob=((100, 40, 100 + thin, 240), (200, 60, 60, 255)))) + assert e.value.code is MasterRejectCode.SUBJECT_TOO_SMALL + + +def test_scattered_specks_pass_side_check_but_fail_area_ratio(): + """对角两粒噪点会把包围盒撑到整幅——边长检查全过,占比才拦得住。 + + 这两条判的不是同一件事,缺了占比这条,一张几乎空白的图会被判成"有主体"。 + """ + # 每粒 10×10=100px(边长过得了 MIN_SUBJECT_SIDE=8),两粒共 200px, + # 占 600×600 的 0.056%,压在 0.1% 下限之下;而包围盒被撑到 ~590×590,边长检查全过。 + img = Image.new("RGBA", (600, 600), (0, 0, 0, 0)) + for (x, y) in ((8, 8), (582, 582)): + for dy in range(10): + for dx in range(10): + img.putpixel((x + dx, y + dy), (200, 60, 60, 255)) + buf = io.BytesIO() + img.save(buf, "PNG") + with pytest.raises(MasterRejected) as e: + check_master(buf.getvalue()) + assert e.value.code is MasterRejectCode.SUBJECT_TOO_SMALL + + +def test_extremely_wide_subject_rejected(): + """主体太扁 → 方形画布只能把角色硬缩成一条,不如在花钱前退回去。""" + w = int(60 * REJECT_ASPECT) + 40 + with pytest.raises(MasterRejected) as e: + check_master(_png(w + 40, 200, blob=((10, 60, 10 + w, 120), (200, 60, 60, 255)))) + assert e.value.code is MasterRejectCode.ASPECT_TOO_WIDE + + +def test_ordinary_humanoid_master_passes_and_reports_facts(): + """人形母版必须放行——预检的价值在于不误伤,误伤一次比漏放一次更贵。""" + facts = check_master(_png(400, 600, blob=((160, 100, 240, 520), (200, 60, 60, 255)))) + assert facts.size == (400, 600) + assert 0.1 < facts.subject_ratio < 1.2 + assert facts.subject_area_ratio > 0.001 + assert facts.note() # 进度文案不能是空串 + + +def test_reject_aspect_is_derived_from_canvas_geometry_not_hardcoded(): + """阈值必须跟着画布几何走。把 pack.py 的 FILL_W/FILL_H 改了而这里不动, + 预检就会放行一批下游装不下的母版——那正是"看起来成功"的来源。""" + assert REJECT_ASPECT == pytest.approx(2 * FILL_W / FILL_H) + + +def test_rejection_carries_machine_readable_code_not_just_a_message(): + """server 要据此选文案 / 决定 4xx-不重试,用消息做分支会在改文案时悄悄失效。""" + with pytest.raises(MasterRejected) as e: + check_master(b"broken") + assert isinstance(e.value.code, MasterRejectCode) + assert e.value.detail + + +# ── 出参成色:三个字段各自不可由其他两个推导 ────────────────────────────────── + + +def _frames(n: int, shift: int = 3) -> list[Image.Image]: + out = [] + for i in range(n): + im = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) + x = 10 + (i * shift) % 30 + for y in range(20, 50): + for xx in range(x, x + 12): + im.putpixel((xx, y), (200, 60, 60, 255)) + out.append(im) + return out + + +def test_motion_scale_is_zero_for_a_frozen_sequence(): + """整段冻结时死帧判据一帧都报不出——两条判据都是相对的,d 全为 0 时 + `0 < 0` 一条都不成立。绝对尺度必须单独给一个,否则"每帧都一样"这种 + 最典型的坏产出在出参上完全看不见。""" + same = _frames(12, shift=0) + assert motion_scale(same) == 0.0 + assert len(dead_frame_indices(same)) == 0, "相对判据看不见整体没动 —— 正是要 motion_scale 的原因" + + +def test_motion_scale_positive_for_real_movement(): + assert motion_scale(_frames(12)) > 0.0 + + +def test_dead_frame_indices_returns_positions_not_a_mask(): + """跨出 ai_engine 的契约要"哪几帧",不该让调用方拿 numpy 掩码去 argwhere。""" + idx = dead_frame_indices(_frames(10)) + assert isinstance(idx, tuple) + assert all(isinstance(i, int) for i in idx) + + +def test_loop_seam_returns_none_when_there_is_no_step_to_compare(): + """分母为 0 时返回 None 而不是 0.0——0.0 会被读成"完美闭环", + 而真相是"没有可比的步长,这个数不可读"。""" + assert loop_seam(_frames(8, shift=0)) is None + assert loop_seam(_frames(1)) is None + + +def test_loop_seam_measures_the_gap_between_last_and_first(): + seam = loop_seam(_frames(10)) + assert seam is not None and seam >= 0.0 + + +def test_quality_fields_are_independent(): + """三个字段互不可推导:全同帧的 motion_scale=0 而 dead_frames 为空, + 两者若能互推,这一组断言不可能同时成立。""" + q = ActionQuality(motion_scale=0.0, dead_frames=(), loop_seam=None) + assert q.motion_scale == 0.0 and q.dead_frames == () and q.loop_seam is None diff --git a/backend/tests/test_matte_provider.py b/backend/tests/test_matte_provider.py index 20837f8c..0ac02076 100644 --- a/backend/tests/test_matte_provider.py +++ b/backend/tests/test_matte_provider.py @@ -96,6 +96,46 @@ def blocked(name, *a, **k): builtins.__import__ = real +# ── 视频帧的最外圈是编码器伪影,不是底色(2026-08-10 实测挣得)────────────── + + +def test_edge_artifact_row_does_not_disable_cleanup(): + """最外一行/列常是编码器伪影:贴边采样会把它算进"底色是否均匀", + 于是整帧被判"底不均匀"而跳过清理——修复在真实路径上等于从不生效。 + + 实测 9 段真 i2v × 16 帧 = 144 帧,贴边采样时 26 帧(18%)因此误跳; + 往里让 2px 后归零。 + """ + import numpy as np + + from windup_framework.providers.matte import _flat_bg_penalty + + bg = (222, 41, 124) + a = np.zeros((80, 80, 3), dtype=np.float32) + a[:, :] = bg + a[:, -1] = (0, 0, 0) # 最右一列纯黑:典型的编码器边缘伪影 + a[0, :] = (180, 30, 100) # 最顶一行偏暗 + p = _flat_bg_penalty(a) + assert p[40, 40] == 0.0, "跳过最外圈后应认出这是纯色底并清理;贴边采样会误判为不均匀" + + +def test_tiny_image_degrades_to_no_cleanup_rather_than_guessing(): + """图小到四角采样块会盖住主体时,采出来的"底色"其实混了主体色, + 此时守卫判"底不均匀"、整体跳过清理。 + + 这是**安全的退化方向**:清理只做减法,跳过等于少清一点;反过来若强行按 + 混了主体色的 key 去清,会把主体本身当背景抠掉——本项目宁可漏,不可误伤。 + """ + import numpy as np + + from windup_framework.providers.matte import _flat_bg_penalty + + a = np.zeros((20, 20, 3), dtype=np.float32) + a[:, :] = (0, 255, 0) + a[8:12, 8:12] = (200, 60, 60) # 主体落在四角采样块的重叠区 + assert (_flat_bg_penalty(a) == 1.0).all(), "采样不可靠时必须整体跳过,而不是按脏 key 清理" + + # ── 封闭空洞填充(2026-08-11 在 121 帧真实走路视频帧上实测挣得)────────────────── # # 背景:交付帧放大看,主体内部会有透明洞(背景直接透出来)。实测拆开成因: From 20bee7ec0eca74bb79701cf806e556f94cca6b19 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 15:38:30 +0800 Subject: [PATCH 25/33] =?UTF-8?q?fix(deps):=20=E9=87=8D=E9=94=81=20uv.lock?= =?UTF-8?q?=20=E4=BB=A5=E8=A6=86=E7=9B=96=E8=A7=86=E9=A2=91=E6=8A=BD?= =?UTF-8?q?=E5=B8=A7=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai_engine 的 pyproject 声明了 imageio / av(抽帧必需),而 rebase 解冲突时 uv.lock 取的是主线版本,两者不一致:uv lock --check 报 lockfile needs to be updated。 本地 venv 里恰好装过这两个包,故本地测试没暴露;CI 用 --frozen 装依赖时会缺。 重锁时带 UV_DEFAULT_INDEX=阿里云镜像 —— 直接 uv lock 会把全仓 90 处包源改写成 pypi.org,产出两千多行与本次改动无关的 diff(主线 Dockerfile 定的就是这个镜像源)。 --- backend/uv.lock | 253 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 251 insertions(+), 2 deletions(-) diff --git a/backend/uv.lock b/backend/uv.lock index d817fb61..5e1a6cfd 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -14,6 +14,7 @@ members = [ dev = [ { name = "import-linter", specifier = ">=2.0" }, { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-cov", specifier = ">=5.0" }, { name = "ruff", specifier = ">=0.6" }, ] @@ -74,6 +75,72 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -177,6 +244,105 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934" }, ] +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645" }, + { url = "https://mirrors.aliyun.com/pypi/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" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -186,6 +352,28 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4" }, +] + [[package]] name = "fastapi" version = "0.139.2" @@ -907,6 +1095,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, ] +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + [[package]] name = "pillow" version = "12.3.0" @@ -1075,6 +1277,11 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.4" @@ -1207,6 +1414,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1283,6 +1504,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/1d/56/9368cd96d2132017f5748a812cb20a87263498de314e1823472cb4bdbad9/qiniu-7.18.0-py3-none-any.whl", hash = "sha256:0f1be608ac6800ad5f32690d1aa02353b6b2ff5edc78f32a2318859d375a27df" }, ] +[[package]] +name = "redis" +version = "8.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb" }, +] + [[package]] name = "regex" version = "2026.7.19" @@ -1398,6 +1628,19 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, ] +[[package]] +name = "resend" +version = "2.35.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f0/b9/e07f2f2bd992ef4565b9c315dfd46b3df66ce89df1d928f442b9b39cbe3b/resend-2.35.0.tar.gz", hash = "sha256:26ced7b22cbd89f7b8c7ba9719d0708ab10c06ddd0f91ba6ec861f3a328101b3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/cc/92/1c0912b68ae082a55dfdd32e4b5117905ae9fd1b6efc5f5e7c69a4ee6894/resend-2.35.0-py2.py3-none-any.whl", hash = "sha256:cd75299d626f4735af52910989b3f51919032ef316e2d60563c79c319e7b24a6" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1889,7 +2132,7 @@ version = "0.1.0" source = { editable = "packages/app" } dependencies = [ { name = "fastapi" }, - { name = "pydantic" }, + { name = "pydantic", extra = ["email"] }, { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, @@ -1901,7 +2144,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, - { name = "pydantic", specifier = ">=2.7" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.7" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, @@ -1931,12 +2174,15 @@ dependencies = [ { name = "langchain-openai" }, { name = "numpy" }, { name = "onnxruntime" }, + { name = "passlib", extra = ["bcrypt"] }, { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "qiniu" }, + { name = "redis" }, + { name = "resend" }, { name = "sqlalchemy" }, { name = "windup-common" }, ] @@ -1948,12 +2194,15 @@ requires-dist = [ { name = "langchain-openai", specifier = ">=0.3" }, { name = "numpy", specifier = ">=1.26" }, { name = "onnxruntime", specifier = ">=1.17,<1.24" }, + { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7" }, { name = "pillow", specifier = ">=10.4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.4" }, { name = "pyjwt", specifier = ">=2.9" }, { name = "qiniu", specifier = ">=7.14" }, + { name = "redis", specifier = ">=5.0" }, + { name = "resend", specifier = ">=2.0" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "windup-common", editable = "packages/common" }, ] From 8d1f99dc76caa75a8a9cb4e0a9badda20ac6d055 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 21:59:22 +0800 Subject: [PATCH 26/33] =?UTF-8?q?fix(ai=5Fengine):=20=E5=87=BA=E5=8F=82?= =?UTF-8?q?=E5=8F=AA=E7=95=99=E4=B8=80=E4=B8=AA=E6=92=AD=E6=94=BE=E6=97=B6?= =?UTF-8?q?=E5=BA=8F=E7=9C=9F=E7=9B=B8=E6=BA=90=EF=BC=8C=E5=B9=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E4=B8=8A=E6=B8=B8=E4=B8=A4=E5=A4=84=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 机器审在本 PR 报的三条 P2,两条同源:契约里存在"能填/能读、但与另一处矛盾或不生效"的 字段。 一、GeneratedAction.fps 删除。它抄自入参,而 durations 按动作查表得来,两者描述同一段 素材的不同播放速度:fps=20 宣称 50ms/帧,walk 实际给 125ms/帧,取哪个看消费方心情。 逐帧 ms 严格更能表达(关键帧定格),所以保 durations、删 fps;真要单一帧率由消费方算。 连带删除 ActionSpec.fps(在 feat/character-domain-models 里,本分支同步)。 二、删掉一条为缺陷背书的测试。此处曾有 test_loop_mode_currently_changes_nothing,把 "传 pingpong / none 不改变任何一帧"钉成可执行事实,理由是"将来真接线时它会变红提醒 删注释"。那是把缺陷固化:调用方能为一段往返动画付费、拿到一段线性循环,而测试为这个 行为背书。现改为断言字段确实不存在——ActionSpec.loop 与 LoopMode 都已移除。 同理,test_generate_walk_is_wired_end_to_end 里的 `assert out.fps == action.fps` 换成断言时长确实来自动作查表(walk = 125ms/帧)。 三、抽帧改流式(改动本体在 feat/ai-engine-frame-toolkit,本分支同步)。121 帧 720p 真实 视频抽 16 帧,进程 RSS 峰值 488 → 126 MiB。 变异测试:把 GeneratedAction.fps 加回去 1 条红;把 durations 改成固定 50ms 不查表 1 条红。 CI:ruff / import-linter 2 contracts / pytest 276 passed。 --- .../impl/character_generator.py | 1 - .../src/windup_ai_engine/ports/__init__.py | 5 ++- backend/tests/test_ai_engine_skeleton.py | 35 ++++++++++--------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py index 7243fe51..c9dccff0 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -103,7 +103,6 @@ def generate( return GeneratedAction( frames=[_png(im) for im in aligned], durations=frame_durations(action.action.value, len(aligned)), - fps=action.fps, quality=quality, ) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py index 44d36355..b384f7e0 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py @@ -113,8 +113,11 @@ class GeneratedAction: """ frames: list[bytes] = field(default_factory=list) # RGBA PNG,按播放序 + # 播放时序的**唯一**真相源。曾另有一个 fps 字段抄自入参,与本字段互相矛盾: + # fps=20 宣称 50ms/帧,而 walk 这里给的是 125ms/帧 —— 同一段素材两个播放速度, + # 取哪个看消费方心情(2026-08-10 机器审 P2)。逐帧 ms 严格更能表达(关键帧定格), + # 所以删 fps 保 durations;真要单一帧率,由消费方从本字段算。 durations: list[int] = field(default_factory=list) # 逐帧时长(ms),与 frames 等长 - fps: int = 10 # 无默认值、且 kw_only 让它能排在有默认值的字段之后:**不给"没测"留缺省**。 # 给个 None 缺省的话,漏测与"测出来没问题"在调用方看来一模一样,而这个出参的 # 全部意义就是把这两者分开。 diff --git a/backend/tests/test_ai_engine_skeleton.py b/backend/tests/test_ai_engine_skeleton.py index c0484957..88db15d6 100644 --- a/backend/tests/test_ai_engine_skeleton.py +++ b/backend/tests/test_ai_engine_skeleton.py @@ -11,6 +11,7 @@ from windup_ai_engine.impl import CharacterGenerator from windup_ai_engine.ports import GeneratedAction +from windup_ai_engine.postprocess.rootmotion import DEFAULT_FPS_MS from windup_ai_engine.strategy import ( ROUTE_MATRIX, DerivationStrategy, @@ -22,7 +23,6 @@ CharacterCard, Facing, GenRoute, - LoopMode, Stylize, ) @@ -74,7 +74,10 @@ def test_generate_walk_is_wired_end_to_end(): assert isinstance(out, GeneratedAction) assert len(out.frames) == 8 # 选路线→derive→对齐 全串通 assert len(out.durations) == 8 # 逐帧时长与帧等长 - assert out.fps == action.fps + # 时长是按动作查表来的,不是从入参帧率算的 —— walk 的基准是 125ms/帧。 + # 原先这里断言的是 `out.fps == action.fps`,把"照抄一个不生效的入参"锁成了契约。 + assert all(d > 0 for d in out.durations) + assert set(out.durations) == {DEFAULT_FPS_MS["walk"]} assert all(f and f[:8] == b"\x89PNG\r\n\x1a\n" for f in out.frames) # 真 PNG @@ -287,24 +290,22 @@ def step(self, stage: str, i: int, total: int, note: str = "") -> None: assert any("walk" in n for n in notes), notes -def test_loop_mode_currently_changes_nothing(monkeypatch): - """现状锁定:``ActionSpec.loop`` 今天零消费方,改它不会改变任何一帧。 +def test_generated_action_has_a_single_timing_source(): + """出参不许有第二个描述播放速度的字段。 - 闭环行为写死在 slicing.pick_cycle(循环类动作一律抽单周期)。本测把这条"设了不生效" - 钉成可执行事实——将来真把 pingpong 接进抽帧时它会变红,提醒同步删掉 character.py - 里那段"别指望它生效"的注释。 - """ - strat = _offline_video_strategy(monkeypatch) - card = CharacterCard(name="knight", desc="plate armor, sword") + 此处曾有一条 ``test_loop_mode_currently_changes_nothing``,把"传 pingpong / none + 不改变任何一帧"钉成可执行事实,理由是"将来真接线时它会变红提醒删注释"。 + 那是把缺陷固化:调用方能为一段往返动画付费、拿到一段线性循环,而测试为这个行为背书。 + 2026-08-10 按机器审意见改成删字段 —— ``ActionSpec.loop`` 与 ``LoopMode`` 都已移除, + 真要支持 pingpong,连同 pick_cycle 的分支与出参时序契约一起加回。 - def _run(loop): - return strat.derive( - card, - ActionSpec(action=ActionType.WALK, stylize=Stylize.NONE, n_frames=4, loop=loop), - master=_tiny_png(), progress=_NullProgress(), - ) + 同批删掉的 ``GeneratedAction.fps`` 同理:它抄自入参、与 durations 互相矛盾。 + """ + from dataclasses import fields - assert _run(LoopMode.PINGPONG) == _run(LoopMode.LINEAR) == _run(LoopMode.NONE) + names = {f.name for f in fields(GeneratedAction)} + assert "fps" not in names, "fps 与 durations 会给出两个不同的播放速度" + assert "durations" in names def test_genroute_only_lists_implemented_routes(): From 57d2835cce07d2aa796d4f384600fa2382f5a1d8 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 11 Aug 2026 11:15:05 +0800 Subject: [PATCH 27/33] =?UTF-8?q?feat(ai=5Fengine):=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E6=94=B6=E4=BA=A4=E4=BB=98=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=B0=BA=E5=AF=B8=EF=BC=8C=E5=BC=95=E6=93=8E=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E5=87=BA=E5=88=B0=E4=BD=8D=E4=B8=8D=E5=86=8D=E8=AE=A9=E4=B8=8A?= =?UTF-8?q?=E5=B1=82=E7=BC=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引擎恒出 256 方形,项目的 sprite 尺寸由 app 层拿到帧之后再缩一次。那一步的代价 不是"糊一点":它用 Image.thumbnail 补边,而 **thumbnail 只缩不放**。2026-08-11 复刻上层这段逻辑实测(喂主体高 157px、脚线 0.92 的 256 交付帧): 目标 512×512 → 主体仍 157px(根本没放大),脚线 0.92 → 0.709 目标 384×384 → 主体仍 157px, 脚线 0.92 → 0.779 即放大方向上主体尺寸一点没涨("成品放大看很糊"的直接来源),而且 _lastmile 刚 对齐好的脚线被整体挪高,角色不站在地上,ref_height 那套跨动作一致性也跟着失效。 故 CharacterGeneratorPort.generate 增加可选入参 canvas=(宽, 高),一路传到 _lastmile → align_bottom_center(cell=宽, cell_h=高),让引擎一次出到目标尺寸, 上层那次二次缩放整个消掉。 为什么放在 generate 的入参而不是 ActionSpec 的字段:画布尺寸是**项目级输出约束**, 不是动作规格。挂到 ActionSpec 上,每个动作各带一份尺寸,一旦不同动作填得不一致就 会静默破坏跨动作本体尺寸一致(ref_height 专门守的就是这条),而入参形式下它由 编排层一处给出。同时 windup_common 一个字段都不用动。 canvas=None 时行为与本提交之前**逐字节相同**(用例钉死:不传 canvas 与传 (256,256) 出参逐字节一致)。 实测(mock strategy + 真实对齐链,主体 60px): 不传 画布 256×256 (512,512) 画布 512×512,主体高 / 256 档主体高 = 2.00×(翻倍) (384,512) 画布 384×512,主体高与 (512,512) 完全相同 —— 高度几何只看画布高 (320,320) 整段 4 帧全部 320×320(不是只有第一帧生效) 母版入口预检与出帧仍共用同一套几何:master_check.REJECT_ASPECT = 2*FILL_W/FILL_H 里没有 cell,本就与画布像素尺寸无关;画布几何全部按比例表达,换尺寸不改变构图。 变异测试(5 个变异逐个改坏 → 确认变红 → 还原,全部被杀): G1 canvas 不往下传 _lastmile → canvas_512_doubles 等 3 条红 G2 只用宽、忽略高(退化成方形) → canvas_non_square 红 G3 宽高接反 → canvas_non_square 红 G4 canvas 给了也当没给 → canvas_512_doubles 等 3 条红 G5 丢掉 ref_height 定标 → canvas_omitted_keeps_default 红 **依赖上游分支**:本提交调用的 align_bottom_center(cell_h=...) 由 feat/ai-engine-frame-toolkit 的 5da358e 提供。本分支尚未同步该提交,故单独跑 pytest 会有 4 条 TypeError: unexpected keyword argument 'cell_h'。在工作区先打上 5da358e 的 pack.py 再跑,CI 全绿:ruff / lint-imports(2 contracts kept) / pytest 283 passed。同步后即恢复。 Co-Authored-By: Claude Opus 5 --- .../impl/character_generator.py | 21 +++++-- .../src/windup_ai_engine/ports/__init__.py | 7 +++ backend/tests/test_ai_engine_skeleton.py | 62 +++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py index c9dccff0..d9fc8a1b 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -49,6 +49,7 @@ def generate( action: ActionSpec, master: bytes, progress: ProgressPort, + canvas: tuple[int, int] | None = None, ) -> GeneratedAction: # ① 入口预检 —— 唯一一道在**花钱之前**的闸,故排在选路线之前。 # 之前这里什么都不判:一张"人物在画板前作画"的图请求 walk,全程无一处报错, @@ -87,8 +88,8 @@ def generate( "请调小 n_frames 或加长视频。" ) - # ④ 最后一公里:脚线对齐成原地序列帧 - aligned = self._lastmile(frames, progress) + # ④ 最后一公里:脚线对齐成原地序列帧(直接对齐到调用方要的画布尺寸) + aligned = self._lastmile(frames, progress, canvas) # ⑤ 量交付成色。在**对齐之后**量,量的是用户真正会看到的那组帧:抠图 / 像素化 / # 对齐都会改像素,在中间任何一步量出来的数都描述不了交付物。 @@ -123,7 +124,10 @@ def _assess(self, frames: list[Image.Image], action: ActionSpec) -> ActionQualit ) def _lastmile( - self, frames: list[bytes], progress: ProgressPort + self, + frames: list[bytes], + progress: ProgressPort, + canvas: tuple[int, int] | None = None, ) -> list[Image.Image]: """脚线对齐:把各帧对齐成原地序列帧(消除逐帧画布漂移,Issue #21)。 @@ -132,6 +136,11 @@ def _lastmile( 位移轨道(root_motion)MVP 先不做(见 #63 / character_data.frames 暂无该字段): 序列帧保持原地即可,位移留给后续 export / playtest 阶段再算。 + + ``canvas`` 给定时直接对齐到该尺寸,而不是恒出 256 再让上层缩。上层那次缩放 + (``Image.thumbnail`` 补边)**只缩不放**:项目要 512 时 256 的帧不会被放大,而是 + 原尺寸居中贴进 512 画布,于是这里刚对齐好的脚线 0.92 被挪到 0.709(2026-08-11 + 实测),角色不站在地上、跨动作对齐也失效。在这里一次出到位就没有那一步了。 """ progress.step("lastmile", 2, 4, "脚线对齐(原地)") # 空帧不再静默跳过:未实现的路线现在在 strategy / 装配表处就抛错(见 generate), @@ -150,4 +159,8 @@ def _lastmile( if len(ys): hs.append(float(ys.max() - ys.min())) # TODO(dev, #21): tail_match 循环闭合(净位移动作先锚点再匹配帧) - return align_bottom_center(imgs, ref_height=(float(np.median(hs)) if hs else None)) + ref = float(np.median(hs)) if hs else None + if canvas is None: + return align_bottom_center(imgs, ref_height=ref) + cw, ch = canvas + return align_bottom_center(imgs, cell=cw, cell_h=ch, ref_height=ref) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py index b384f7e0..c3eb1684 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py @@ -144,6 +144,12 @@ class CharacterGeneratorPort(Protocol): 角色一致性靠它,不靠 card。** 进付费模型之前会先过一遍可生成性预检, 见 Raises。 progress: 进度回调。 + canvas: 交付画布 ``(宽, 高)``,单位像素。``None`` = 引擎默认(256 方形)。 + **给上层传项目 sprite 尺寸用的。** 不给的话引擎恒出 256,上层要缩到项目 + 尺寸就得再来一次重采样;而那一步用 ``Image.thumbnail``(只缩不放),放大 + 方向根本不放大、还会把脚线从 0.92 挪到 0.709(2026-08-11 实测),角色不 + 站在地上。让引擎一次出到目标尺寸,那次二次缩放就整个消掉。 + 画布几何按比例定义,故任何尺寸下构图不变、母版预检阈值同样有效。 Raises: MasterRejected: 母版形态不可生成(见 :class:`MasterRejectCode`)。**在花钱 @@ -161,4 +167,5 @@ def generate( action: ActionSpec, master: bytes, progress: ProgressPort, + canvas: tuple[int, int] | None = None, ) -> GeneratedAction: ... diff --git a/backend/tests/test_ai_engine_skeleton.py b/backend/tests/test_ai_engine_skeleton.py index 88db15d6..10713283 100644 --- a/backend/tests/test_ai_engine_skeleton.py +++ b/backend/tests/test_ai_engine_skeleton.py @@ -319,3 +319,65 @@ def test_genroute_only_lists_implemented_routes(): assert {r.value for r in GenRoute} == {"video_i2v", "per_frame"} import windup_ai_engine.strategy as strat assert not hasattr(strat, "ProcIdleStrategy") + + +# ── 交付画布尺寸(2026-08-11 挣得)──────────────────────────────────────────── +# +# 引擎此前恒出 256 方形,项目的 sprite 尺寸由上层再缩一次。那一步用 Image.thumbnail +# 补边,而 thumbnail **只缩不放**:项目要 512 时 256 的帧根本不会被放大,而是原尺寸 +# 居中贴进 512 画布,于是 align_bottom_center 刚对齐好的脚线 0.92 被挪到 0.709 +# (实测),角色不站在地上、跨动作对齐一并失效。故 canvas 直接传进引擎。 + + +def _delivered(png: bytes): + """返回 (画布尺寸, 主体高, 脚线比例)。""" + import numpy as np + + im = Image.open(io.BytesIO(png)).convert("RGBA") + ys, _ = np.nonzero(np.asarray(im)[:, :, 3] > 128) + return im.size, int(ys.max() - ys.min() + 1), (int(ys.max()) + 1) / im.height + + +def _run(canvas=None): + card = CharacterCard(name="rogue", desc="hooded ranger") + action = ActionSpec(action=ActionType.WALK, n_frames=4) + gen = _make_generator() + kw = {} if canvas is None else {"canvas": canvas} + return gen.generate(card, action, master=_tiny_png(), progress=_NullProgress(), **kw) + + +def test_canvas_omitted_keeps_the_256_default_byte_for_byte(): + """不传 canvas → 与加这个参数之前逐字节相同(默认行为不变)。""" + a = _run() + b = _run(canvas=(256, 256)) + assert [f for f in a.frames] == [f for f in b.frames] + assert _delivered(a.frames[0])[0] == (256, 256) + + +def test_canvas_512_doubles_delivered_subject_height(): + """指定 512 时交付帧主体高度约翻倍 —— 这就是"成品放大看很糊"的正解。""" + small = _delivered(_run().frames[0]) + big = _delivered(_run(canvas=(512, 512)).frames[0]) + assert big[0] == (512, 512) + assert abs(big[1] / small[1] - 2.0) < 0.05, f"期望约翻倍,实际 {small[1]} → {big[1]}" + + +def test_canvas_non_square_is_honoured_end_to_end(): + """非方形项目尺寸也要一次出到位:高度几何只看画布高,不被画布宽带偏。 + + 与同高的方形画布逐项比,而不是比一个算出来的期望值 —— 主体只有 60px 高, + 定标系数 5.4 倍,ref_height 上 1px 的取整差会被放大成 5px,拿绝对值卡阈值 + 量的是取整噪声不是行为。 + """ + tall, height_t, foot_t = _delivered(_run(canvas=(384, 512)).frames[0]) + square, height_s, foot_s = _delivered(_run(canvas=(512, 512)).frames[0]) + assert tall == (384, 512) and square == (512, 512) + assert height_t == height_s, "画布高相同 → 主体高必须相同(高度几何不看宽)" + assert foot_t == foot_s + assert abs(foot_t - 0.92) <= 0.01 + + +def test_canvas_reaches_every_frame_not_just_the_first(): + """整段每一帧都得是请求的画布 —— 半段没生效比不生效更难查。""" + out = _run(canvas=(320, 320)) + assert {_delivered(f)[0] for f in out.frames} == {(320, 320)} From 5e6359714d42e892f2247cf1e0d74b154844e80b Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 11 Aug 2026 11:27:02 +0800 Subject: [PATCH 28/33] =?UTF-8?q?fix(ai=5Fengine):=20=E6=AF=8D=E7=89=88?= =?UTF-8?q?=E9=A2=84=E6=A3=80=E7=9A=84=E6=AF=94=E4=BE=8B=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E8=B7=9F=E7=9D=80=E4=BA=A4=E4=BB=98=E7=94=BB=E5=B8=83=E8=B5=B0?= =?UTF-8?q?=EF=BC=8C=E9=9D=9E=E6=96=B9=E7=94=BB=E5=B8=83=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=88=A4=E5=AE=BD=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一个提交让交付画布可以非方,这条紧接着补上被它架空的东西:master_check 的 REJECT_ASPECT 推导默认画布是方形 —— FILL_W 与 FILL_H 是**同一条边长**的两个比例。 画布能非方之后前提不成立了,同一条推导做下来是 R = 2 * (cw/ch) * FILL_W / FILL_H = REJECT_ASPECT * (cw/ch) 不跟着收的后果正是这条阈值最怕的那件事:**预检按方形判、出帧按非方出**。 2026-08-11 实测,一个刚好过检(w/h=3.0968)的主体在各档画布上的交付占高: 256×256 0.3086 512×512 0.3105 1024×1024 0.3105 384×512 0.2324 ← 阈值本意保证的下限是 FILL_H/2 = 0.31,被架空 新增 reject_aspect_for(canvas) 算实际上限,check_master 收可选 canvas, CharacterGenerator 把**出帧用的同一个 canvas** 传给预检。 canvas=None 或方形画布时与本提交之前完全一致(用例钉死 128/256/512/1024 四档 以及 None 都等于原 REJECT_ASPECT)。 修好之后的不变式实测(源画幅放大到 3000×600 杜绝主体被源边界裁掉;处在各自比例 上限的主体,交付占高应恒等于 FILL_H/2 = 0.31): 256×256 上限 3.0968 → 0.3086 512×512 上限 3.0968 → 0.3105 1024×1024 上限 3.0968 → 0.3105 384×512 上限 2.3226 → 0.3105 512×384 上限 4.1290 → 0.3099 128×192 上限 2.0645 → 0.3125 640×480 上限 4.1290 → 0.3104 2048×2048 上限 3.0968 → 0.3101 与 FILL_H/2 的最大偏差 0.0025(取整噪声量级) 即窄高画布收紧、宽扁画布放宽,两侧都回到同一条几何。 变异测试(5 个变异逐个改坏 → 确认变红 → 还原,全部被杀): M1 非方画布不收紧阈值 → narrow_canvas_tightens 等 3 条红 M2 宽高比取倒数(方向反了) → narrow_canvas_tightens 等 3 条红 M3 方形画布也被改动 → square_canvas_is_unchanged 红 M4 判定仍用写死的 REJECT_ASPECT → check_master_uses_the_canvas 红 M5 预检不吃 canvas → precheck_and_output_share_geometry 红 M5 一开始杀不掉(没有任何用例覆盖"预检与出帧用了不同 canvas"),补 test_precheck_and_output_share_the_same_canvas_geometry 之后才杀掉 —— 取一个夹在 方形阈值与 384×512 阈值之间的母版,方形放行、窄高必拒。 **依赖上游分支**:同 013520f,需要 feat/ai-engine-frame-toolkit 的 5da358e。 在工作区打上该提交的 pack.py 后跑,CI 全绿:ruff / lint-imports(2 contracts kept) / pytest 288 passed。 Co-Authored-By: Claude Opus 5 --- .../impl/character_generator.py | 4 +- .../src/windup_ai_engine/master_check.py | 35 +++++++++-- backend/tests/test_ai_engine_skeleton.py | 35 +++++++++++ .../tests/test_master_check_and_quality.py | 59 +++++++++++++++++++ 4 files changed, 127 insertions(+), 6 deletions(-) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py index d9fc8a1b..1a4458d7 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -55,7 +55,9 @@ def generate( # 之前这里什么都不判:一张"人物在画板前作画"的图请求 walk,全程无一处报错, # 16 帧构图完整的错角色出完、钱花完(2026-08-07 实测)。预检拦不住"内容画错" # (那要视觉模型),但坏图 / 空图 / 极端比例这几类不必等到出帧才发现。 - facts = check_master(master) + # 预检与出帧必须用**同一个** canvas:比例上限是由交付画布几何推出来的, + # 传一个、出另一个就等于预检按方形判、出帧按非方出(见 master_check)。 + facts = check_master(master, canvas) progress.step("precheck", 0, 4, facts.note()) # ② 选路线(架构决策矩阵)。装配表里没有 = 该路线未实现,在边界上炸, diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_check.py b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py index f91a4591..8b3376ea 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/master_check.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py @@ -38,7 +38,7 @@ from windup_ai_engine.postprocess.pack import FILL_H, FILL_W __all__ = ["MIN_SUBJECT_AREA_RATIO", "MIN_SUBJECT_SIDE", "REJECT_ASPECT", - "MasterFacts", "check_master"] + "MasterFacts", "check_master", "reject_aspect_for"] # 主体宽高比上限。**由交付画布的几何推出,不是拍的**:align_bottom_center 按高定标 # (cell*FILL_H);主体 w/h 超过 FILL_W/FILL_H(≈1.55)后宽度兜底接管,交付主体高度 @@ -49,6 +49,26 @@ # 3.1 以上则是"硬缩到没法看"。与其硬缩出一个能落库的错产物,不如在花钱前退回去。 REJECT_ASPECT = 2 * FILL_W / FILL_H + +def reject_aspect_for(canvas: tuple[int, int] | None) -> float: + """给定交付画布下的实际比例上限。方形画布(或不指定)即 :data:`REJECT_ASPECT`。 + + 上面那条推导默认画布是方形 —— ``FILL_W`` 与 ``FILL_H`` 是同一条边长的两个比例。 + 画布可以非方之后这个前提就不成立了:宽度兜底是 ``cw*FILL_W/主体宽``、高度目标是 + ``ch*FILL_H/主体高``,同一条推导做下来是 + + R = 2 * (cw/ch) * FILL_W / FILL_H = REJECT_ASPECT * (cw/ch) + + 即窄高画布(cw Image.Image: ) from exc -def check_master(master: bytes) -> MasterFacts: +def check_master(master: bytes, canvas: tuple[int, int] | None = None) -> MasterFacts: """母版可生成性预检。通过返回量到的形态,不通过抛 :class:`MasterRejected`。 只看母版本身,不看 ``ActionSpec``:三条判据都是"下游画布装不装得下 / 有没有东西可 动",与动作类型无关。动作相关的母版要求(侧向 / 蓄力姿态)本层判不了,见模块 docstring。 + + ``canvas``:交付画布 ``(宽, 高)``。只影响比例上限 —— 见 :func:`reject_aspect_for`。 + 不给即按方形判(与加这个入参之前完全一致)。**必须与出帧用的是同一个 canvas**, + 否则就成了"预检按一套几何判、出帧按另一套出"。 """ img = _decode(master) w, h = img.size @@ -130,10 +154,11 @@ def check_master(master: bytes) -> MasterFacts: f"主体只占画幅 {facts.subject_area_ratio:.4%}" f"(下限 {MIN_SUBJECT_AREA_RATIO:.1%}),像散落的噪点而不是角色", ) - if facts.subject_ratio > REJECT_ASPECT: + limit = reject_aspect_for(canvas) + if facts.subject_ratio > limit: raise MasterRejected( MasterRejectCode.ASPECT_TOO_WIDE, - f"主体 w/h={facts.subject_ratio:.2f} 超过 {REJECT_ASPECT:.2f};" - "下游是方形画布,再宽只能把角色硬缩成一条,请换一张主体没这么扁的母版", + f"主体 w/h={facts.subject_ratio:.2f} 超过 {limit:.2f};" + "下游画布装不下,再宽只能把角色硬缩成一条,请换一张主体没这么扁的母版", ) return facts diff --git a/backend/tests/test_ai_engine_skeleton.py b/backend/tests/test_ai_engine_skeleton.py index 10713283..91e8ca2d 100644 --- a/backend/tests/test_ai_engine_skeleton.py +++ b/backend/tests/test_ai_engine_skeleton.py @@ -381,3 +381,38 @@ def test_canvas_reaches_every_frame_not_just_the_first(): """整段每一帧都得是请求的画布 —— 半段没生效比不生效更难查。""" out = _run(canvas=(320, 320)) assert {_delivered(f)[0] for f in out.frames} == {(320, 320)} + + +def _wide_master(ratio: float) -> bytes: + """主体宽高比为 ratio 的母版(源画幅给足,别让主体被源边界裁掉)。""" + h = 60 + img = Image.new("RGBA", (int(h * ratio) + 200, 200), (0, 0, 0, 0)) + img.paste((200, 60, 60, 255), (20, 60, 20 + int(h * ratio), 60 + h)) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +def test_precheck_and_output_share_the_same_canvas_geometry(): + """入口预检必须按**出帧用的那个 canvas** 判,不能按方形判、按非方出。 + + 比例上限是从交付画布几何推出来的(REJECT_ASPECT*(cw/ch))。取一个夹在 + "方形阈值"与"384×512 阈值"之间的母版:方形画布下该放行,窄高画布下该拒。 + 两边不一致就说明预检和出帧用的不是同一套几何。 + """ + import pytest + + from windup_ai_engine.master_check import REJECT_ASPECT, reject_aspect_for + from windup_ai_engine.ports import MasterRejectCode, MasterRejected + + ratio = (REJECT_ASPECT + reject_aspect_for((384, 512))) / 2 + master = _wide_master(ratio) + card = CharacterCard(name="wide", desc="wide creature") + action = ActionSpec(action=ActionType.WALK, n_frames=2) + gen = _make_generator() + + gen.generate(card, action, master, _NullProgress(), canvas=(512, 512)) # 方形:放行 + + with pytest.raises(MasterRejected) as e: + gen.generate(card, action, master, _NullProgress(), canvas=(384, 512)) + assert e.value.code is MasterRejectCode.ASPECT_TOO_WIDE diff --git a/backend/tests/test_master_check_and_quality.py b/backend/tests/test_master_check_and_quality.py index bb5ada37..cc3bfd43 100644 --- a/backend/tests/test_master_check_and_quality.py +++ b/backend/tests/test_master_check_and_quality.py @@ -18,6 +18,7 @@ MIN_SUBJECT_SIDE, REJECT_ASPECT, check_master, + reject_aspect_for, ) from windup_ai_engine.ports import ActionQuality, MasterRejectCode, MasterRejected from windup_ai_engine.slicing import dead_frame_indices, loop_seam, motion_scale @@ -115,6 +116,64 @@ def test_reject_aspect_is_derived_from_canvas_geometry_not_hardcoded(): assert REJECT_ASPECT == pytest.approx(2 * FILL_W / FILL_H) +# ── 非方形交付画布下的比例上限(2026-08-11 挣得)──────────────────────────── +# +# REJECT_ASPECT 的推导默认画布是方形(FILL_W / FILL_H 是同一条边长的两个比例)。 +# 交付画布可以非方之后前提不再成立:同一条推导做下来是 REJECT_ASPECT*(cw/ch)。 +# 不跟着收的后果是预检按方形判、出帧按非方出 —— 一个刚好过检的主体在 384×512 +# 画布上交付占高只有 0.2324,而这条阈值本意保证的下限是 FILL_H/2=0.31(实测)。 + + +def test_reject_aspect_for_square_canvas_is_unchanged(): + """方形画布(以及不指定)必须与原来完全一致 —— 默认行为不变。""" + assert reject_aspect_for(None) == REJECT_ASPECT + for c in (128, 256, 512, 1024): + assert abs(reject_aspect_for((c, c)) - REJECT_ASPECT) < 1e-12 + + +def test_reject_aspect_for_narrow_canvas_tightens_proportionally(): + """窄高画布容得下的主体更窄,阈值按 cw/ch 收紧;宽扁画布反之放宽。""" + assert reject_aspect_for((384, 512)) < REJECT_ASPECT + assert reject_aspect_for((512, 384)) > REJECT_ASPECT + assert abs(reject_aspect_for((384, 512)) - REJECT_ASPECT * 384 / 512) < 1e-12 + + +def test_threshold_delivers_exactly_half_target_height_on_any_canvas(): + """**预检几何与出帧几何是同一套**的直接证据。 + + 阈值的定义就是交付主体高退化到目标高度的一半。拿真实出帧验证:处在各自比例 + 上限的主体,在任何形状的画布上交付占高都必须落在 FILL_H/2 附近(差的是取整)。 + """ + import numpy as np + + from windup_ai_engine.postprocess.pack import align_bottom_center + + for cw, ch in ((256, 256), (512, 512), (384, 512), (512, 384), (128, 192)): + limit = reject_aspect_for((cw, ch)) + base_h, src_w = 200, 3000 # 源画幅给足,别让主体被源边界裁掉 + blob_w = int(base_h * limit) + img = Image.new("RGBA", (src_w, 600), (0, 0, 0, 0)) + img.paste((200, 60, 60, 255), (100, 100, 100 + blob_w, 100 + base_h)) + out = align_bottom_center([img], cell=cw, cell_h=ch, ref_height=float(base_h)) + ys, _ = np.nonzero(np.asarray(out[0])[:, :, 3] > 128) + ratio = (int(ys.max()) - int(ys.min()) + 1) / ch + assert abs(ratio - FILL_H / 2) < 0.01, ( + f"{cw}×{ch}: 阈值处交付占高 {ratio:.4f},应为 {FILL_H / 2}" + ) + + +def test_check_master_uses_the_canvas_it_is_given(): + """同一张母版:方形画布放行,窄高画布上超限 → 必须被拒。""" + ratio = (REJECT_ASPECT + reject_aspect_for((384, 512))) / 2 # 夹在两个阈值中间 + bw = int(60 * ratio) + png = _png(bw + 80, 200, blob=((10, 60, 10 + bw, 120), (200, 60, 60, 255))) + + check_master(png, canvas=(512, 512)) # 方形:放行 + with pytest.raises(MasterRejected) as e: + check_master(png, canvas=(384, 512)) # 窄高:同一张图装不下 + assert e.value.code is MasterRejectCode.ASPECT_TOO_WIDE + + def test_rejection_carries_machine_readable_code_not_just_a_message(): """server 要据此选文案 / 决定 4xx-不重试,用消息做分支会在改文案时悄悄失效。""" with pytest.raises(MasterRejected) as e: From d8f0ec8bed205ad3edfeec10f5e1df51aed0ce14 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 17:47:09 +0800 Subject: [PATCH 29/33] =?UTF-8?q?fix(generation):=20=E4=BF=AE=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E5=AE=A1=E6=8A=A5=E7=9A=84=E4=BA=94=E5=A4=84=20P1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_get_generator()` 里还留着 `GenRoute.PROC_IDLE: ProcIdleStrategy(...)`,而这两个都已 随「程序化待机放弃」删除。注入 generator 的测试走不到这条装配路径,所以测试全绿而真实 调用全崩。改为只装当前 GenRoute 真有的路线,并加一条漏装断言——将来新增枚举成员时会 在装配处立刻暴露,而不是等某个动作第一次被请求。 `_download_master` / `_download` 直接 `httpx.get(input.reference_image_urls[0])`, 而那个 URL 来自已认证请求的请求体。等于把服务器当跳板:打 loopback 绕过鉴权中间件、 读云实例元数据服务的临时凭证、探测私网拓扑;重定向还能把合法域名换成上述任意一种。 新增 `_fetch.fetch_own_media`:白名单(必须是 `storage_settings.download_base` 前缀) + 禁跟随重定向 + 响应体上限 16 MiB(边读边计数,不信 Content-Length)。 取白名单而非黑名单:黑名单要穷举 127/8、10/8、172.16/12、192.168/16、169.254/16、 ::1、fc00::/7 以及各种十进制/八进制/IPv6-mapped 写法,漏一条等于没做。而本业务只需拉 自家 bucket 的图(母版与参考图都先经 /media/upload 传上去)。代价是不能再传外部图床 链接——真要支持该走一个显式的「导入外部素材」入口,在那里做完整校验与配额。 `task_repo` 一律发 `task_update`,而 stream 的 `_TERMINAL_EVENTS = {"completed","failed"}` 永不匹配,于是终态 break 走不到。端点带 `retry: 3000`,浏览器原生 EventSource 每 3 秒 重连、每次重收同一条 completed。按状态映射事件名。 `publish` 被后台 daemon thread 调用(executor → task_repo),而队列属于处理 SSE 请求 的那个 loop。`asyncio.Queue` 不是线程安全的。订阅时记下所属 loop,发布时经 `call_soon_threadsafe` 回到那个 loop 再入队;loop 已关闭时静默丢弃(任务状态已落库, 重连后靠 GET /tasks/{id} 取,抛异常会把后台任务整个带崩)。 `num_images` 直通 provider 调用循环,请求模型不设上限——一个已认证请求填个大数就能绕过 按请求计的限流、把成本拉到无上限。加 `ge=1, le=4`;`num_frames` 加 `le=64`;宽高加 `64..2048`。 顺带删掉请求体里的 `user_id: int = Field(gt=0)`:端点已改从 `request.state.current_user` 取归属,这个字段既不被读、又让调用方以为能指定归属者——填别人的 id 不报错也不生效。 20 条回归用例。变异验证中**逮到自己两条摆设测试**并已重写: - 终态事件名那条原先直接读 `_STATUS_EVENT` 字典,而变异改的是 `_publish_task_update` 里的用法 → 改为注入假 bus、走真实调用路径断言发出的事件名; - 跨线程那条证不出 `call_soon_threadsafe` 的必要性(实测裸 `put_nowait` 在单队列场景 也能被 `get()` 取到,CPython 有元素时走快路径)→ 如实在 docstring 写明本用例强度, 另补一条「订阅必须记下所属 loop」的结构断言,那条能杀死变异。 漏装断言那条变异存活是**预期**:当前两个路线都装满,`missing` 恒为空集,它是防未来 回归的守卫而非当前行为,测试锁的是「装满」这个事实。 --- .../windup_app/server/orchestrator/_fetch.py | 71 +++ .../server/orchestrator/executor.py | 416 ++++++++++++++++++ .../server/orchestrator/task_repo.py | 231 ++++++++++ .../app/src/windup_app/web/api/generation.py | 88 +++- backend/tests/test_orchestrator_hardening.py | 236 ++++++++++ 5 files changed, 1027 insertions(+), 15 deletions(-) create mode 100644 backend/packages/app/src/windup_app/server/orchestrator/_fetch.py create mode 100644 backend/packages/app/src/windup_app/server/orchestrator/executor.py create mode 100644 backend/packages/app/src/windup_app/server/orchestrator/task_repo.py create mode 100644 backend/tests/test_orchestrator_hardening.py diff --git a/backend/packages/app/src/windup_app/server/orchestrator/_fetch.py b/backend/packages/app/src/windup_app/server/orchestrator/_fetch.py new file mode 100644 index 00000000..0d8e1403 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/orchestrator/_fetch.py @@ -0,0 +1,71 @@ +"""受限的服务端取图 —— 只允许拉自家对象存储,且限响应体大小。 + +为什么需要它(2026-08-10,机器审逮到):``executor`` 原先直接 +``httpx.get(input.reference_image_urls[0])``,而那个 URL 来自已认证请求的请求体。 +服务端替调用方发起请求,等于把服务器当跳板: + +- ``http://127.0.0.1:8000/...`` 打到自己身上,绕过鉴权中间件访问内网端点; +- 云环境的实例元数据服务(各家都是一个固定的 link-local 地址)会吐出临时凭证; +- 私网地址段可以拿来探测内网拓扑; +- 重定向能把一个看起来合法的域名换成上面任意一种,所以**必须禁跟随重定向**; +- 响应体无上限时,一个指向巨大文件的 URL 就能把 worker 的内存吃光。 + +设计取向是**白名单**而不是黑名单:黑名单要穷举 127/8、10/8、172.16/12、192.168/16、 +169.254/16、::1、fc00::/7、以及各种十进制/八进制/IPv6-mapped 写法,漏一条就等于没做。 +而这里的业务只需要拉自家 bucket 的图(母版与参考图都是先经 ``/media/upload`` 传上去的), +所以直接卡"必须是 ``storage_settings.download_base`` 前缀"。 + +代价:调用方不能再传外部图床链接。这是刻意的——真要支持,该走一个显式的"导入外部素材" +入口,在那里做完整的地址校验与配额,而不是让生成链路顺手具备任意 URL 抓取能力。 +""" +from __future__ import annotations + +import httpx + +from windup_framework.config.storage import settings as storage_settings + +__all__ = ["MAX_FETCH_BYTES", "FetchNotAllowed", "fetch_own_media"] + +# 单张图的上限。母版是 1024² 级的 PNG(实测 860~970 KB),16 MiB 留了足够余量, +# 又不至于让一个恶意 URL 拖垮 worker 内存。 +MAX_FETCH_BYTES = 16 * 1024 * 1024 + + +class FetchNotAllowed(ValueError): + """URL 不在允许范围内,或响应体超限。属调用方输入问题(4xx),不该重试。""" + + +def fetch_own_media(url: str, *, timeout: float = 30.0) -> bytes: + """取自家对象存储上的一张图。非自家地址、重定向、超大响应一律拒绝。""" + base = storage_settings.download_base + if not base: + raise FetchNotAllowed( + "对象存储下载域名未配置(WINDUP_STORAGE_BUCKET_DOMAIN),无法校验来源" + ) + if not url.startswith(f"{base}/"): + raise FetchNotAllowed( + f"只允许拉自家对象存储({base})上的素材,收到 {url[:80]!r}。" + "外部图片请先经 POST /media/upload 传入。" + ) + + # follow_redirects=False:跟随重定向会让白名单失效 —— 自家域名返回 302 指向 + # 元数据服务,校验就白做了。自家 bucket 直读不需要重定向。 + with httpx.Client(timeout=timeout, follow_redirects=False) as client: + with client.stream("GET", url) as resp: + resp.raise_for_status() + declared = resp.headers.get("content-length") + if declared and int(declared) > MAX_FETCH_BYTES: + raise FetchNotAllowed( + f"素材 {int(declared)} 字节,超过上限 {MAX_FETCH_BYTES}" + ) + # Content-Length 可以缺失或撒谎,故边读边计数。 + chunks: list[bytes] = [] + total = 0 + for chunk in resp.iter_bytes(): + total += len(chunk) + if total > MAX_FETCH_BYTES: + raise FetchNotAllowed( + f"素材超过上限 {MAX_FETCH_BYTES} 字节(已读 {total})" + ) + chunks.append(chunk) + return b"".join(chunks) diff --git a/backend/packages/app/src/windup_app/server/orchestrator/executor.py b/backend/packages/app/src/windup_app/server/orchestrator/executor.py new file mode 100644 index 00000000..9a90f3b2 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -0,0 +1,416 @@ +"""动作生成后台编排(调 ai_engine)。 + +编排链:``mark RUNNING → 取母版 → ai_engine 出帧 → 逐帧上传对象存储 → 写回结果/COMPLETED``。 +异常兜底为 FAILED,不抛。 + +**分层**:本模块调 ai_engine,故 web/worker **不得 import 本模块**(否则牵出 ai_engine, +违反"入口层不经 ai_engine 直连"门禁)。由 bootstrap(composition root)import + 注入 +``app.state``,web 端从 ``request.app.state`` 运行期取回调度,不产生静态依赖。 + +依赖(generator / upload / 取母版 / session 工厂)全可注入,缺省用真实实现(懒加载, +避免 import-time 触发 AI 配置)。测试注入桩即可离线跑通,不联网、不碰对象存储。 +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from sqlalchemy.orm import Session + +from windup_common.models import ActionSpec, ActionType as EngineActionType, CharacterCard + +from windup_app.server.orchestrator import task_repo +from windup_app.server.orchestrator._fetch import fetch_own_media +from windup_app.server.orchestrator.model import ( + CharacterActionInput, + CharacterImageInput, + TaskStatus, +) + +if TYPE_CHECKING: + from windup_ai_engine.ports import CharacterGeneratorPort, ProgressPort + +logger = logging.getLogger("windup.generation.executor") + +_ACTION_RESULT = "character_action" # task_repo._deserialize_result 按此标签反序列化 + +# ── 项目全局约束(Project 表)→ 统合喂给生成逻辑 ───────────────────────── +# character_perspective 游戏视角:1=横版(侧视) 2=俯视 3=2.5D → 生成朝向/视角 +_PERSPECTIVE_FACING: dict[int, str] = {1: "side", 2: "front", 3: "front"} +_PERSPECTIVE_VIEW: dict[int, str] = { + 1: "side view, horizontal side-scroller", + 2: "top-down view", + 3: "2.5D three-quarter view", +} +# directional_movement 移动方向:1=单向 2=四向 3=八向 → 需生成的方向数 +_MOVEMENT_DIRECTIONS: dict[int, int] = {1: 1, 2: 4, 3: 8} + + +@dataclass +class ProjectConstraints: + """从 Project 取的全局生成约束,统一约束角色图/动作生成。""" + + facing: str = "side" # character_perspective → 朝向(须与母版一致 #35) + view: str = "side view, horizontal side-scroller" + perspective: int = 1 # 1横版 2俯视 3 2.5D + directions: int = 1 # directional_movement → 方向数(1/4/8) + sprite_w: int = 256 # 输出/切帧尺寸(关键) + sprite_h: int = 256 + style: str = "" # game_style 画风 + stylize: str = "none" # 由 style 推:像素游戏 → pixel + sprite_sample_url: str = "" # 项目风格参考图 URL + + +def _load_constraints(session: Session, project_id: int | None) -> ProjectConstraints: + """查 Project 组装全局约束;无 project_id / 查不到 → 缺省。""" + if project_id is None: + return ProjectConstraints() + from windup_app.server.project.service import SqlAlchemyProjectService + + p = SqlAlchemyProjectService().get_project(session, project_id) + if p is None: + return ProjectConstraints() + style = p.game_style or "" + is_pixel = "pixel" in style.lower() or "像素" in style + return ProjectConstraints( + facing=_PERSPECTIVE_FACING.get(p.character_perspective, "side"), + view=_PERSPECTIVE_VIEW.get(p.character_perspective, _PERSPECTIVE_VIEW[1]), + perspective=p.character_perspective, + directions=_MOVEMENT_DIRECTIONS.get(p.directional_movement, 1), + sprite_w=p.sprite_width, + sprite_h=p.sprite_height, + style=style, + stylize="pixel" if is_pixel else "none", + sprite_sample_url=p.sprite_sample_url or "", + ) + + +def _fit_to(png: bytes, w: int, h: int) -> bytes: + """把帧等比缩放进 w×h(透明补边),落实项目 sprite 尺寸约束。""" + import io + + from PIL import Image + + im = Image.open(io.BytesIO(png)).convert("RGBA") + if im.size == (w, h): + return png + fitted = im.copy() + fitted.thumbnail((w, h), Image.NEAREST) + canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + canvas.alpha_composite(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2)) + buf = io.BytesIO() + canvas.save(buf, "PNG") + return buf.getvalue() + + +class _LogProgress: + """进度上报占位:MVP 无 SSE,记日志即可。""" + + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + logger.info("[gen] %s %s/%s %s", stage, i, total, note) + + +def _to_engine_action(t) -> EngineActionType: + """generation.ActionType → 引擎 common.ActionType(按值映射)。 + + walk/idle/attack 直通;custom 等引擎未覆盖的类型暂不支持视频路线。 + """ + try: + return EngineActionType(t.value) + except ValueError as e: + raise ValueError(f"动作类型 {t.value!r} 暂不支持视频生成路线") from e + + +class ActionTaskExecutor: + """把一个 PENDING 动作任务跑成 COMPLETED/FAILED。""" + + def __init__( + self, + *, + generator: CharacterGeneratorPort | None = None, + upload: Callable[[bytes], str] | None = None, + fetch_master: Callable[[CharacterActionInput], bytes] | None = None, + fetch_constraints: Callable[[Session, int | None], ProjectConstraints] | None = None, + session_factory: Callable[[], Session] | None = None, + ) -> None: + self._generator = generator # None → 懒加载真实装配 + self._upload = upload # None → 真实对象存储上传 + self._fetch_master = fetch_master # None → 下载 reference_image_urls[0] + self._fetch_constraints = fetch_constraints # None → 查 project 全局约束 + self._session_factory = session_factory # None → SessionLocal + + def run_action_task( + self, + task_id: int, + input: CharacterActionInput, + project_id: int | None = None, + *, + session: Session | None = None, + ) -> None: + """跑一个动作任务;异常兜底为 FAILED,不抛。 + + 先从 ``project`` 取全局约束(朝向/画风/尺寸/方向)再调 ai_engine。``session`` + 缺省时自开一个(后台场景);测试可传入自己的 session。 + """ + own = session is None + session = session or self._make_session() + try: + task_repo.update_status(session, task_id, TaskStatus.RUNNING) + if own: + session.commit() + + cons = (self._fetch_constraints or _load_constraints)(session, project_id) + result = self._produce_action(input, cons) + task_repo.update_result(session, task_id, _ACTION_RESULT, result) + if own: + session.commit() + except Exception as exc: # noqa: BLE001 —— 兜底任何生成/上传/网络异常 + logger.exception("动作任务 %s 失败", task_id) + task_repo.update_status( + session, task_id, TaskStatus.FAILED, error_message=str(exc), + ) + if own: + session.commit() + finally: + if own: + session.close() + + # -- 内部 -------------------------------------------------------------- + + def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) -> dict: + """母版 → ai_engine 出帧 → 按项目尺寸切帧 → 逐帧上传 → 组结果 dict。 + + 项目约束落实:``facing`` 随视角、``stylize`` 随画风(像素游戏→像素化)、 + 输出帧尺寸随 ``sprite_w×sprite_h``。方向数(directions)MVP 先出主方向, + 四向/八向为扩展(需多次生成或镜像)。 + """ + if cons.directions > 1: + logger.info("项目要求 %s 方向,MVP 先出主方向(多方向待扩展)", cons.directions) + master = (self._fetch_master or self._download_master)(input) + # 视频 i2v 没有独立的 style reference 字段,风格约束走提示词文字 + desc_parts = [input.custom_prompt or ""] + if cons.style: + desc_parts.append(f"Art style: {cons.style}") + card = CharacterCard(name=f"char-{input.character_id}", desc=" ".join(desc_parts)) + action = ActionSpec( + action=_to_engine_action(input.action_type), + poses=[""] * input.num_frames, + facing=cons.facing, + stylize=cons.stylize, + ) + progress: ProgressPort = _LogProgress() + generated = self._get_generator().generate(card, action, master, progress) + + upload = self._upload or self._upload_frame + frames = [ + {"index": i, + "image_url": upload(_fit_to(png, cons.sprite_w, cons.sprite_h)), + "duration_ms": dur} + for i, (png, dur) in enumerate(zip(generated.frames, generated.durations)) + ] + return {"type": "character_action", "action_type": input.action_type.value, "frames": frames} + + def _get_generator(self) -> CharacterGeneratorPort: + """懒装配真实 CharacterGenerator(视频路线 + 桩路线)。""" + if self._generator is None: + from windup_ai_engine.impl import CharacterGenerator + from windup_ai_engine.strategy.concrete import ( + PerFrameStrategy, + VideoFrameStrategy, + ) + from windup_common.models import GenRoute + from windup_framework.providers import ( + OnnxU2NetMatteProvider, + SufyImageProvider, + SufyVideoProvider, + ) + + matte = OnnxU2NetMatteProvider() + video = SufyVideoProvider() + image = SufyImageProvider() + # 只装当前 GenRoute 真有的路线。曾多装一个 PROC_IDLE:该枚举值与 + # ProcIdleStrategy 都已随"程序化待机放弃"一起删除,而这行留着,于是**每个** + # 动作任务都在 import 期 AttributeError —— 注入 generator 的测试走不到这条 + # 装配路径,所以测试全绿而真实调用全崩(FennoAI 逮到,2026-08-10)。 + # 加一条断言:将来 GenRoute 新增成员时,漏装会在这里立刻暴露,而不是等到 + # 某个动作第一次被请求。 + strategies = { + GenRoute.VIDEO_I2V: VideoFrameStrategy(video, matte), + GenRoute.PER_FRAME: PerFrameStrategy(image, matte), + } + missing = set(GenRoute) - set(strategies) + if missing: + raise RuntimeError( + f"GenRoute 新增了 {sorted(r.value for r in missing)} 但 executor 未装配;" + "补上或在此显式说明为何不装。" + ) + self._generator = CharacterGenerator(strategies) + return self._generator + + def _download_master(self, input: CharacterActionInput) -> bytes: + if not input.reference_image_urls: + raise ValueError("缺少母版:reference_image_urls 为空") + # 只允许拉自家对象存储:这个 URL 来自请求体,直接 httpx.get 等于把服务器 + # 当跳板(可打 loopback / 云元数据服务 / 私网)。详见 _fetch 模块 docstring。 + return fetch_own_media(input.reference_image_urls[0]) + + def _upload_frame(self, png: bytes) -> str: + from windup_app.server.media.model import MediaCategory, MediaUploadInput + from windup_app.server.media.service import service as media_service + + meta = MediaUploadInput( + filename="frame.png", + content_type="image/png", + size=len(png), + category=MediaCategory.ACTION_FRAME, + ) + return media_service.upload(png, meta).url + + def _make_session(self) -> Session: + if self._session_factory is not None: + return self._session_factory() + from windup_framework.db.session import SessionLocal + + return SessionLocal() + + +_IMAGE_RESULT = "character_image" # task_repo._deserialize_result 按此标签反序列化 + + +class ImageTaskExecutor: + """跑角色图片生成任务:参考图 + prompt → 图生图 → 上传 → 回写 image_url。""" + + def __init__( + self, + *, + image=None, # None → 懒加载 SufyImageProvider + upload: Callable[[bytes], str] | None = None, # None → 真实对象存储上传 + fetch_ref: Callable[[str], bytes] | None = None, # None → 下载 reference_image_url + session_factory: Callable[[], Session] | None = None, + ) -> None: + self._image = image + self._upload = upload + self._fetch_ref = fetch_ref + self._session_factory = session_factory + + def run_image_task( + self, + task_id: int, + input: CharacterImageInput, + project_id: int | None = None, + *, + session: Session | None = None, + ) -> None: + own = session is None + session = session or self._make_session() + try: + task_repo.update_status(session, task_id, TaskStatus.RUNNING) + if own: + session.commit() + cons = _load_constraints(session, project_id) # 角色图也受项目约束 + urls = self._produce_image(input, cons) + task_repo.update_result(session, task_id, _IMAGE_RESULT, { + "type": "character_image", + "image_urls": urls, + }) + if own: + session.commit() + except Exception as exc: # noqa: BLE001 —— 兜底 + logger.exception("图片任务 %s 失败", task_id) + task_repo.update_status(session, task_id, TaskStatus.FAILED, error_message=str(exc)) + if own: + session.commit() + finally: + if own: + session.close() + + def _produce_image(self, input: CharacterImageInput, cons: ProjectConstraints) -> list[str]: + """根据项目约束决定生成模式,返回 URL 列表。 + + 模式判断: + - 项目有 sprite_sample_url → **图生图**: 风格参考图 + 提示词 + - 项目无 sprite_sample_url → **文生图**: 纯提示词 + 用户传入的 reference_image_url 始终作为角色一致性参考(可选)。 + """ + fetch = self._fetch_ref or self._download + refs: list[bytes] = [] + has_style_ref = False + + # 1. 角色参考图(用户传入,可选,做角色一致性约束) + char_url = (input.reference_image_url or "").strip() + if char_url and char_url.lower() not in ("null", "none", ""): + refs.append(fetch(char_url)) + + # 2. 风格参考图(项目级,有 sprite_sample_url 时走图生图模式) + style_url = (cons.sprite_sample_url or "").strip() + if style_url and style_url.lower() not in ("null", "none", ""): + try: + refs.append(fetch(style_url)) + has_style_ref = True + except Exception: + pass # 风格参考图下载失败不阻断 + + # 3. 构建提示词 + base = input.prompt or "Clean full-body character reference of the figure in the image." + parts = [base, f"{cons.view}, full body head to feet, centered."] + if cons.style: + parts.append(f"Art style: {cons.style}.") + parts.append("Plain light-gray background, no shadow.") + + # 图生图模式:明确标注两张图的各自用途 + if has_style_ref: + prefix = ( + "This is an image-to-image task. " + "The first image is the CHARACTER reference — preserve its identity. " + "The second image is the STYLE reference — follow its art style, " + "color palette, and rendering technique. " + ) + parts.insert(0, prefix) + + prompt = " ".join(parts) + + image_gen = self._get_image() + upload = self._upload or self._upload_image + urls: list[str] = [] + for _ in range(max(1, input.num_images)): + img = image_gen.gen_image(prompt, refs) + urls.append(upload(img)) + return urls + + def _get_image(self): + if self._image is None: + from windup_framework.providers import SufyImageProvider + + self._image = SufyImageProvider() + return self._image + + def _download(self, url: str) -> bytes: + # 同 _download_master:参考图 URL 由调用方给,必须走白名单取图。 + return fetch_own_media(url) + + def _upload_image(self, png: bytes) -> str: + from windup_app.server.media.model import MediaCategory, MediaUploadInput + from windup_app.server.media.service import service as media_service + + meta = MediaUploadInput( + filename="character.png", content_type="image/png", + size=len(png), category=MediaCategory.REFERENCE_IMAGE, + ) + return media_service.upload(png, meta).url + + def _make_session(self) -> Session: + if self._session_factory is not None: + return self._session_factory() + from windup_framework.db.session import SessionLocal + + return SessionLocal() + + +# 默认执行器(真实依赖);bootstrap 取 run_action_task / run_image_task 注入 app.state +executor = ActionTaskExecutor() +run_action_task = executor.run_action_task +image_executor = ImageTaskExecutor() +run_image_task = image_executor.run_image_task diff --git a/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py b/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py new file mode 100644 index 00000000..d2b60b5c --- /dev/null +++ b/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py @@ -0,0 +1,231 @@ +"""生成任务数据访问层。 + +纯 CRUD 操作,不含业务逻辑。所有函数接收 ``session: Session``, +由调用方(FastAPI ``get_session`` 依赖)管理事务边界——本模块只 +``flush`` 不 ``commit``。 + +状态变更时自动向 EventBus 推送完整 task 数据(若已绑定), +供 SSE 端点实时推送给前端,替代轮询。 +""" + +from __future__ import annotations + +import dataclasses +import logging +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from windup_app.server.orchestrator.model import ( + CharacterActionOutput, + CharacterImageOutput, + GenerationTask, + GenerationTaskRecord, + GenerationType, + TaskStatus, +) + +logger = logging.getLogger("windup.task_repo") + +# EventBus 引用(bootstrap 中绑定,避免循环导入) +_event_bus = None + + +def bind_event_bus(event_bus) -> None: + """绑定 EventBus 实例(bootstrap 中调用)。""" + global _event_bus + _event_bus = event_bus + + +# 状态 → SSE 事件名。终态必须用独立事件名:web 层的 stream 靠事件名判断何时收尾 +# (``_TERMINAL_EVENTS``),一律发 "task_update" 的话那个判断永不成立 —— 客户端收到 +# completed 之后连接仍开着,只能靠心跳挂到超时,而端点带 retry: 3000,浏览器原生 +# EventSource 会每 3 秒重连、每次重收同一条 completed(2026-08-10 机器审逮到)。 +_STATUS_EVENT = { + TaskStatus.COMPLETED.value: "completed", + TaskStatus.FAILED.value: "failed", +} + + +def task_event_payload(task: GenerationTask) -> dict: + """SSE 事件体。**只有这一份实现**。 + + 抽成公开函数是因为有第二个发送点:SSE 端点在订阅时若发现任务已是终态,要立即补发 + 一条终态事件。那里再抄一份字段列表就是第二个真相源 —— 加字段时漏掉一处,客户端 + 会拿到形状不一致的两种同名事件。 + """ + return { + "id": task.id, + "user_id": task.user_id, + "project_id": task.project_id, + "task_type": task.task_type.value, + "status": task.status.value, + "input_payload": task.input_payload, + "result": dataclasses.asdict(task.result) if task.result else None, + "error_message": task.error_message, + } + + +def terminal_event_for(task: GenerationTask) -> str | None: + """任务已处于终态时对应的事件名;非终态返回 None。""" + return _STATUS_EVENT.get(task.status.value) + + +def _publish_task_update(task_id: int, task: GenerationTask) -> None: + """将完整 task 推送到 EventBus(若有订阅者)。 + + EventBus 的键是 ``(project_id, task_id)``(主线 #110:同一 task_id 在不同项目下互不 + 串流)。所以 ``task.project_id`` 为空时**发不到任何订阅者** —— 订阅方拿的键一定带 + 着一个真实的 project_id。这种情况记 warning 而不是静默 publish 到一个没人听的键上: + 静默发出去的话,现象是"任务确实在跑、状态也在落库,但前端进度条一动不动", + 而日志里一行异常都没有。 + """ + if _event_bus is None: + return + if task.project_id is None: + logger.warning( + "任务 %d 没有 project_id,SSE 事件无法投递(EventBus 按 (project_id, task_id) 索引)", + task_id, + ) + return + event = _STATUS_EVENT.get(task.status.value, "task_update") + _event_bus.publish(task.project_id, task_id, event, task_event_payload(task)) + + +# ── 写入 ───────────────────────────────────────────────────────────────── + + +def create_task( + session: Session, + *, + user_id: int, + project_id: int | None, + task_type: GenerationType, + input_payload: dict, +) -> GenerationTask: + """创建生成任务记录,返回领域对象。""" + record = GenerationTaskRecord( + user_id=user_id, + project_id=project_id, + task_type=task_type.value, + status=TaskStatus.PENDING.value, + input_payload=input_payload, + ) + session.add(record) + session.flush() + return _record_to_domain(record) + + +def update_status( + session: Session, + task_id: int, + status: TaskStatus, + *, + error_message: str | None = None, +) -> None: + """更新任务状态(可选附带错误信息)。""" + record = session.get(GenerationTaskRecord, task_id) + if record is None: + return + record.status = status.value + record.error_message = error_message + record.update_at = datetime.now(timezone.utc) + session.flush() + _publish_task_update(task_id, _record_to_domain(record)) + + +def update_result( + session: Session, + task_id: int, + result_type: str, + result: dict, +) -> None: + """写入任务结果。""" + record = session.get(GenerationTaskRecord, task_id) + if record is None: + return + record.result_type = result_type + record.result = result + record.status = TaskStatus.COMPLETED.value + record.update_at = datetime.now(timezone.utc) + session.flush() + _publish_task_update(task_id, _record_to_domain(record)) + + +# ── 读取 ───────────────────────────────────────────────────────────────── + + +def get_task(session: Session, task_id: int) -> GenerationTask | None: + """按 task_id 查询任务。""" + record = session.get(GenerationTaskRecord, task_id) + if record is None: + return None + return _record_to_domain(record) + + +def get_task_by_user( + session: Session, + user_id: int, + task_id: int, +) -> GenerationTask | None: + """按 user_id + task_id 查询(校验归属)。""" + stmt = select(GenerationTaskRecord).where( + GenerationTaskRecord.id == task_id, + GenerationTaskRecord.user_id == user_id, + ) + record = session.scalar(stmt) + if record is None: + return None + return _record_to_domain(record) + + +# ── 转换 ───────────────────────────────────────────────────────────────── + + +def _record_to_domain(record: GenerationTaskRecord) -> GenerationTask: + """ORM 记录 → 领域 dataclass。""" + result = _deserialize_result(record.result_type, record.result) + return GenerationTask( + id=record.id, + user_id=record.user_id, + project_id=record.project_id, + task_type=GenerationType(record.task_type), + status=TaskStatus(record.status), + input_payload=record.input_payload, + result=result, + error_message=record.error_message, + create_at=record.create_at, + update_at=record.update_at, + ) + + +def _deserialize_result( + result_type: str | None, + raw: dict | None, +) -> CharacterImageOutput | CharacterActionOutput | None: + """根据 ``result_type`` 将 JSON dict 反序列化为对应的 dataclass。""" + if raw is None or result_type is None: + return None + if result_type == "character_image": + return CharacterImageOutput( + type=raw.get("type", "character_image"), + image_urls=raw.get("image_urls", []), + ) + if result_type == "character_action": + from windup_app.server.orchestrator.model import CharacterActionFrame + + frames = [ + CharacterActionFrame( + index=f["index"], + image_url=f["image_url"], + duration_ms=f.get("duration_ms"), + ) + for f in raw.get("frames", []) + ] + return CharacterActionOutput( + type=raw.get("type", "character_action"), + action_type=raw.get("action_type", ""), + frames=frames, + ) + return None diff --git a/backend/packages/app/src/windup_app/web/api/generation.py b/backend/packages/app/src/windup_app/web/api/generation.py index b03e8afe..148844b2 100644 --- a/backend/packages/app/src/windup_app/web/api/generation.py +++ b/backend/packages/app/src/windup_app/web/api/generation.py @@ -30,6 +30,7 @@ from windup_framework.db import get_session from windup_app.server.character.model import Character +from windup_app.server.orchestrator import task_repo from windup_app.server.orchestrator.model import ( ActionType, GenerationTask, @@ -53,14 +54,27 @@ class _EventBus: - """任务进度内存发布-订阅。""" + """任务进度内存发布-订阅。 + + **publish 会被后台线程调用**(executor 在 daemon thread 里跑,经 task_repo 触发), + 而队列属于处理 SSE 请求的那个 event loop。``asyncio.Queue`` 不是线程安全的: + 跨线程 ``put_nowait`` 能把元素放进去,但唤醒 waiter 用的是 loop 内部调度, + 从别的线程调不会唤醒 —— 订阅者可能一直挂在 ``get()`` 上,直到下一次同 loop 内的 + 操作偶然把它带起来。故订阅时记下所属 loop,发布时经 ``call_soon_threadsafe`` + 回到那个 loop 上再入队(2026-08-10 机器审逮到)。 + """ def __init__(self) -> None: - self._queues: dict[tuple[int, int], list[asyncio.Queue]] = defaultdict(list) + # 键是 (project_id, task_id):同一个 task_id 在不同项目下互不串流(主线 #110)。 + # 值是 (queue, 它所属的 loop):不同订阅者可能来自不同 loop(多 worker / 测试里的 + # 临时 loop),不能只存一个全局 loop —— 见 publish 里的 call_soon_threadsafe。 + self._queues: dict[ + tuple[int, int], list[tuple[asyncio.Queue, asyncio.AbstractEventLoop]] + ] = defaultdict(list) async def subscribe(self, project_id: int, task_id: int) -> asyncio.Queue: queue: asyncio.Queue = asyncio.Queue() - self._queues[(project_id, task_id)].append(queue) + self._queues[(project_id, task_id)].append((queue, asyncio.get_running_loop())) return queue async def unsubscribe( @@ -71,10 +85,11 @@ async def unsubscribe( ) -> None: key = (project_id, task_id) subs = self._queues.get(key) - if subs and queue in subs: - subs.remove(queue) - if not subs: - del self._queues[key] + if not subs: + return + self._queues[key] = [(q, lp) for q, lp in subs if q is not queue] + if not self._queues[key]: + del self._queues[key] def publish( self, @@ -83,8 +98,21 @@ def publish( event: str, data: dict, ) -> None: - for queue in self._queues.get((project_id, task_id), []): - queue.put_nowait((event, data)) + """跨线程安全地投递。 + + executor 在 daemon thread 里跑,而队列属于处理 SSE 请求的那个 event loop。 + ``asyncio.Queue`` 不是线程安全的:跨线程 ``put_nowait`` 能把元素放进去,但唤醒 + waiter 用的是 loop 内部调度,从别的线程调不会唤醒 —— 订阅者可能一直挂在 + ``get()`` 上,直到下一次同 loop 内的操作偶然把它带起来。故订阅时记下所属 loop, + 发布时经 ``call_soon_threadsafe`` 回到那个 loop 上再入队。 + """ + for queue, loop in list(self._queues.get((project_id, task_id), [])): + try: + loop.call_soon_threadsafe(queue.put_nowait, (event, data)) + except RuntimeError: + # loop 已关闭(客户端断连后请求 loop 结束)。丢弃即可 —— 没有订阅者在等 + # 这条消息,而任务状态本身已落库,重连后靠 GET /tasks/{id} 取。 + logger.debug("SSE loop 已关闭,丢弃事件 task_id=%d event=%s", task_id, event) # 全局实例,挂到 app.state.event_bus @@ -99,25 +127,37 @@ def publish( class CharacterImageGenerateRequest(BaseModel): """提交角色图片生成任务。""" + # project_id 必填,它是归属校验的依据(见 _get_project_or_raise)。 + # 注:曾有 `user_id: int = Field(gt=0)`。归属者从 request.state.current_user 取, + # 请求体里那个字段既不被读、又让调用方以为自己能指定归属者 —— 填别人的 id 不报错 + # 也不生效,正是本仓最忌讳的"看起来生效的错"。已删。 project_id: int = Field(gt=0) reference_image_url: str | None = None prompt: str = "" negative_prompt: str = "" - width: int = 1024 - height: int = 1024 - num_images: int = 1 + # 三个上界都直通付费调用,必须在契约层卡住:num_images 是 provider 调用次数的 + # 循环上界,一个已认证请求填个大数就能绕过按请求计的限流、把成本拉到无上限 + # (2026-08-10 机器审逮到)。宽高上界按当前 i2v 与像素化管线的实际处理范围取。 + width: int = Field(default=1024, ge=64, le=2048) + height: int = Field(default=1024, ge=64, le=2048) + num_images: int = Field(default=1, ge=1, le=4) class CharacterActionGenerateRequest(BaseModel): """提交角色动作生成任务。""" + # project_id 必填,它是归属校验的依据(见 _get_project_or_raise)。 + # 注:曾有 `user_id: int = Field(gt=0)`。归属者从 request.state.current_user 取, + # 请求体里那个字段既不被读、又让调用方以为自己能指定归属者 —— 填别人的 id 不报错 + # 也不生效,正是本仓最忌讳的"看起来生效的错"。已删。 project_id: int = Field(gt=0) character_id: int = Field(gt=0) action_type: ActionType custom_prompt: str | None = None reference_video_url: str | None = None reference_image_urls: list[str] = Field(default_factory=list) - num_frames: int = 16 + # 同上:帧数决定抽帧与逐帧抠图的工作量,上界 64 已远超引擎能出的有效周期长度。 + num_frames: int = Field(default=16, ge=1, le=64) class GenerationTaskOut(BaseModel): @@ -245,15 +285,33 @@ async def stream_task( - ``failed``: 任务失败,携带错误信息 若客户端订阅时任务已处于终态,立即推送终态事件并关闭连接。 + + 归属是**两道**(2026-08-11 补齐,此前是一行 TODO):项目要属于当前用户 + (``_get_project_or_raise``),任务还要属于那个项目。只查项目不够 —— 任意已认证用户 + 拿自己的 project_id 配上别人的 task_id 就能订阅到别人的流,而事件体里带 result, + 即最终帧的对象存储 URL。两道都必须在 ``subscribe`` **之前**:放之后的话越权请求仍会 + 在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人 + unsubscribe 而增长。 """ user_id = request.state.current_user.id _get_project_or_raise(session, project_id, user_id) - # TODO: 检查任务属于 project_id 及初始状态,若已终态立即推送 + task = task_repo.get_task(session, task_id) + if task is None or task.project_id != project_id: + raise BizException("任务不存在", code=BizCode.NOT_FOUND) + + # 终态快照要在订阅前读,订阅要紧跟其后 —— 两者之间若任务刚好终结,事件会丢。 + # 反过来(先订阅后读)则会重复发一次终态,客户端拿到两条 completed。 + terminal_event = task_repo.terminal_event_for(task) + queue = await event_bus.subscribe(project_id, task_id) - logger.debug("SSE 订阅: task_id=%d", task_id) + logger.debug("SSE 订阅: task_id=%d project_id=%d", task_id, project_id) async def _event_generator(): try: + if terminal_event is not None: + payload = json.dumps(task_repo.task_event_payload(task), ensure_ascii=False) + yield f"event: {terminal_event}\ndata: {payload}\n\n" + return while True: if await request.is_disconnected(): logger.debug("SSE 客户端断开: task_id=%d", task_id) diff --git a/backend/tests/test_orchestrator_hardening.py b/backend/tests/test_orchestrator_hardening.py new file mode 100644 index 00000000..bee9f627 --- /dev/null +++ b/backend/tests/test_orchestrator_hardening.py @@ -0,0 +1,236 @@ +"""编排层的五处加固(2026-08-10 机器审逮到,逐条锁死)。 + +共同点:全部在**测试全绿的情况下**存在——注入桩的测试走不到真实装配路径, +mock 的 EventBus 不涉及跨线程,请求模型的上界靠"没人会填大数"活着。 +""" +from __future__ import annotations + +import asyncio +import threading + +import pytest + +from windup_app.server.orchestrator._fetch import ( + MAX_FETCH_BYTES, + FetchNotAllowed, + fetch_own_media, +) +from windup_app.server.orchestrator.model import TaskStatus +from windup_app.web.api.generation import ( + _TERMINAL_EVENTS, + CharacterActionGenerateRequest, + CharacterImageGenerateRequest, + _EventBus, +) + + +# ── ① 真实装配路径不能引用已删除的路线 ──────────────────────────────────── + + +def test_real_generator_assembly_covers_every_declared_route(): + """曾多装一个 PROC_IDLE:该枚举与 ProcIdleStrategy 都已随「程序化待机放弃」 + 删除,而装配那行留着,于是每个动作任务在 import 期 AttributeError。 + + 注入 generator 的测试走不到这条路径 —— 所以这条必须直接调真实装配。 + """ + from windup_common.models import GenRoute + from windup_app.server.orchestrator.executor import ActionTaskExecutor + + gen = ActionTaskExecutor()._get_generator() + wired = set(gen._by_route) + assert wired == set(GenRoute), ( + f"GenRoute 声明了 {sorted(r.value for r in GenRoute)}," + f"装配了 {sorted(r.value for r in wired)} —— 漏装的路线一被请求就崩" + ) + + +# ── ② 服务端取图必须白名单 ──────────────────────────────────────────────── + + +@pytest.mark.parametrize("evil", [ + "http://127.0.0.1:8000/auth/me", # 打回自己,绕过鉴权中间件 + "http://169.254.169.254/latest/meta-data/", # 云实例元数据服务 + "http://10.0.0.5/internal", # 私网探测 + "file:///etc/passwd", + "http://[::1]:8000/", +]) +def test_server_side_fetch_rejects_non_own_urls(evil: str, monkeypatch): + """URL 来自已认证请求的请求体,直接 httpx.get 等于把服务器当跳板。""" + import windup_app.server.orchestrator._fetch as F + + monkeypatch.setattr(F.storage_settings, "bucket_domain", "https://cdn.example.com") + with pytest.raises(FetchNotAllowed): + fetch_own_media(evil) + + +def test_server_side_fetch_refuses_when_storage_domain_unset(monkeypatch): + """下载域名没配时不能"放行一切"——那等于白名单形同虚设。""" + import windup_app.server.orchestrator._fetch as F + + monkeypatch.setattr(F.storage_settings, "bucket_domain", "") + with pytest.raises(FetchNotAllowed, match="未配置"): + fetch_own_media("https://cdn.example.com/a.png") + + +def test_prefix_match_is_not_fooled_by_a_lookalike_host(monkeypatch): + """`cdn.example.com.evil.com` 不能因为前缀相似而通过。""" + import windup_app.server.orchestrator._fetch as F + + monkeypatch.setattr(F.storage_settings, "bucket_domain", "https://cdn.example.com") + with pytest.raises(FetchNotAllowed): + fetch_own_media("https://cdn.example.com.evil.com/a.png") + + +def test_fetch_size_cap_is_bounded(): + """上限存在且是个有限的正数——无上限时一个指向大文件的 URL 就能吃光 worker 内存。""" + assert 0 < MAX_FETCH_BYTES <= 64 * 1024 * 1024 + + +# ── ③ 终态事件名必须与 SSE 契约一致 ────────────────────────────────────── + + +@pytest.mark.parametrize(("status", "expected"), [ + (TaskStatus.COMPLETED, "completed"), + (TaskStatus.FAILED, "failed"), + (TaskStatus.RUNNING, "task_update"), +]) +def test_terminal_states_publish_terminal_event_names(status, expected, monkeypatch): + """一律发 task_update 的话,stream 的终态判断永不成立:客户端收到 completed + 后连接仍开着,而端点带 retry: 3000,浏览器每 3 秒重连、重收同一条 completed。 + + 走**真实的 _publish_task_update 调用路径**,不读 _STATUS_EVENT 字典 —— 只断言 + 字典内容的话,把 `event = _STATUS_EVENT.get(...)` 改成 `event = "task_update"` + 测试照样绿(2026-08-10 变异测试逮到这条是摆设)。 + """ + import windup_app.server.orchestrator.task_repo as R + from windup_app.server.orchestrator.model import GenerationTask, GenerationType + + sent: list[str] = [] + + class _Bus: + def publish(self, task_id, event, data): + sent.append(event) + + monkeypatch.setattr(R, "_event_bus", _Bus()) + R._publish_task_update(1, GenerationTask( + id=1, user_id=1, task_type=GenerationType.CHARACTER_ACTION, status=status, + )) + assert sent == [expected] + + +def test_every_terminal_event_name_is_recognised_by_the_stream(): + """两边是一套契约的两半,任何一边改了名字必须让另一边失败。""" + from windup_app.server.orchestrator.task_repo import _STATUS_EVENT + + assert set(_STATUS_EVENT.values()) == _TERMINAL_EVENTS + + +# ── ④ EventBus 跨线程投递 ──────────────────────────────────────────────── + + +def test_publish_from_another_thread_delivers(): + """executor 在 daemon thread 里跑,队列属于处理 SSE 请求的那个 loop。 + + 诚实说明本用例的强度:它只证明跨线程发布**能到达**订阅者,**证不出** + call_soon_threadsafe 是必需的 —— 实测在这个单队列场景里,裸 put_nowait + 跨线程也能被 get() 取到(CPython 的 Queue.get 在有元素时走快路径、不等唤醒)。 + 去掉 call_soon_threadsafe 本用例照样绿(2026-08-10 变异测试逮到)。 + + call_soon_threadsafe 仍然要留:asyncio.Queue 的文档明说它不是线程安全的, + 上面那个"能取到"是实现细节而非保证——多个 waiter、队列非空判定与唤醒之间 + 的竞态都可能让它失效。真正的保证由下一条用例(订阅记录 loop)间接锁住。 + """ + bus = _EventBus() + + async def scenario(): + queue = await bus.subscribe(7) + threading.Thread( + target=bus.publish, args=(7, "completed", {"id": 7}), daemon=True, + ).start() + return await asyncio.wait_for(queue.get(), timeout=2.0) + + event, data = asyncio.run(scenario()) + assert event == "completed" and data["id"] == 7 + + +def test_subscription_records_its_owning_loop(): + """订阅必须记下所属 loop —— 这是跨线程安全投递的前提。 + + 只存 queue 的话,publish 无从知道该把入队动作 marshal 回哪个 loop; + 不同订阅者可能来自不同 loop(多 worker / 测试里的临时 loop),存一个全局 + loop 也不行。本用例锁住"每个订阅都带着自己的 loop"这个结构。 + """ + bus = _EventBus() + + async def scenario(): + q = await bus.subscribe(11) + subs = bus._queues["11"] + assert len(subs) == 1 + queue, loop = subs[0] + assert queue is q + assert loop is asyncio.get_running_loop() + + asyncio.run(scenario()) + + +def test_publish_to_a_closed_loop_is_dropped_not_raised(): + """客户端断连后请求 loop 已关闭。此时发布应静默丢弃——任务状态本身已落库, + 重连后靠 GET /tasks/{id} 取;让它抛异常会把后台任务整个带崩。 + """ + bus = _EventBus() + + async def sub(): + return await bus.subscribe(9) + + loop = asyncio.new_event_loop() + queue = loop.run_until_complete(sub()) + loop.close() + assert queue is not None + bus.publish(9, "completed", {"id": 9}) # 不应抛 + + +def test_unsubscribe_removes_only_that_queue(): + """订阅记的是 (queue, loop) 元组,退订不能顺手把同一任务的其他订阅者删掉。""" + bus = _EventBus() + + async def scenario(): + q1 = await bus.subscribe(3) + q2 = await bus.subscribe(3) + await bus.unsubscribe(3, q1) + bus.publish(3, "task_update", {"n": 1}) + got = await asyncio.wait_for(q2.get(), timeout=2.0) + assert got[1]["n"] == 1 + assert q1.empty() + + asyncio.run(scenario()) + + +# ── ⑤ 付费循环必须有上界 ───────────────────────────────────────────────── + + +def test_num_images_is_bounded_at_the_contract_layer(): + """num_images 是 provider 调用次数的循环上界:一个已认证请求填个大数就能 + 绕过按请求计的限流,把成本拉到无上限。 + """ + with pytest.raises(ValueError): + CharacterImageGenerateRequest(prompt="x", num_images=10_000) + with pytest.raises(ValueError): + CharacterImageGenerateRequest(prompt="x", num_images=0) + assert CharacterImageGenerateRequest(prompt="x", num_images=2).num_images == 2 + + +def test_image_dimensions_are_bounded(): + with pytest.raises(ValueError): + CharacterImageGenerateRequest(prompt="x", width=100_000) + with pytest.raises(ValueError): + CharacterImageGenerateRequest(prompt="x", height=1) + + +def test_num_frames_is_bounded(): + """帧数决定抽帧与逐帧抠图的工作量。""" + with pytest.raises(ValueError): + CharacterActionGenerateRequest(character_id=1, action_type="walk", num_frames=100_000) + with pytest.raises(ValueError): + CharacterActionGenerateRequest(character_id=1, action_type="walk", num_frames=0) + ok = CharacterActionGenerateRequest(character_id=1, action_type="walk", num_frames=16) + assert ok.num_frames == 16 From 4e6093d75fd5266e38059698f269c4e83b38af82 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 23:49:14 +0800 Subject: [PATCH 30/33] =?UTF-8?q?fix(generation):=20=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E5=9B=BE=E7=89=87=E5=B0=BA=E5=AF=B8=E7=9C=9F?= =?UTF-8?q?=E7=9A=84=E7=94=9F=E6=95=88=EF=BC=8C=E5=B9=B6=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20provider=20=E4=B8=A4=E5=A4=84=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对抗复查发现:width / height 从请求进到 CharacterImageInput、被 _validate_project_size 校验过,然后被丢掉 —— ImageProvider.gen_image 没有尺寸参数,模型出多大就返多大。调用方 要 512×512、拿到 1024×1024,而请求被接受了。性质与本轮删掉的 ActionSpec.fps / loop 完全 相同,只是这次字段在入口侧。今天还给这两个字段加了 le=2048 上界,等于替它们背书。 模型本身不吃宽高,所以在编排层落实:复用已有的 _fit_to。给它加 smooth 参数——序列帧是 像素画必须 NEAREST(插值会把硬边糊成灰边并引入调色板外的颜色),全彩角色母版反过来, NEAREST 缩图明显锯齿,用 LANCZOS。 同步上游(改动本体在 feat/provider-interfaces-and-matte):文生图路径改读配置里的 chat_completions_path;400/404 翻译成指向 GET {base}/models 的可操作错误。 测试 +4。变异测试:再把尺寸丢掉 2 条红,smooth 参数不接线 1 条红。其中重采样那条第一版 用纯色图作源是无效仪器(纯色下两种重采样结果完全相同),已换成棋盘格。 --- .../server/orchestrator/executor.py | 15 +++-- backend/tests/test_orchestrator_hardening.py | 65 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/backend/packages/app/src/windup_app/server/orchestrator/executor.py b/backend/packages/app/src/windup_app/server/orchestrator/executor.py index 9a90f3b2..21447edf 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -88,8 +88,12 @@ def _load_constraints(session: Session, project_id: int | None) -> ProjectConstr ) -def _fit_to(png: bytes, w: int, h: int) -> bytes: - """把帧等比缩放进 w×h(透明补边),落实项目 sprite 尺寸约束。""" +def _fit_to(png: bytes, w: int, h: int, *, smooth: bool = False) -> bytes: + """把图等比缩放进 w×h(透明补边),落实尺寸约束。 + + ``smooth`` 决定重采样:序列帧是像素画,必须 NEAREST(插值会把硬边糊成灰边、 + 并引入调色板外的颜色);全彩角色母版反过来,NEAREST 缩图会明显锯齿,用 LANCZOS。 + """ import io from PIL import Image @@ -98,7 +102,7 @@ def _fit_to(png: bytes, w: int, h: int) -> bytes: if im.size == (w, h): return png fitted = im.copy() - fitted.thumbnail((w, h), Image.NEAREST) + fitted.thumbnail((w, h), Image.LANCZOS if smooth else Image.NEAREST) canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0)) canvas.alpha_composite(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2)) buf = io.BytesIO() @@ -377,7 +381,10 @@ def _produce_image(self, input: CharacterImageInput, cons: ProjectConstraints) - urls: list[str] = [] for _ in range(max(1, input.num_images)): img = image_gen.gen_image(prompt, refs) - urls.append(upload(img)) + # 请求里的 width/height 此前被丢掉:入口收下并校验过它们(_validate_project_size), + # 而 ImageProvider.gen_image 没有尺寸参数,模型出多大就返多大 —— 又一个"接了不 + # 履约"的字段(2026-08-10 对抗复查发现)。模型本身不吃宽高,所以在这里落实。 + urls.append(upload(_fit_to(img, input.width, input.height, smooth=True))) return urls def _get_image(self): diff --git a/backend/tests/test_orchestrator_hardening.py b/backend/tests/test_orchestrator_hardening.py index bee9f627..930ebb8a 100644 --- a/backend/tests/test_orchestrator_hardening.py +++ b/backend/tests/test_orchestrator_hardening.py @@ -234,3 +234,68 @@ def test_num_frames_is_bounded(): CharacterActionGenerateRequest(character_id=1, action_type="walk", num_frames=0) ok = CharacterActionGenerateRequest(character_id=1, action_type="walk", num_frames=16) assert ok.num_frames == 16 + + +# ── ⑥ 请求里的尺寸必须真的生效(2026-08-10 对抗复查)──────────────────────── + + +def _png(w: int, h: int) -> bytes: + """带细节的图。纯色图在 NEAREST 与 LANCZOS 下产出完全相同,拿它验重采样是无效仪器 + (2026-08-10 第一版就是这么写的,测试立刻变红)。这里用 8px 棋盘格。""" + import io + + import numpy as np + from PIL import Image + + y, x = np.mgrid[0:h, 0:w] + checker = (((x // 8) + (y // 8)) % 2 * 255).astype("uint8") + arr = np.dstack([checker, 255 - checker, checker, np.full((h, w), 255, "uint8")]) + buf = io.BytesIO() + Image.fromarray(arr, "RGBA").save(buf, "PNG") + return buf.getvalue() + + +@pytest.mark.parametrize(("want_w", "want_h"), [(512, 512), (256, 384), (1024, 1024)]) +def test_requested_image_size_is_actually_applied(want_w, want_h): + """入口收下 width/height 并校验过,但 ImageProvider.gen_image 没有尺寸参数。 + + 此前模型出多大就返多大:调用方要 512×512、拿到 1024×1024,而请求被接受了 —— + 又一个"接了不履约"的字段。本用例锁住"要多大就得多大"。 + """ + import io + + from PIL import Image + + from windup_app.server.orchestrator.executor import ImageTaskExecutor + from windup_app.server.orchestrator.model import CharacterImageInput + + class _Gen: + def gen_image(self, prompt, refs): + return _png(1024, 1024) # 模型固定出 1024² + + got: list[bytes] = [] + ex = ImageTaskExecutor(image=_Gen(), upload=lambda b: (got.append(b), "u")[1]) + ex._produce_image( + CharacterImageInput(prompt="knight", width=want_w, height=want_h, num_images=1), + _constraints(), + ) + assert Image.open(io.BytesIO(got[0])).size == (want_w, want_h) + + +def test_sprite_frames_and_master_use_different_resampling(): + """序列帧是像素画,必须 NEAREST;全彩母版用 NEAREST 缩图会明显锯齿。 + + 只断言两条路径产出不同 —— 同一张图两种重采样若字节相同,说明 smooth 参数没接上。 + """ + from windup_app.server.orchestrator.executor import _fit_to + + src = _png(1024, 1024) + assert _fit_to(src, 256, 256, smooth=False) != _fit_to(src, 256, 256, smooth=True) + + +def _constraints(): + """最小项目约束(本文件只关心尺寸这条链路)。""" + from windup_app.server.orchestrator.executor import _load_constraints # noqa: F401 + from windup_app.server.orchestrator.executor import ProjectConstraints + + return ProjectConstraints() From 430a3d44b6d32331b5ecd1f648fa6c240b95e981 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 11 Aug 2026 11:21:49 +0800 Subject: [PATCH 31/33] =?UTF-8?q?fix(app):=20=E4=BA=A4=E4=BB=98=E5=B0=BA?= =?UTF-8?q?=E5=AF=B8=E4=BC=A0=E7=BB=99=E5=BC=95=E6=93=8E=E5=87=BA=E5=B8=A7?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=8B=BF=E5=88=B0=E5=B8=A7=E4=B9=8B?= =?UTF-8?q?=E5=90=8E=E5=86=8D=E7=BC=A9=E4=B8=80=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 编排层此前对引擎交付的每一帧再做一次 _fit_to(png, sprite_w, sprite_h)。引擎恒出 256,项目要 512 时这就是二次重采样 —— 而实际后果比"糊一次"严重得多:_fit_to 用 Image.thumbnail,**thumbnail 只缩不放**。2026-08-11 复刻这段逻辑实测(喂主体高 157px、脚线 0.92 的 256 交付帧): 目标 512×512 → 主体仍 157px(根本没放大),脚线 0.92 → 0.709 目标 384×384 → 主体仍 157px, 脚线 0.92 → 0.779 目标 128×128 → 主体 78px, 脚线 0.914(缩小方向正常) 即放大方向上主体一点没变大("成品放大看很糊"的直接来源),并且引擎刚用 align_bottom_center 对齐好的脚线被整体挪高,角色不站在地上,ref_height 那套跨动作 本体尺寸一致也一并失效。 改法:把项目 sprite 尺寸作为 canvas 传给 generate,引擎一次出到位,那一步不存在了。 用归档角色「林间斥候」的真实抠图帧(1280×720,主体高中位 619px)端到端实测: canvas 不传 交付 256×256 主体高中位 159px canvas (256,256) 交付 256×256 主体高中位 159px(与不传**逐字节相同**) canvas (512,512) 交付 512×512 主体高中位 318px —— 倍数 2.000 canvas (384,512) 交付 384×512 主体高与 (512,512) 完全相同(高度几何只看高) canvas (1024,1024) 交付 1024×1024 主体高中位 635px 顺带一条选型信息:源帧主体 619px,512 档交付 318px 仍是**下采样**(不引入插值糊), 1024 档 635px 已经越过源分辨率、是上采样,收益递减。 _fit_to 换成 _require_size:只核对、不补救。尺寸对不上说明生成侧没按 canvas 出帧, 该报错让人看见,而不是缩放补边把问题抹平、交付一批脚线错位的帧。_fit_to 本身保留, 角色母版那条路径(smooth=True)仍在用。 **接口影响**:CharacterGeneratorPort.generate 多了 canvas 入参,实现该 Protocol 的 测试替身必须跟着接。修 _SpyGenerator 时顺带发现一个假绿用例:该 spy 一直在传 GeneratedAction(fps=...),而该字段早已删除,构造直接 TypeError、任务其实被判 FAILED; 当时的用例只断言 seen_facing(在构造之前就赋了值)所以一直绿着。已一并修好, test_project_perspective_constrains_facing 现在跑的是一次真正成功的任务。 变异测试(4 个变异逐个改坏 → 确认变红 → 还原,全部被杀): E1 不把项目尺寸传给引擎 → project_sprite_size_is_passed 等 2 条红 E2 canvas 宽高接反 → non_square_sprite_size_passed 红 E3 只传宽当成方形 → non_square_sprite_size_passed 红 E4 尺寸不符时静默放行 → wrong_size_fails_instead_of_rescaled 红 **依赖上游分支**:canvas 入参由 feat/ai-engine-ports-and-strategy 的 013520f 提供, 后者又依赖 feat/ai-engine-frame-toolkit 的 5da358e。本分支尚未同步这两个提交,故 单独跑 pytest 会有 1 条 test_action_task_runs_end_to_end 失败(真实 CharacterGenerator 还不认 canvas)。在工作区先打上那两个提交再跑,CI 全绿: ruff / lint-imports(2 contracts kept) / pytest 309 passed。同步后即恢复。 Co-Authored-By: Claude Opus 5 --- .../server/orchestrator/executor.py | 36 ++- .../tests/test_generation_orchestration.py | 285 ++++++++++++++++++ 2 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_generation_orchestration.py diff --git a/backend/packages/app/src/windup_app/server/orchestrator/executor.py b/backend/packages/app/src/windup_app/server/orchestrator/executor.py index 21447edf..492e7c06 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/executor.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -110,6 +110,27 @@ def _fit_to(png: bytes, w: int, h: int, *, smooth: bool = False) -> bytes: return buf.getvalue() +def _require_size(png: bytes, w: int, h: int) -> bytes: + """核对引擎交付帧确实是项目要的尺寸,不对就报错 —— **不做静默补救**。 + + 这里以前是 ``_fit_to``:尺寸对不上就缩放补边。看着稳,实际是把"引擎没按尺寸出帧" + 这件事悄悄抹平,代价是脚线对齐被破坏(见 ``_produce_action`` 的说明)。尺寸现在由 + 引擎按 ``canvas`` 负责,对不上说明生成侧出了问题,该让它响,而不是交付一批对齐 + 坏掉的帧 —— 那正是本仓最忌讳的"看起来成功的错产物"。 + """ + import io + + from PIL import Image + + size = Image.open(io.BytesIO(png)).size + if size != (w, h): + raise ValueError( + f"引擎交付帧尺寸 {size[0]}×{size[1]} 与项目约束 {w}×{h} 不一致;" + "生成侧未按 canvas 出帧,不做静默缩放补救。" + ) + return png + + class _LogProgress: """进度上报占位:MVP 无 SSE,记日志即可。""" @@ -185,11 +206,18 @@ def run_action_task( # -- 内部 -------------------------------------------------------------- def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) -> dict: - """母版 → ai_engine 出帧 → 按项目尺寸切帧 → 逐帧上传 → 组结果 dict。 + """母版 → ai_engine 按项目尺寸出帧 → 逐帧上传 → 组结果 dict。 项目约束落实:``facing`` 随视角、``stylize`` 随画风(像素游戏→像素化)、 输出帧尺寸随 ``sprite_w×sprite_h``。方向数(directions)MVP 先出主方向, 四向/八向为扩展(需多次生成或镜像)。 + + **尺寸是传给引擎的,不是拿到帧再缩的。** 这里曾对每帧再做一次 + ``_fit_to(png, sprite_w, sprite_h)``:引擎恒出 256,项目要 512 就等于二次 + 重采样。而 ``_fit_to`` 用 ``Image.thumbnail`` —— 它**只缩不放**,放大方向 + 根本不放大,只是把 256 的帧原尺寸居中贴进 512 画布,于是引擎刚对齐好的脚线 + 0.92 被挪到 0.709(2026-08-11 实测),角色不站在地上、跨动作对齐一并失效。 + 现在把 ``canvas`` 交给引擎,它一次就出到项目尺寸,那一步整个不存在了。 """ if cons.directions > 1: logger.info("项目要求 %s 方向,MVP 先出主方向(多方向待扩展)", cons.directions) @@ -206,12 +234,14 @@ def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) stylize=cons.stylize, ) progress: ProgressPort = _LogProgress() - generated = self._get_generator().generate(card, action, master, progress) + generated = self._get_generator().generate( + card, action, master, progress, canvas=(cons.sprite_w, cons.sprite_h) + ) upload = self._upload or self._upload_frame frames = [ {"index": i, - "image_url": upload(_fit_to(png, cons.sprite_w, cons.sprite_h)), + "image_url": upload(_require_size(png, cons.sprite_w, cons.sprite_h)), "duration_ms": dur} for i, (png, dur) in enumerate(zip(generated.frames, generated.durations)) ] diff --git a/backend/tests/test_generation_orchestration.py b/backend/tests/test_generation_orchestration.py new file mode 100644 index 00000000..5ae0f6d5 --- /dev/null +++ b/backend/tests/test_generation_orchestration.py @@ -0,0 +1,285 @@ +"""生成任务编排端到端(离线):提交任务 → 后台调 ai_engine 出帧 → 上传 → 写回结果。 + +用内存 sqlite + 真实 GenerationTaskRecord ORM + 真实 AiGenerationService + 真实 +CharacterGenerator(视频 provider / matte / 抽帧全部桩替,不联网、不碰对象存储)。 +证明"任务 → ai_engine → 帧 → COMPLETED"这条链真能跑通。 +""" +from __future__ import annotations + +import io + +import pytest +from PIL import Image +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from windup_framework.db.base import Base +from windup_app.server.project.model import Project # 注册 windup_project 表(create_all 用) +from windup_app.server.orchestrator.model import ( + ActionType, + CharacterActionInput, + CharacterActionOutput, + TaskStatus, +) +from windup_app.server.orchestrator.executor import ActionTaskExecutor +from windup_app.server.orchestrator.service import AiGenerationService +from windup_ai_engine.impl import CharacterGenerator +from windup_ai_engine.strategy.concrete import VideoFrameStrategy +from windup_common.models import GenRoute + + +def _tiny_png(shift: int = 0) -> bytes: + img = Image.new("RGBA", (64, 96), (0, 0, 0, 0)) + for y in range(20, 80): + for x in range(24 + shift, 40 + shift): + img.putpixel((x, y), (200, 60, 60, 255)) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +class _StubVideo: + def i2v(self, first_frame, prompt, seconds=5, size="1280x720"): + return b"fake-mp4" + + +class _StubMatte: + def cutout(self, frame): + return frame + + +@pytest.fixture +def session_factory(): + """共享的内存 sqlite(StaticPool 保证多 session 同库),建好任务表。""" + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + return sessionmaker(bind=engine) + + +def _real_offline_generator(monkeypatch) -> CharacterGenerator: + """真实 CharacterGenerator,但抽帧顶替成合成帧(不解码 mp4 / 不联网)。""" + dense = [ + Image.open(io.BytesIO(_tiny_png(shift=i % 6))).convert("RGBA") + for i in range(24) + ] + monkeypatch.setattr( + "windup_ai_engine.strategy.concrete.extract_all_frames_bytes", + lambda video, cap=150: dense, + ) + return CharacterGenerator( + {GenRoute.VIDEO_I2V: VideoFrameStrategy(_StubVideo(), _StubMatte())} + ) + + +def test_action_task_runs_end_to_end(session_factory, monkeypatch): + uploaded: list[bytes] = [] + + def _upload(png: bytes) -> str: + uploaded.append(png) + return f"https://cdn.example.com/frame-{len(uploaded)}.png" + + service = AiGenerationService() + executor = ActionTaskExecutor( + generator=_real_offline_generator(monkeypatch), + upload=_upload, + fetch_master=lambda _input: _tiny_png(), + session_factory=session_factory, + ) + action_input = CharacterActionInput( + character_id=1, action_type=ActionType.WALK, num_frames=6, + ) + + # 1) 提交:建 PENDING 任务 + with session_factory() as s: + task = service.generate_character_action(s, user_id=1, input=action_input) + s.commit() + task_id = task.id + assert task.status is TaskStatus.PENDING + + # 2) 后台跑(自开 session) + executor.run_action_task(task_id, action_input) + + # 3) 轮询:任务 COMPLETED,结果是含 URL 的帧序列 + with session_factory() as s: + done = service.get_task(s, project_id=1, task_id=task_id) + assert done is not None + assert done.status is TaskStatus.COMPLETED + assert isinstance(done.result, CharacterActionOutput) + assert done.result.action_type == "walk" + assert len(done.result.frames) >= 1 + assert uploaded, "应逐帧上传" + for i, frame in enumerate(done.result.frames): + assert frame.index == i + assert frame.image_url.startswith("https://") + assert frame.duration_ms is not None + + +def _png_of(w: int, h: int) -> bytes: + """指定尺寸的一张带主体的 PNG。""" + img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + img.paste((200, 60, 60, 255), (w // 4, h // 4, w // 2, h // 2)) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +class _SpyGenerator: + """记录传入的 facing / canvas,验证项目约束确实喂进了 ai_engine。 + + ``canvas`` 是必须接的:交付尺寸现在由引擎按项目 sprite 尺寸出帧负责,编排层 + 不再拿到帧之后自己缩 —— 那一步用 thumbnail 补边,只缩不放,会把引擎对齐好的 + 脚线挪走。本 spy 照真实引擎的约定按 canvas 出帧。 + + ``honour_canvas=False`` 用来模拟"引擎没按尺寸出帧",验证编排层会报错而不是 + 静默缩放补救。 + """ + + def __init__(self, honour_canvas: bool = True) -> None: + self.seen_facing: str | None = None + self.seen_canvas: tuple[int, int] | None = None + self._honour = honour_canvas + + def generate(self, card, action, master, progress, canvas=None): + from windup_ai_engine.ports import GeneratedAction + + self.seen_facing = action.facing + self.seen_canvas = canvas + size = canvas if (canvas and self._honour) else (256, 256) + # 不传 fps:GeneratedAction 早已删掉该字段(播放时序的唯一真相源是 durations)。 + # 这个 spy 之前一直在传,构造直接 TypeError、任务被判 FAILED —— 而当时的用例 + # 只断言 seen_facing(在构造之前就赋了值),于是**用例绿着、任务其实是失败的**。 + from windup_ai_engine.ports import ActionQuality + + return GeneratedAction( + frames=[_png_of(*size)], + durations=[100], + quality=ActionQuality(motion_scale=1.0, dead_frames=[], loop_seam=None), + ) + + +def test_project_perspective_constrains_facing(session_factory): + # perspective=2 → front(见 executor._PERSPECTIVE_TO_FACING) + with session_factory() as s: + proj = Project( + user_id=1, project_name="p", character_perspective=2, + directional_movement=1, sprite_width=64, sprite_height=64, + ) + s.add(proj) + s.commit() + project_id = proj.id + + spy = _SpyGenerator() + executor = ActionTaskExecutor( + generator=spy, + upload=lambda _png: "https://cdn.example.com/f.png", + fetch_master=lambda _input: _tiny_png(), + session_factory=session_factory, + ) + action_input = CharacterActionInput( + character_id=1, action_type=ActionType.WALK, num_frames=2, + ) + with session_factory() as s: + task = AiGenerationService().generate_character_action(s, user_id=1, input=action_input) + s.commit() + task_id = task.id + + executor.run_action_task(task_id, action_input, project_id) # 带项目约束 + + assert spy.seen_facing == "front", "项目 perspective 应约束生成朝向" + + +def test_action_task_marks_failed_on_error(session_factory): + def _boom(_input): + raise RuntimeError("母版下载失败") + + service = AiGenerationService() + executor = ActionTaskExecutor( + generator=None, # 不会用到:取母版先炸 + fetch_master=_boom, + session_factory=session_factory, + ) + action_input = CharacterActionInput( + character_id=1, action_type=ActionType.WALK, num_frames=4, + ) + with session_factory() as s: + task = service.generate_character_action(s, user_id=1, input=action_input) + s.commit() + task_id = task.id + + executor.run_action_task(task_id, action_input) # 不抛,兜底为 FAILED + + with session_factory() as s: + done = service.get_task(s, project_id=1, task_id=task_id) + assert done.status is TaskStatus.FAILED + assert "母版下载失败" in (done.error_message or "") + + +# ── 交付尺寸传给引擎(2026-08-11 挣得)────────────────────────────────────────── +# +# 这里以前是拿到 256 的帧再 _fit_to 到项目 sprite 尺寸。那步用 Image.thumbnail 补边, +# 而 thumbnail **只缩不放**:项目要 512 时帧根本不会被放大,只是原尺寸居中贴进 512 +# 画布,于是引擎刚对齐好的脚线 0.92 被挪到 0.709(实测),角色不站在地上。 +# 现在尺寸交给引擎(canvas),编排层只核对、不缩放。 + + +def _run_with_project(session_factory, spy, sprite=(64, 64)): + """建一个指定 sprite 尺寸的项目,跑一次动作任务,返回 (task_id, project_id)。""" + with session_factory() as s: + proj = Project( + user_id=1, project_name="p", character_perspective=1, + directional_movement=1, sprite_width=sprite[0], sprite_height=sprite[1], + ) + s.add(proj) + s.commit() + project_id = proj.id + + executor = ActionTaskExecutor( + generator=spy, + upload=lambda _png: "https://cdn.example.com/f.png", + fetch_master=lambda _input: _tiny_png(), + session_factory=session_factory, + ) + action_input = CharacterActionInput( + character_id=1, action_type=ActionType.WALK, num_frames=2, + ) + with session_factory() as s: + task = AiGenerationService().generate_character_action( + s, user_id=1, input=action_input + ) + s.commit() + task_id = task.id + executor.run_action_task(task_id, action_input, project_id) + return task_id, project_id + + +def test_project_sprite_size_is_passed_to_the_engine(session_factory): + """项目 sprite 尺寸必须作为 canvas 传进引擎 —— 而不是拿到帧再缩。""" + spy = _SpyGenerator() + _run_with_project(session_factory, spy, sprite=(512, 512)) + assert spy.seen_canvas == (512, 512), "引擎应当收到项目 sprite 尺寸" + + +def test_non_square_project_sprite_size_is_passed_through(session_factory): + """非方形项目尺寸也要原样传下去,不能只传一个边长。""" + spy = _SpyGenerator() + _run_with_project(session_factory, spy, sprite=(384, 512)) + assert spy.seen_canvas == (384, 512) + + +def test_engine_frame_of_wrong_size_fails_instead_of_being_rescaled(session_factory): + """引擎没按尺寸出帧 → 任务失败,**不做静默缩放补救**。 + + 以前这里会 _fit_to 补救,把"引擎没按尺寸出帧"抹平,代价是脚线对齐被破坏 —— + 正是本仓最忌讳的"看起来成功的错产物"。 + """ + spy = _SpyGenerator(honour_canvas=False) # 恒出 256,无视 canvas + task_id, _ = _run_with_project(session_factory, spy, sprite=(512, 512)) + with session_factory() as s: + done = AiGenerationService().get_task(s, project_id=1, task_id=task_id) + assert done.status is TaskStatus.FAILED, "尺寸对不上必须失败,不能悄悄缩放交付" + assert "512" in (done.error_message or ""), "报错要说清期望尺寸" From 9faf919bbd8321ca28842756cb55327e39c11cf9 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 11 Aug 2026 15:44:02 +0800 Subject: [PATCH 32/33] =?UTF-8?q?fix(generation):=20SSE=20=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E8=A1=A5=E5=BD=92=E5=B1=9E=E6=A0=A1=E9=AA=8C=E4=B8=8E?= =?UTF-8?q?=E7=BB=88=E6=80=81=E9=A2=84=E6=A3=80=EF=BC=8C=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E6=97=A0=E6=B6=88=E8=B4=B9=E6=96=B9=E7=9A=84=20project=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 人工评审报的三条,逐条修: 一、越权订阅。stream 端点只按 task_id 订阅、不看是谁在订阅,任意已认证用户猜到 id 就能拿到别人任务的实时进度与最终产物 URL(事件体带 result,即帧的对象存储地址)。 同文件的 GET /tasks/{task_id} 有校验,stream 漏了,而两者从没被放在一起测过。 校验放在 subscribe **之前**:放之后的话越权请求仍会在 EventBus 上挂一个订阅者, 照样收事件、只是响应体被丢弃,订阅表还会因为没人 unsubscribe 而增长。 二、终态预检。原先是一行 TODO,而上一行的 docstring 已经承诺了该行为 —— 读文档的人 不会发现,实际表现是客户端要先挂满 30 秒心跳超时才拿到终态。 三、project_id 声明为必填但从未被使用,归属判定的依据是任务自己的 user_id,不是调用方 声称的项目。删掉后 fastapi.Query 也变成未使用 import,反过来印证它确实没有消费方。 顺带:事件 payload 从 task_repo 抽成公开的 task_event_payload()。终态预检是第二个发送 点,在 API 层再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的 同名事件。 测试 +9,5 条变异全部杀掉(去掉归属校验 / 去掉终态预检 / 校验挪到订阅之后 / payload 少字段 / 终态映射把 failed 当成 completed)。 其中"payload 同形状"那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源, 删字段一起变、断言永远成立。改成把 SSE 事件体的键集写死成契约清单,并另加一条直接比 两条真实发送路径产出的用例。 过程中三处是测试自身写错、不是代码错,记下避免再犯:按 HTTP 状态码断言越权(本仓 BizException 统一以 200 + 业务码返回,把"校验生效"误判成"越权",且成功码是 200 不是 0); create_task 签名靠猜;conftest 的建表清单里没有本 PR 新引入的 generation_task 表。 --- backend/tests/test_generation_stream_auth.py | 185 +++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 backend/tests/test_generation_stream_auth.py diff --git a/backend/tests/test_generation_stream_auth.py b/backend/tests/test_generation_stream_auth.py new file mode 100644 index 00000000..9b614b17 --- /dev/null +++ b/backend/tests/test_generation_stream_auth.py @@ -0,0 +1,185 @@ +"""SSE 订阅的归属校验与终态预检(2026-08-11 人工评审逮到)。 + +两条都是"测试全绿的情况下"存在的: +- 越权订阅没有任何用例覆盖 —— 同文件的 GET /tasks/{task_id} 有校验,stream 漏了, + 而两者没有任何一条测试把它们放在一起比过; +- 终态预检是一行 TODO,但 docstring 已经承诺了该行为,读文档的人不会发现。 +""" +from __future__ import annotations + +import pytest + +from windup_app.server.orchestrator import task_repo +from windup_app.server.orchestrator.model import GenerationType, TaskStatus + + +def _make_task(session, user_id: int, status: TaskStatus = TaskStatus.PENDING) -> int: + """直接落一条任务,绕过端点(端点会真的起后台线程去跑生成)。""" + task = task_repo.create_task( + session, + user_id=user_id, + project_id=None, + task_type=GenerationType.CHARACTER_IMAGE, + input_payload={"prompt": "x"}, + ) + if status is not TaskStatus.PENDING: + task_repo.update_status(session, task.id, status) + session.commit() + return task.id + + +@pytest.fixture() +def session(engine): + """绑定到测试 engine 的 session。 + + 额外建 generation_task 表:conftest 的 ``engine`` fixture 只建了 project / user / + character / workflow_run 四张,本 PR 新引入的这张没进那份清单。在这里补建而不是改 + 公共 fixture,是为了不影响其它测试文件的建表集合。 + """ + from sqlalchemy.orm import sessionmaker + + from windup_app.server.orchestrator.model import GenerationTaskRecord + from windup_framework.db import Base + + Base.metadata.create_all(engine, tables=[GenerationTaskRecord.__table__]) + s = sessionmaker(bind=engine, expire_on_commit=False)() + yield s + s.close() + + +# ── ① 越权订阅必须被拒 ────────────────────────────────────────────────────── + + +def test_another_user_cannot_subscribe_to_my_task_stream(auth_client, auth_client_b, session): + """任意已认证用户猜到 task_id 就能拿到别人任务的实时进度与产物 URL。 + + 这是**授权绕过**,不是信息层面的小瑕疵:SSE 事件体里带 result,即最终帧的 + 对象存储 URL。 + """ + task_id = _make_task(session, user_id=1) + + # 本仓的 BizException 统一以 **HTTP 200 + 业务码** 返回,故判据看 body 的 data + # 是否为空,不看 HTTP status(第一版按 status_code 断言,把"校验生效"误判成 + # "越权";成功码也不是 0 而是 200,所以也别按 code==0 判)。 + mine = auth_client.get(f"/generation/tasks/{task_id}").json() + assert mine["data"] is not None, mine # 对照组:任务确实存在、路由确实通 + theirs = auth_client_b.get(f"/generation/tasks/{task_id}").json() + assert theirs["data"] is None, theirs + + with auth_client_b.stream("GET", f"/generation/tasks/{task_id}/stream") as r: + body = r.read().decode() + assert "event: " not in body, f"越权订阅拿到了事件流:{body[:200]}" + + +def test_rejection_happens_before_subscribing(auth_client_b, session): + """校验必须在 subscribe **之前**。 + + 放在之后的话,越权请求仍会在 EventBus 上挂一个订阅者 —— 它照样收到事件, + 只是响应体被丢弃;订阅表还会因为没人 unsubscribe 而增长。 + """ + from windup_app.web.api.generation import event_bus + + task_id = _make_task(session, user_id=1) + with auth_client_b.stream("GET", f"/generation/tasks/{task_id}/stream") as r: + r.read() + assert str(task_id) not in event_bus._queues or not event_bus._queues[str(task_id)], \ + "越权请求在 EventBus 上留下了订阅者" + + +# ── ② 终态预检 ───────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize(("status", "expected"), [ + (TaskStatus.COMPLETED, "event: completed"), + (TaskStatus.FAILED, "event: failed"), +]) +def test_already_terminal_task_gets_its_event_immediately(auth_client, session, status, expected): + """订阅时任务已终结 → 立即推终态并关闭。 + + 此前是一行 TODO:客户端要先挂满一次心跳超时才拿到终态。 + 读取带超时:行为退回去时用例会超时失败,而不是把整个测试进程挂死 + (2026-08-11 第一版没设超时,pytest 被挂满 10 分钟)。 + """ + task_id = _make_task(session, user_id=1, status=status) + with auth_client.stream("GET", f"/generation/tasks/{task_id}/stream") as r: + body = r.read().decode() + assert expected in body, body[:300] + assert "heartbeat" not in body, "先发了心跳 = 没有走终态预检" + + +# SSE 事件体的键集是**对外契约**,故在这里写死。 +# 不要用 task_event_payload(task) 反算期望值 —— 那样两边同源,删字段时一起变、 +# 断言永远成立(2026-08-11 变异测试逮到第一版正是如此:删掉 error_message 仍全绿)。 +_EVENT_KEYS = { + "id", "user_id", "project_id", "task_type", + "status", "input_payload", "result", "error_message", +} + + +def test_terminal_event_body_matches_the_documented_contract(auth_client, session): + """订阅时补发的终态事件,键集必须与契约一致。""" + import json + + task_id = _make_task(session, user_id=1, status=TaskStatus.COMPLETED) + with auth_client.stream("GET", f"/generation/tasks/{task_id}/stream") as r: + body = r.read().decode() + line = next(x for x in body.splitlines() if x.startswith("data: ")) + assert set(json.loads(line[6:])) == _EVENT_KEYS + + +def test_both_send_paths_use_the_same_payload_builder(session): + """运行中推送与终态补发必须同形状 —— 两处各抄一份字段列表迟早分叉。 + + 直接比两条真实路径的产出:``_publish_task_update``(运行中)与 + ``task_event_payload``(终态预检用的那个)。 + """ + task_id = _make_task(session, user_id=1, status=TaskStatus.COMPLETED) + task = task_repo.get_task(session, task_id) + + sent: list[dict] = [] + + class _Bus: + def publish(self, tid, event, data): + sent.append(data) + + old_bus = task_repo._event_bus + task_repo._event_bus = _Bus() + try: + task_repo._publish_task_update(task_id, task) + finally: + task_repo._event_bus = old_bus + + assert set(sent[0]) == _EVENT_KEYS + assert set(task_repo.task_event_payload(task)) == _EVENT_KEYS + + +@pytest.mark.parametrize("status", [TaskStatus.PENDING, TaskStatus.RUNNING]) +def test_non_terminal_status_is_not_mistaken_for_terminal(status): + """非终态不能被预检判成终态,否则连接刚建立就被关掉。 + + **本条不走 HTTP,如实说明原因**:非终态的流是无限心跳,靠 ``request.is_disconnected()`` + 退出,而 TestClient 下它不会变 True —— 生成器永不结束,TestClient 在 teardown 上 + 阻塞(2026-08-11 第一版这么写,把 pytest 挂满 10 分钟)。压低心跳间隔也无效, + 因为挂的不是等待、是退出条件。 + + 所以这里直接测预检用的那个判据函数。它是终态预检的唯一入口,改坏了上面那几条 + 终态用例会红,因此覆盖不算空缺。 + """ + from windup_app.server.orchestrator.model import GenerationTask + + task = GenerationTask(id=1, user_id=1, task_type=GenerationType.CHARACTER_IMAGE, + status=status) + assert task_repo.terminal_event_for(task) is None + + +# ── ③ project_id 已移除 ──────────────────────────────────────────────────── + + +def test_stream_no_longer_requires_an_unused_project_id(auth_client, session): + """它声明为必填但从未被使用,归属判定的依据是任务自己的 user_id。 + + 留着等于要求客户端传一个不影响任何结果的必填参数 —— 传错了也照样通过。 + """ + task_id = _make_task(session, user_id=1, status=TaskStatus.COMPLETED) + r = auth_client.get(f"/generation/tasks/{task_id}/stream") + assert r.status_code == 200, "不传 project_id 应当可用" From a1d96d2bee8c323e15155c775a40bc7d9a6bb3a5 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 11 Aug 2026 17:17:50 +0800 Subject: [PATCH 33/33] =?UTF-8?q?fix(generation):=20SSE=20=E5=BD=92?= =?UTF-8?q?=E5=B1=9E=E8=A1=A5=E9=BD=90=E3=80=8C=E4=BB=BB=E5=8A=A1=E5=B1=9E?= =?UTF-8?q?=E4=BA=8E=E9=A1=B9=E7=9B=AE=E3=80=8D=E8=BF=99=E4=B8=80=E9=81=93?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E8=90=BD=E7=BB=88=E6=80=81=E9=A2=84=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属 校验,并把 EventBus 改成单键。那是错的:主线 #110 里 `project_id` 正是归属校验的依据 (`_get_project_or_raise`),且 EventBus 按 `(project_id, task_id)` 双键隔离同一 task_id 在不同项目下的流。删掉它会退化主线已有的能力。本版改为**在主线骨架上补齐它的 TODO**。 一、归属补成两道。主线已校验「项目属于当前用户」,缺「任务属于那个项目」。缺这一道, 任意已认证用户拿**自己的** project_id 配上别人的 task_id 就能订阅到别人的流,而事件 体带 result,即最终帧的对象存储 URL。两道都在 `subscribe` 之前 —— 放之后的话越权请求 仍会在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人 unsubscribe 而增长。 二、终态预检落地(原先是一行 TODO,而 docstring 已经承诺了该行为)。实际表现是客户端要 先挂满一次心跳超时才拿到终态。 三、跨线程投递:`publish` 改成**同 loop 直接入队、跨 loop 才 call_soon_threadsafe**。 一律走 marshal 是错的 —— 那是异步调度,要等 loop 下一次迭代才真入队,于是 「publish 完立刻 get_nowait」会拿到空队列,主线 #110 的项目隔离用例正是这么写的。 跨 loop 分支保留是因为 executor 在 daemon thread 里跑,而 asyncio.Queue 不是线程安全的。 四、`task.project_id` 为空时记 warning 并早退,不再 publish 到一个没人听的键上。 静默发出去的现象是「任务确实在跑、状态也在落库,但前端进度条一动不动」,日志里一行 异常都没有。 顺带:事件 payload 抽成公开的 `task_event_payload()`。终态预检是第二个发送点,在 API 层 再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的同名事件。 测试 10 条,7 条变异全部杀掉(去掉「任务属于项目」/ 去掉终态预检 / 校验挪到订阅之后 / payload 少字段 / 终态映射把 failed 当 completed / project_id 为空时静默 publish / publish 一律走异步 marshal)。 其中「payload 同形状」那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源、 删字段一起变、断言永远成立。已改成把 SSE 事件体键集写死为契约清单。 --- .../app/src/windup_app/bootstrap/app.py | 12 + .../server/orchestrator/__init__.py | 13 +- .../server/orchestrator/interface.py | 16 +- .../windup_app/server/orchestrator/model.py | 59 +++++ .../windup_app/server/orchestrator/service.py | 56 +++++ .../app/src/windup_app/web/api/generation.py | 11 + backend/tests/test_generation_stream_auth.py | 227 +++++++++++------- backend/tests/test_orchestrator_hardening.py | 67 ++++-- 8 files changed, 349 insertions(+), 112 deletions(-) create mode 100644 backend/packages/app/src/windup_app/server/orchestrator/service.py diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index fd997e30..b3d40466 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -21,6 +21,8 @@ from windup_app.server.workflow_run.model import WorkflowRun # noqa: F401 from windup_app.web.api.auth import router as auth_router from windup_app.web.api.character import router as character_router +from windup_app.server.orchestrator import task_repo +from windup_app.server.orchestrator.executor import run_action_task, run_image_task from windup_app.web.api.generation import router as generation_router from windup_app.web.api.media import router as media_router from windup_app.web.api.project import router as project_router @@ -90,6 +92,16 @@ def create_app() -> FastAPI: app.include_router(workflow_run_router) app.include_router(media_router) app.include_router(generation_router) + # 生成任务的后台执行器挂到 app.state:端点只建 PENDING 记录立即返回,真正的 + # 图生图/i2v 在后台线程跑。放在 state 而不是 import 到 web 层,是因为 + # import-linter 的分层契约禁止 app.web 直连 ai_engine,而 executor 要调它。 + app.state.run_action_task = run_action_task + app.state.run_image_task = run_image_task + + # task_repo 状态变更时自动推 SSE。延迟 import 避免与 generation 模块循环依赖。 + from windup_app.web.api.generation import event_bus + + task_repo.bind_event_bus(event_bus) register_exception_handlers(app) return app diff --git a/backend/packages/app/src/windup_app/server/orchestrator/__init__.py b/backend/packages/app/src/windup_app/server/orchestrator/__init__.py index c21a7b85..2cea0d63 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/__init__.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/__init__.py @@ -1,4 +1,9 @@ -"""生成任务领域。""" +"""生成任务编排(orchestrator):提交 / 调度 / 查询生成任务。 + +本包只做**任务编排调度**——建任务记录、后台驱动执行、查询状态;实际 AI 生成 +(调 ai_engine)在 :mod:`.executor` 后台跑。原名 ``generation``,更名为 ``orchestrator`` +以准确表达职责(调度而非生成本身)。 +""" from windup_app.server.orchestrator.model import ( ActionType, @@ -7,9 +12,12 @@ CharacterActionOutput, CharacterImageInput, GenerationTask, + GenerationTaskRecord, GenerationType, TaskStatus, ) +from windup_app.server.orchestrator.service import service as generation_service +from windup_app.server.orchestrator import task_repo __all__ = [ "ActionType", @@ -18,6 +26,9 @@ "CharacterActionOutput", "CharacterImageInput", "GenerationTask", + "GenerationTaskRecord", "GenerationType", "TaskStatus", + "generation_service", + "task_repo", ] diff --git a/backend/packages/app/src/windup_app/server/orchestrator/interface.py b/backend/packages/app/src/windup_app/server/orchestrator/interface.py index 83e38aa5..4d0a1d57 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/interface.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/interface.py @@ -22,6 +22,8 @@ from abc import ABC, abstractmethod +from sqlalchemy.orm import Session + from windup_app.server.orchestrator.model import ( CharacterActionInput, CharacterImageInput, @@ -35,7 +37,10 @@ class GenerationService(ABC): # -- 任务提交 ------------------------------------------------------------ @abstractmethod - def generate_character_image(self, input: CharacterImageInput) -> GenerationTask: + def generate_character_image( + self, session: Session, *, user_id: int, + project_id: int | None, input: CharacterImageInput, + ) -> GenerationTask: """提交角色图片生成任务。 入参包含参考图 URL 和 prompt 等参数;出参为 ``CharacterImageOutput``, @@ -43,7 +48,10 @@ def generate_character_image(self, input: CharacterImageInput) -> GenerationTask """ @abstractmethod - def generate_character_action(self, input: CharacterActionInput) -> GenerationTask: + def generate_character_action( + self, session: Session, *, user_id: int, + project_id: int | None, input: CharacterActionInput, + ) -> GenerationTask: """提交角色动作生成任务。 入参包含角色 ID、动作类型和参考素材;出参为 ``CharacterActionOutput``, @@ -53,7 +61,9 @@ def generate_character_action(self, input: CharacterActionInput) -> GenerationTa # -- 查询 ---------------------------------------------------------------- @abstractmethod - def get_task(self, project_id: int, task_id: int) -> GenerationTask | None: + def get_task( + self, session: Session, project_id: int, task_id: int, + ) -> GenerationTask | None: """查询任务状态与结果。 返回完整的 ``GenerationTask``,前端根据 ``status`` 判断是否完成, diff --git a/backend/packages/app/src/windup_app/server/orchestrator/model.py b/backend/packages/app/src/windup_app/server/orchestrator/model.py index 36fdab58..2558f416 100644 --- a/backend/packages/app/src/windup_app/server/orchestrator/model.py +++ b/backend/packages/app/src/windup_app/server/orchestrator/model.py @@ -9,6 +9,12 @@ from datetime import datetime, timezone from enum import StrEnum +from sqlalchemy import BigInteger, DateTime, Integer, JSON, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from windup_framework.db import Base + # -- 枚举 ---------------------------------------------------------------- @@ -25,6 +31,7 @@ class ActionType(StrEnum): WALK = "walk" IDLE = "idle" + JUMP = "jump" ATTACK = "attack" CUSTOM = "custom" @@ -124,3 +131,55 @@ class GenerationTask: @property def is_terminal(self) -> bool: return self.status in (TaskStatus.COMPLETED, TaskStatus.FAILED) + + +# -- ORM ----------------------------------------------------------------- + + +class GenerationTaskRecord(Base): + """生成任务持久化记录。 + + ``input_payload`` 和 ``result`` 以 JSON 存储;``result_type`` 标识 + ``result`` 的具体类型,读出后按类型反序列化为对应 dataclass。 + """ + + __tablename__ = "windup_generation_task" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + project_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + task_type: Mapped[str] = mapped_column( + Text, nullable=False, + default=GenerationType.CHARACTER_IMAGE.value, + ) + status: Mapped[str] = mapped_column( + Text, nullable=False, + default=TaskStatus.PENDING.value, + ) + input_payload: Mapped[dict] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + default=dict, + ) + result_type: Mapped[str | None] = mapped_column(Text, nullable=True) + result: Mapped[dict | None] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=True, + ) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/packages/app/src/windup_app/server/orchestrator/service.py b/backend/packages/app/src/windup_app/server/orchestrator/service.py new file mode 100644 index 00000000..ab46abac --- /dev/null +++ b/backend/packages/app/src/windup_app/server/orchestrator/service.py @@ -0,0 +1,56 @@ +"""生成任务领域服务(提交 + 查询)。 + +:class:`AiGenerationService` 只负责**建任务记录 + 查任务**——web 层依赖本模块。 +实际 AI 生成(调 ai_engine)在 :mod:`.executor` 后台跑,本模块**不碰 ai_engine**, +以满足"入口层(web/worker)不经 ai_engine 直连"的分层门禁(web → service 不得牵出 ai_engine)。 + +无状态:``session`` 由调用方按请求传入,本对象作模块级单例(:data:`service`)。 +""" + +from __future__ import annotations + +import dataclasses + +from sqlalchemy.orm import Session + +from windup_app.server.orchestrator import task_repo +from windup_app.server.orchestrator.interface import GenerationService +from windup_app.server.orchestrator.model import ( + CharacterActionInput, + CharacterImageInput, + GenerationTask, + GenerationType, +) + + +class AiGenerationService(GenerationService): + """生成任务服务:提交(建 PENDING 记录)+ 查询。生成执行在 executor 后台。""" + + def generate_character_image( + self, session: Session, *, user_id: int, project_id: int | None = None, + input: CharacterImageInput, + ) -> GenerationTask: + return task_repo.create_task( + session, user_id=user_id, project_id=project_id, + task_type=GenerationType.CHARACTER_IMAGE, + input_payload=dataclasses.asdict(input), + ) + + def generate_character_action( + self, session: Session, *, user_id: int, project_id: int | None = None, + input: CharacterActionInput, + ) -> GenerationTask: + """建动作生成任务(PENDING)并返回;实际生成由 executor 后台跑,前端轮询 get_task。""" + return task_repo.create_task( + session, user_id=user_id, project_id=project_id, + task_type=GenerationType.CHARACTER_ACTION, + input_payload=dataclasses.asdict(input), + ) + + def get_task( + self, session: Session, project_id: int, task_id: int, + ) -> GenerationTask | None: + return task_repo.get_task(session, task_id) + + +service = AiGenerationService() diff --git a/backend/packages/app/src/windup_app/web/api/generation.py b/backend/packages/app/src/windup_app/web/api/generation.py index 148844b2..5b4e83df 100644 --- a/backend/packages/app/src/windup_app/web/api/generation.py +++ b/backend/packages/app/src/windup_app/web/api/generation.py @@ -106,7 +106,18 @@ def publish( ``get()`` 上,直到下一次同 loop 内的操作偶然把它带起来。故订阅时记下所属 loop, 发布时经 ``call_soon_threadsafe`` 回到那个 loop 上再入队。 """ + try: + here = asyncio.get_running_loop() + except RuntimeError: + here = None # 从没有 loop 的线程调(executor daemon thread) + for queue, loop in list(self._queues.get((project_id, task_id), [])): + if loop is here: + # 同一个 loop 内:直接入队。**不能一律走 call_soon_threadsafe** —— 那是 + # 异步调度,要等 loop 下一次迭代才真入队,于是"publish 完立刻 get_nowait" + # 会拿到空队列(主线 #110 的隔离用例正是这么写的)。 + queue.put_nowait((event, data)) + continue try: loop.call_soon_threadsafe(queue.put_nowait, (event, data)) except RuntimeError: diff --git a/backend/tests/test_generation_stream_auth.py b/backend/tests/test_generation_stream_auth.py index 9b614b17..e5f7db11 100644 --- a/backend/tests/test_generation_stream_auth.py +++ b/backend/tests/test_generation_stream_auth.py @@ -1,40 +1,51 @@ -"""SSE 订阅的归属校验与终态预检(2026-08-11 人工评审逮到)。 - -两条都是"测试全绿的情况下"存在的: -- 越权订阅没有任何用例覆盖 —— 同文件的 GET /tasks/{task_id} 有校验,stream 漏了, - 而两者没有任何一条测试把它们放在一起比过; -- 终态预检是一行 TODO,但 docstring 已经承诺了该行为,读文档的人不会发现。 +"""SSE 订阅的归属校验与终态预检。 + +两条都是"测试全绿的情况下"存在的缺口: +- 主线 #110 已经校验了"项目属于当前用户",但**没有**校验"任务属于那个项目"。缺这一道, + 任意已认证用户拿自己的 project_id 配上别人的 task_id 就能订阅到别人的流,而事件体带 + result,即最终帧的对象存储 URL。 +- 终态预检原先是一行 TODO,而端点 docstring 已经承诺了该行为 —— 读文档的人不会发现, + 实际表现是客户端要先挂满一次心跳超时才拿到终态。 + +归属口径沿用主线:靠 project_id 而不是任务自己的 user_id。我此前那版删掉了 project_id +改用 task.user_id,方向是错的 —— project_id 在主线里正是归属校验的依据,且 EventBus +按 (project_id, task_id) 双键隔离,删掉它会退化主线已有的能力。 """ from __future__ import annotations +import json + import pytest from windup_app.server.orchestrator import task_repo from windup_app.server.orchestrator.model import GenerationType, TaskStatus +# SSE 事件体的键集是**对外契约**,故在这里写死。 +# 不要用 task_event_payload(task) 反算期望值 —— 那样两边同源,删字段时一起变、断言永远 +# 成立(2026-08-11 变异测试逮到第一版正是如此:删掉 error_message 仍全绿)。 +_EVENT_KEYS = { + "id", "user_id", "project_id", "task_type", + "status", "input_payload", "result", "error_message", +} -def _make_task(session, user_id: int, status: TaskStatus = TaskStatus.PENDING) -> int: - """直接落一条任务,绕过端点(端点会真的起后台线程去跑生成)。""" - task = task_repo.create_task( - session, - user_id=user_id, - project_id=None, - task_type=GenerationType.CHARACTER_IMAGE, - input_payload={"prompt": "x"}, - ) - if status is not TaskStatus.PENDING: - task_repo.update_status(session, task.id, status) - session.commit() - return task.id + +def _create_project(client, name: str = "SSE 项目") -> dict: + return client.post("/projects", json={ + "project_name": name, + "character_perspective": 1, + "directional_movement": 2, + "sprite_width": 64, + "sprite_height": 64, + }).json()["data"] @pytest.fixture() def session(engine): - """绑定到测试 engine 的 session。 + """绑定测试 engine 的 session,并补建 generation_task 表。 - 额外建 generation_task 表:conftest 的 ``engine`` fixture 只建了 project / user / - character / workflow_run 四张,本 PR 新引入的这张没进那份清单。在这里补建而不是改 - 公共 fixture,是为了不影响其它测试文件的建表集合。 + conftest 的 ``engine`` fixture 只建了 project / user / character / workflow_run + 四张,本 PR 新引入的这张不在那份清单里。在这里补建而不是改公共 fixture, + 是为了不影响其它测试文件的建表集合。 """ from sqlalchemy.orm import sessionmaker @@ -47,43 +58,78 @@ def session(engine): s.close() -# ── ① 越权订阅必须被拒 ────────────────────────────────────────────────────── +def _make_task(session, *, user_id: int, project_id: int, + status: TaskStatus = TaskStatus.PENDING) -> int: + """直接落一条任务,绕过端点(端点会真的起后台线程去跑生成)。""" + task = task_repo.create_task( + session, + user_id=user_id, + project_id=project_id, + task_type=GenerationType.CHARACTER_IMAGE, + input_payload={"prompt": "x"}, + ) + if status is not TaskStatus.PENDING: + task_repo.update_status(session, task.id, status) + session.commit() + return task.id + +# ── ① 任务必须属于所声明的项目 ────────────────────────────────────────────── -def test_another_user_cannot_subscribe_to_my_task_stream(auth_client, auth_client_b, session): - """任意已认证用户猜到 task_id 就能拿到别人任务的实时进度与产物 URL。 - 这是**授权绕过**,不是信息层面的小瑕疵:SSE 事件体里带 result,即最终帧的 - 对象存储 URL。 +def test_task_from_another_project_cannot_be_subscribed(auth_client, session): + """主线只校验了"项目属于我",没校验"任务属于该项目"。 + + 缺这一道:**用自己的项目 id 配别人的任务 id** 就能订阅到别人的流。本用例用同一个 + 用户的两个项目复现,因此排除了"项目归属校验挡住了"这种解释 —— 两个项目都属于我, + 唯一的区别是任务不在我声明的那个项目里。 """ - task_id = _make_task(session, user_id=1) + mine = _create_project(auth_client, "我的项目") + other = _create_project(auth_client, "另一个项目") + task_id = _make_task(session, user_id=1, project_id=other["id"], + status=TaskStatus.COMPLETED) + + with auth_client.stream( + "GET", f"/generation/tasks/{task_id}/stream", + params={"project_id": mine["id"]}, + ) as r: + body = r.read().decode() + assert "event: " not in body, f"跨项目订阅拿到了事件流:{body[:200]}" - # 本仓的 BizException 统一以 **HTTP 200 + 业务码** 返回,故判据看 body 的 data - # 是否为空,不看 HTTP status(第一版按 status_code 断言,把"校验生效"误判成 - # "越权";成功码也不是 0 而是 200,所以也别按 code==0 判)。 - mine = auth_client.get(f"/generation/tasks/{task_id}").json() - assert mine["data"] is not None, mine # 对照组:任务确实存在、路由确实通 - theirs = auth_client_b.get(f"/generation/tasks/{task_id}").json() - assert theirs["data"] is None, theirs - with auth_client_b.stream("GET", f"/generation/tasks/{task_id}/stream") as r: +def test_task_in_the_declared_project_is_subscribable(auth_client, session): + """对照组:任务确实在所声明的项目里时必须放行 —— 否则上一条可能只是全都拒了。""" + project = _create_project(auth_client) + task_id = _make_task(session, user_id=1, project_id=project["id"], + status=TaskStatus.COMPLETED) + + with auth_client.stream( + "GET", f"/generation/tasks/{task_id}/stream", + params={"project_id": project["id"]}, + ) as r: body = r.read().decode() - assert "event: " not in body, f"越权订阅拿到了事件流:{body[:200]}" + assert "event: completed" in body, body[:200] -def test_rejection_happens_before_subscribing(auth_client_b, session): +def test_rejection_happens_before_subscribing(auth_client, session): """校验必须在 subscribe **之前**。 - 放在之后的话,越权请求仍会在 EventBus 上挂一个订阅者 —— 它照样收到事件, - 只是响应体被丢弃;订阅表还会因为没人 unsubscribe 而增长。 + 放在之后的话,越权请求仍会在 EventBus 上挂一个订阅者 —— 它照样收到事件,只是响应体 + 被丢弃;订阅表还会因为没人 unsubscribe 而增长。 """ from windup_app.web.api.generation import event_bus - task_id = _make_task(session, user_id=1) - with auth_client_b.stream("GET", f"/generation/tasks/{task_id}/stream") as r: + mine = _create_project(auth_client, "我的项目") + other = _create_project(auth_client, "另一个项目") + task_id = _make_task(session, user_id=1, project_id=other["id"]) + + with auth_client.stream( + "GET", f"/generation/tasks/{task_id}/stream", + params={"project_id": mine["id"]}, + ) as r: r.read() - assert str(task_id) not in event_bus._queues or not event_bus._queues[str(task_id)], \ - "越权请求在 EventBus 上留下了订阅者" + key = (mine["id"], task_id) + assert not event_bus._queues.get(key), "越权请求在 EventBus 上留下了订阅者" # ── ② 终态预检 ───────────────────────────────────────────────────────────── @@ -94,34 +140,29 @@ def test_rejection_happens_before_subscribing(auth_client_b, session): (TaskStatus.FAILED, "event: failed"), ]) def test_already_terminal_task_gets_its_event_immediately(auth_client, session, status, expected): - """订阅时任务已终结 → 立即推终态并关闭。 - - 此前是一行 TODO:客户端要先挂满一次心跳超时才拿到终态。 - 读取带超时:行为退回去时用例会超时失败,而不是把整个测试进程挂死 - (2026-08-11 第一版没设超时,pytest 被挂满 10 分钟)。 - """ - task_id = _make_task(session, user_id=1, status=status) - with auth_client.stream("GET", f"/generation/tasks/{task_id}/stream") as r: + """订阅时任务已终结 → 立即推终态并关闭,而不是先挂满一次心跳超时。""" + project = _create_project(auth_client) + task_id = _make_task(session, user_id=1, project_id=project["id"], status=status) + + with auth_client.stream( + "GET", f"/generation/tasks/{task_id}/stream", + params={"project_id": project["id"]}, + ) as r: body = r.read().decode() assert expected in body, body[:300] assert "heartbeat" not in body, "先发了心跳 = 没有走终态预检" -# SSE 事件体的键集是**对外契约**,故在这里写死。 -# 不要用 task_event_payload(task) 反算期望值 —— 那样两边同源,删字段时一起变、 -# 断言永远成立(2026-08-11 变异测试逮到第一版正是如此:删掉 error_message 仍全绿)。 -_EVENT_KEYS = { - "id", "user_id", "project_id", "task_type", - "status", "input_payload", "result", "error_message", -} - - def test_terminal_event_body_matches_the_documented_contract(auth_client, session): """订阅时补发的终态事件,键集必须与契约一致。""" - import json - - task_id = _make_task(session, user_id=1, status=TaskStatus.COMPLETED) - with auth_client.stream("GET", f"/generation/tasks/{task_id}/stream") as r: + project = _create_project(auth_client) + task_id = _make_task(session, user_id=1, project_id=project["id"], + status=TaskStatus.COMPLETED) + + with auth_client.stream( + "GET", f"/generation/tasks/{task_id}/stream", + params={"project_id": project["id"]}, + ) as r: body = r.read().decode() line = next(x for x in body.splitlines() if x.startswith("data: ")) assert set(json.loads(line[6:])) == _EVENT_KEYS @@ -133,13 +174,13 @@ def test_both_send_paths_use_the_same_payload_builder(session): 直接比两条真实路径的产出:``_publish_task_update``(运行中)与 ``task_event_payload``(终态预检用的那个)。 """ - task_id = _make_task(session, user_id=1, status=TaskStatus.COMPLETED) + task_id = _make_task(session, user_id=1, project_id=42, status=TaskStatus.COMPLETED) task = task_repo.get_task(session, task_id) sent: list[dict] = [] class _Bus: - def publish(self, tid, event, data): + def publish(self, project_id, tid, event, data): sent.append(data) old_bus = task_repo._event_bus @@ -157,29 +198,49 @@ def publish(self, tid, event, data): def test_non_terminal_status_is_not_mistaken_for_terminal(status): """非终态不能被预检判成终态,否则连接刚建立就被关掉。 - **本条不走 HTTP,如实说明原因**:非终态的流是无限心跳,靠 ``request.is_disconnected()`` - 退出,而 TestClient 下它不会变 True —— 生成器永不结束,TestClient 在 teardown 上 - 阻塞(2026-08-11 第一版这么写,把 pytest 挂满 10 分钟)。压低心跳间隔也无效, - 因为挂的不是等待、是退出条件。 + **本条不走 HTTP,如实说明原因**:非终态的流是无限心跳,靠 + ``request.is_disconnected()`` 退出,而 TestClient 下它不会变 True —— 生成器永不 + 结束,TestClient 在 teardown 上阻塞(第一版这么写,把 pytest 挂满 10 分钟)。 + 压低心跳间隔也无效,因为挂的不是等待、是退出条件。 - 所以这里直接测预检用的那个判据函数。它是终态预检的唯一入口,改坏了上面那几条 - 终态用例会红,因此覆盖不算空缺。 + 所以这里直接测预检用的那个判据函数。它是终态预检的唯一入口,改坏了上面那几条终态 + 用例会红,因此覆盖不算空缺。 """ from windup_app.server.orchestrator.model import GenerationTask - task = GenerationTask(id=1, user_id=1, task_type=GenerationType.CHARACTER_IMAGE, - status=status) + task = GenerationTask(id=1, user_id=1, project_id=42, + task_type=GenerationType.CHARACTER_IMAGE, status=status) assert task_repo.terminal_event_for(task) is None -# ── ③ project_id 已移除 ──────────────────────────────────────────────────── +# ── ③ project_id 为空的任务发不出事件,要记 warning 而不是静默丢 ────────────── -def test_stream_no_longer_requires_an_unused_project_id(auth_client, session): - """它声明为必填但从未被使用,归属判定的依据是任务自己的 user_id。 +def test_task_without_project_id_logs_instead_of_publishing_into_the_void(session, caplog): + """EventBus 按 (project_id, task_id) 索引,project_id 为空就发不到任何订阅者。 - 留着等于要求客户端传一个不影响任何结果的必填参数 —— 传错了也照样通过。 + 静默 publish 出去的话,现象是"任务确实在跑、状态也在落库,但前端进度条一动不动", + 而日志里一行异常都没有 —— 属于最难查的那类静默失败。 """ - task_id = _make_task(session, user_id=1, status=TaskStatus.COMPLETED) - r = auth_client.get(f"/generation/tasks/{task_id}/stream") - assert r.status_code == 200, "不传 project_id 应当可用" + import logging + + task_id = _make_task(session, user_id=1, project_id=42) + task = task_repo.get_task(session, task_id) + task.project_id = None + + sent: list = [] + + class _Bus: + def publish(self, *a): + sent.append(a) + + old_bus = task_repo._event_bus + task_repo._event_bus = _Bus() + try: + with caplog.at_level(logging.WARNING): + task_repo._publish_task_update(task_id, task) + finally: + task_repo._event_bus = old_bus + + assert sent == [], "不该发到一个没人听的键上" + assert any("project_id" in r.message for r in caplog.records), "应记 warning" diff --git a/backend/tests/test_orchestrator_hardening.py b/backend/tests/test_orchestrator_hardening.py index 930ebb8a..4b4b9ed2 100644 --- a/backend/tests/test_orchestrator_hardening.py +++ b/backend/tests/test_orchestrator_hardening.py @@ -1,4 +1,10 @@ -"""编排层的五处加固(2026-08-10 机器审逮到,逐条锁死)。 +"""编排层的加固用例。 + +EventBus 的键是 ``(project_id, task_id)``(主线 #110:同一 task_id 在不同项目下互不 +串流)。本文件里的 project_id 取一个固定值即可 —— 这些用例验的是队列/loop 行为, +项目隔离本身由 test_generation_api.py 的专用用例覆盖。 + +原始标题:编排层的五处加固(2026-08-10 机器审逮到,逐条锁死)。 共同点:全部在**测试全绿的情况下**存在——注入桩的测试走不到真实装配路径, mock 的 EventBus 不涉及跨线程,请求模型的上界靠"没人会填大数"活着。 @@ -108,12 +114,15 @@ def test_terminal_states_publish_terminal_event_names(status, expected, monkeypa sent: list[str] = [] class _Bus: - def publish(self, task_id, event, data): + def publish(self, project_id, task_id, event, data): sent.append(event) monkeypatch.setattr(R, "_event_bus", _Bus()) + # 必须带 project_id:EventBus 按 (project_id, task_id) 索引,_publish_task_update + # 对 project_id 为空的任务会记 warning 并早退(发到没人听的键上等于静默失败)。 R._publish_task_update(1, GenerationTask( - id=1, user_id=1, task_type=GenerationType.CHARACTER_ACTION, status=status, + id=1, user_id=1, project_id=42, + task_type=GenerationType.CHARACTER_ACTION, status=status, )) assert sent == [expected] @@ -134,20 +143,28 @@ def test_publish_from_another_thread_delivers(): 诚实说明本用例的强度:它只证明跨线程发布**能到达**订阅者,**证不出** call_soon_threadsafe 是必需的 —— 实测在这个单队列场景里,裸 put_nowait 跨线程也能被 get() 取到(CPython 的 Queue.get 在有元素时走快路径、不等唤醒)。 - 去掉 call_soon_threadsafe 本用例照样绿(2026-08-10 变异测试逮到)。 call_soon_threadsafe 仍然要留:asyncio.Queue 的文档明说它不是线程安全的, 上面那个"能取到"是实现细节而非保证——多个 waiter、队列非空判定与唤醒之间 的竞态都可能让它失效。真正的保证由下一条用例(订阅记录 loop)间接锁住。 + + 先等发布线程真的调完再取(用一个 future 同步),否则测的是"抢跑运气"而不是投递: + publish 走的是跨 loop 分支,marshal 回来要等 loop 一次迭代。 """ bus = _EventBus() async def scenario(): - queue = await bus.subscribe(7) - threading.Thread( - target=bus.publish, args=(7, "completed", {"id": 7}), daemon=True, - ).start() - return await asyncio.wait_for(queue.get(), timeout=2.0) + queue = await bus.subscribe(42, 7) + loop = asyncio.get_running_loop() + published = loop.create_future() + + def publish_from_thread(): + bus.publish(42, 7, "completed", {"id": 7}) + loop.call_soon_threadsafe(published.set_result, None) + + threading.Thread(target=publish_from_thread, daemon=True).start() + await asyncio.wait_for(published, timeout=3.0) + return await asyncio.wait_for(queue.get(), timeout=3.0) event, data = asyncio.run(scenario()) assert event == "completed" and data["id"] == 7 @@ -163,8 +180,8 @@ def test_subscription_records_its_owning_loop(): bus = _EventBus() async def scenario(): - q = await bus.subscribe(11) - subs = bus._queues["11"] + q = await bus.subscribe(42, 11) + subs = bus._queues[(42, 11)] assert len(subs) == 1 queue, loop = subs[0] assert queue is q @@ -180,13 +197,13 @@ def test_publish_to_a_closed_loop_is_dropped_not_raised(): bus = _EventBus() async def sub(): - return await bus.subscribe(9) + return await bus.subscribe(42, 9) loop = asyncio.new_event_loop() queue = loop.run_until_complete(sub()) loop.close() assert queue is not None - bus.publish(9, "completed", {"id": 9}) # 不应抛 + bus.publish(42, 9, "completed", {"id": 9}) # 不应抛 def test_unsubscribe_removes_only_that_queue(): @@ -194,10 +211,10 @@ def test_unsubscribe_removes_only_that_queue(): bus = _EventBus() async def scenario(): - q1 = await bus.subscribe(3) - q2 = await bus.subscribe(3) - await bus.unsubscribe(3, q1) - bus.publish(3, "task_update", {"n": 1}) + q1 = await bus.subscribe(42, 3) + q2 = await bus.subscribe(42, 3) + await bus.unsubscribe(42, 3, q1) + bus.publish(42, 3, "task_update", {"n": 1}) got = await asyncio.wait_for(q2.get(), timeout=2.0) assert got[1]["n"] == 1 assert q1.empty() @@ -213,26 +230,26 @@ def test_num_images_is_bounded_at_the_contract_layer(): 绕过按请求计的限流,把成本拉到无上限。 """ with pytest.raises(ValueError): - CharacterImageGenerateRequest(prompt="x", num_images=10_000) + CharacterImageGenerateRequest(project_id=42, prompt="x", num_images=10_000) with pytest.raises(ValueError): - CharacterImageGenerateRequest(prompt="x", num_images=0) - assert CharacterImageGenerateRequest(prompt="x", num_images=2).num_images == 2 + CharacterImageGenerateRequest(project_id=42, prompt="x", num_images=0) + assert CharacterImageGenerateRequest(project_id=42, prompt="x", num_images=2).num_images == 2 def test_image_dimensions_are_bounded(): with pytest.raises(ValueError): - CharacterImageGenerateRequest(prompt="x", width=100_000) + CharacterImageGenerateRequest(project_id=42, prompt="x", width=100_000) with pytest.raises(ValueError): - CharacterImageGenerateRequest(prompt="x", height=1) + CharacterImageGenerateRequest(project_id=42, prompt="x", height=1) def test_num_frames_is_bounded(): """帧数决定抽帧与逐帧抠图的工作量。""" with pytest.raises(ValueError): - CharacterActionGenerateRequest(character_id=1, action_type="walk", num_frames=100_000) + CharacterActionGenerateRequest(project_id=42, character_id=1, action_type="walk", num_frames=100_000) with pytest.raises(ValueError): - CharacterActionGenerateRequest(character_id=1, action_type="walk", num_frames=0) - ok = CharacterActionGenerateRequest(character_id=1, action_type="walk", num_frames=16) + CharacterActionGenerateRequest(project_id=42, character_id=1, action_type="walk", num_frames=0) + ok = CharacterActionGenerateRequest(project_id=42, character_id=1, action_type="walk", num_frames=16) assert ok.num_frames == 16