From de57b4cc616e8c14bcb5eb57559df459deeaf4f5 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Fri, 7 Aug 2026 15:39:55 +0800 Subject: [PATCH 01/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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/12] =?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)