From 92ddc6fd2631b9c5dca42a1f7cefb7fae1ef91e5 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 ---- 1 file changed, 4 deletions(-) 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++ 绑定版二选一) ] From edff67e8ab5c52e9cbe66a8979c4411f8c6ebe19 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Fri, 7 Aug 2026 19:08:12 +0800 Subject: [PATCH 02/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 --- .../tests/test_fal_queue_video_provider.py | 448 ++++++++++++++++++ 1 file changed, 448 insertions(+) create mode 100644 backend/tests/test_fal_queue_video_provider.py 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") From 54ed9b9d4610829a8eb768a93442c83813a4aecd Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 18:12:07 +0800 Subject: [PATCH 03/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 --- backend/tests/test_fal_queue_video_provider.py | 4 ++++ 1 file changed, 4 insertions(+) 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): From f6b707b900e89d4234fd58141b85cdd8d91e0e9a Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Fri, 7 Aug 2026 16:46:32 +0800 Subject: [PATCH 04/12] =?UTF-8?q?feat(ai=5Fengine):=20=E5=87=BA=E5=B8=A7?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=AE=B1=E2=80=94=E2=80=94=E6=8A=BD=E5=B8=A7?= =?UTF-8?q?=20/=20=E9=80=89=E5=B8=A7=20/=20=E5=90=8E=E5=A4=84=E7=90=86=20/?= =?UTF-8?q?=20=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 视频路线的纯计算层,零 windup 依赖(只用 PIL + numpy),可独立测试。 slicing/ 视频 → 帧序列 extract 解码;loop 循环类动作抽单步态周期;oneshot 一次性动作裁区间; quality 帧质量诊断(死帧 / 糊帧判据,只作诊断不进选帧,理由见 loop docstring) postprocess/ 帧 → 交付级序列帧 pixelate 母版是像素画时吸附母版网格 + 锁母版色板,否则通用量化 pack 脚线对齐 / sprite sheet / GIF rootmotion 逐帧时长(关键帧加长定格,等时长会让动作发飘) prompt/ + master_prep.py 按动作类型选提示词、按动作预处理母版 两处实测挣得的修复一并带上: 1) 画布横向裁切(postprocess/pack.py) align_bottom_center 的三条缩放分支只按高度定标,是"主体是纵向长条"的人形先验。 横向长条主体按同一系数缩放后宽度超出 cell,被 alpha_composite 以负 dest 静默丢像素, PIL 不报错。裁切悬崖 w/h ≈ 1.61;实测狐狸母版 w/h=1.78 丢 27px(鼻尖+尾尖), w/h=2.0 只剩 79.9% 内容。加宽度兜底 fill_w=0.96;人形 w/h 0.3–1.1 时该约束恒不生效, 产物逐像素不变。 2) 步态周期误检(slicing/loop.py)三个坑,四段真 i2v 视频实测 a. 角色整体平移让 d(p) 单调上升,argmin 滑到搜索窗边界交出假周期。 实测骷髅走路:不消平移时曲线 40/56 段在上升,只剩 22/42/52 三个浅坑,argmin=22; 加 _deskew 消平移后整条曲线只剩一个局部极小,正是真周期 56(凹陷深度 2.68)。 b. 搜索窗上界 n//2 把真周期挡在窗外(待机真周期 62 > pmax 60)。改为 total*0.6。 c. 谐波:22 接近真周期的一半,半周期闭环 = 末帧接回首帧时左右腿瞬间互换。 改为在基周期整数倍里按归一化接缝复选,优先最小倍数。 测不到可信凹陷(prominence < 0.25)时判"无周期",退化成全片均匀取、不硬闭环—— 实测骑士待机只有 31 帧,旧算法曲线单调、argmin 落在搜索窗下界 6 交出边界假值。 实测对照(n=16,接缝 = 末→首差 ÷ 组内相邻差均值,越接近 1 越闭合) 骷髅走路 3.07→1.96 | 骑士走路 1.29→0.87 | 骑士待机 9.31→1.39 | 骑士奔跑 1.77→0.81 待机那条最直观:旧算法写出的 GIF 只有 6 帧——16 帧里 10 帧逐像素重复,被 PIL 自动去重。 消融:改善全部来自 _deskew + 谐波复选。追加的"死帧避让 + 冻结裁剪"两个样本无变化、 两个变差(奔跑接缝 0.81→2.00),已回退,quality 只留作诊断。 --- .../src/windup_ai_engine/postprocess/pack.py | 19 ++++++-- .../src/windup_ai_engine/slicing/loop.py | 15 +++++- .../src/windup_ai_engine/slicing/oneshot.py | 17 ++----- .../src/windup_ai_engine/slicing/quality.py | 46 ++++++++++++++++++- 4 files changed, 77 insertions(+), 20 deletions(-) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py index 53d2565b..2b6207b3 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/postprocess/pack.py @@ -8,15 +8,24 @@ from PIL import Image -__all__ = ["align_bottom_center", "sprite_sheet", "save_gif"] +__all__ = ["CELL", "FILL_H", "FILL_W", "FOOT_LINE", "align_bottom_center", + "sprite_sheet", "save_gif"] + +# 交付画布的几何 —— 提成模块常量而不是只当默认参数,是因为**入口预检要按同一套几何 +# 判母版能不能装下**(见 master_check.REJECT_ASPECT)。抄一份数字过去就等于埋下 +# "改了这里、那边阈值不动"的静默分歧。 +CELL = 256 # 方形 cell 边长(交付序列帧的画布) +FOOT_LINE = 0.92 # 脚线在画布中的高度比例 +FILL_H = 0.62 # 参考姿态占画布高的比例(留余量给举过头顶的动作) +FILL_W = 0.96 # 主体占画布宽的上限(宽度兜底的天花板) def align_bottom_center( frames: list[Image.Image], - cell: int = 256, - foot_line: float = 0.92, - fill_h: float = 0.62, - fill_w: float = 0.96, + cell: int = CELL, + foot_line: float = FOOT_LINE, + fill_h: float = FILL_H, + fill_w: float = FILL_W, preserve_lift: bool = False, ref_height: float | None = None, cell_h: int | None = None, diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py index 07491daa..26a5b586 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/loop.py @@ -98,7 +98,20 @@ def pick_cycle(frames: list[Image.Image], n: int) -> list[Image.Image]: ② 取样后按 ``dead_frame_mask`` 就近避让死帧:i2v 死帧占比常达一半(24fps 容器隔帧复制, 实测 59/121 与 63/121),避让会系统性打乱相位均匀性,反而选中更多死帧(1→3)。 候选评分里的 ``a < 0.5 * scale`` 已经排掉"几乎不动"的窗口,够用。 - quality 留作诊断/报告用,不进选帧。 + quality 不进选帧(它另有一份出参职责,见 :mod:`.quality`)。 + + **已知缺口(2026-08-09 记,未修)**:本函数有四条返回路径,其中三条是 return 帧列表, + 调用方**分不清走了哪条**: + 1. ``total <= n`` 原样返回(源帧比要的还少,根本没选); + 2. 测不到可信周期 → 全片均匀取(**降级**,不闭环); + 3. 候选全被否 → 全片均匀取(**降级**,同上); + 4. 正常闭环。 + 2 与 3 的补救方式不同(2 多半是母版/动作幅度问题,该换母版;3 多半是视频里周期数 + 不够,该加长视频),但今天都表现为"一组看起来正常的帧"。**降级对交付物的后果**是可测 + 的 —— ``ports.ActionQuality.loop_seam`` 在交付帧上量归一化接缝,降级通常表现为接缝 + 偏大;但**降级的原因**测不出来。没有顺手把状态塞进返回值,是因为那要改本函数的返回 + 形状、波及所有调用方,而今天还没有任何调用方会依据"原因"改变行为。等真有调用方要按 + 原因给不同提示时,再让本函数返回 ``(frames, reason)``。 """ # n<=0 没有合法语义(要 0 帧的动画不存在),且两条出路都是坏的(2026-08-10 实测): # 检出周期时 `_offsets(P, 0)` 交出空 offsets,一路走到 `M[idx[-1], idx[0]]` 抛 IndexError; diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py index d6cb0192..dcbf1507 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/oneshot.py @@ -16,6 +16,8 @@ import numpy as np from PIL import Image +from windup_ai_engine._subject import subject_mask as _subject_mask + __all__ = [ "find_motion_span", "first_action_end", @@ -191,19 +193,8 @@ def pick_oneshot( def _subject_rows(frame: Image.Image, alpha_thr: int = 128, bg_tol: int = 60) -> np.ndarray: - """主体所在的行下标。有真实 alpha 用 alpha;**全不透明帧**(原始视频帧)按四角背景色判。 - - 必须兼容不透明帧:抽帧阶段拿到的是原始视频帧,还没抠图,只看 alpha 会把整幅当主体、 - 脚线恒定,导致腾空判据立刻误判"已落地"(实测踩过,跳跃被裁在起跳前)。 - """ - arr = np.asarray(frame.convert("RGBA")) - alpha = arr[:, :, 3] - if not alpha.min() > alpha_thr: - return np.where(alpha > alpha_thr)[0] - rgb = arr[:, :, :3].astype(np.int16) - corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) - bg = np.median(corners, axis=0) - return np.where(np.abs(rgb - bg).sum(axis=2) > bg_tol)[0] + """主体所在的行下标。判据本身在 :mod:`.._subject`(与母版预检共用同一个主体定义)。""" + return np.where(_subject_mask(frame, alpha_thr, bg_tol))[0] def foot_line_series(frames: list[Image.Image], alpha_thr: int = 128) -> np.ndarray: diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py index 8bd85e57..734274ec 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/quality.py @@ -12,7 +12,8 @@ from ._frames import gray as _gray -__all__ = ["active_span", "blur_ratio", "dead_frame_mask", "frame_deltas"] +__all__ = ["active_span", "blur_ratio", "dead_frame_indices", "dead_frame_mask", + "frame_deltas", "loop_seam", "motion_scale"] def frame_deltas(frames) -> np.ndarray: @@ -44,6 +45,49 @@ def dead_frame_mask(frames, ratio: float = 0.35, floor: float = 0.25) -> np.ndar return m +def dead_frame_indices(frames) -> tuple[int, ...]: + """死帧下标。:func:`dead_frame_mask` 的出参形态转换 —— 掩码是算的时候好用的形态, + 跨出 ai_engine 的契约(``ports.ActionQuality``)要的是"哪几帧",不该让调用方拿着 + 一个 numpy 掩码去自己 argwhere。""" + return tuple(int(i) for i in np.flatnonzero(dead_frame_mask(frames))) + + +def motion_scale(frames) -> float: + """相邻帧平均差异的**绝对**尺度(48×48 灰度)。0.0 = 这些帧逐像素完全一样。 + + 为什么与 :func:`dead_frame_mask` 并存、而不是从它推导:后者两条判据 + (``d[i] < ratio*max(邻居)`` 与 ``d[i] < floor*p75``)**都是相对的**,整段完全 + 冻结时 d 全为 0,两条不等式变成 ``0 < 0``,一条都不成立 —— **一帧死帧都报不出** + (2026-08-09 用全同帧序列实测:12 帧全同,死帧数 0)。相对判据天生看不见"整体 + 没动",绝对尺度必须单独给一个。 + """ + d = frame_deltas(frames) + return float(d[1:].mean()) if len(d) > 1 else 0.0 + + +def loop_seam(frames) -> float | None: + """末帧接回首帧的跳幅 ÷ 相邻帧平均步长;整段静止(分母为 0)返回 ``None``。 + + 与 :func:`.loop.pick_cycle` 选帧时的归一化接缝同式,但**测的对象不同**:pick_cycle + 在抠图 / 像素化 / 脚线对齐**之前**的密集帧上打分,而用户看到的是这三步之后的帧, + 这三步都会改动像素。要描述交付物就得在交付物上量。 + + 不套 :func:`.loop._deskew`:交付帧已被 ``align_bottom_center`` 逐帧居中,整体平移 + 早消掉了,再按差分质心对一次只是引入第二套居中口径(两套口径不一致正是本仓反复 + 踩的那类静默分歧)。 + + 分母为 0 时返回 None 而不是 0.0 —— 0.0 会被读成"完美闭环",而真相是"没有可比的 + 步长,这个数不可读"。 + """ + gs = _gray(frames) + if len(gs) < 2: + return None + step = float(np.mean([np.abs(gs[i + 1] - gs[i]).mean() for i in range(len(gs) - 1)])) + if step <= 0.0: + return None + return float(np.abs(gs[-1] - gs[0]).mean() / step) + + def active_span(frames, floor: float = 0.25, min_run: int = 3) -> tuple[int, int]: """掐掉头尾的**持续**冻结段,返回 [s, e](闭区间)。中间的隔帧死不动。""" d = frame_deltas(frames) From 0248cfbf5d19fe5abfc505dce3d5e962aa2c3d1d Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Wed, 12 Aug 2026 09:58:57 +0800 Subject: [PATCH 05/12] =?UTF-8?q?chore(tests):=20=E5=88=A0=E6=8E=89=20reba?= =?UTF-8?q?se=20=E5=B8=A6=E5=9B=9E=E6=9D=A5=E7=9A=84=20FAL=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FAL 队列面已随 #179 移除,这个测试文件也一并删了。rebase 到新 main 时它被重放回来, 而它引用的 8 个 FAL 符号已不存在 —— 收集期直接 ImportError。 --- .../tests/test_fal_queue_video_provider.py | 452 ------------------ 1 file changed, 452 deletions(-) delete mode 100644 backend/tests/test_fal_queue_video_provider.py 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") From 94b4503a02b4ef01d7938ec3945a0635a63cb4d3 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Fri, 7 Aug 2026 15:39:55 +0800 Subject: [PATCH 06/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。 --- .../src/windup_framework/providers/matte.py | 38 ++- backend/tests/test_matte_provider.py | 40 +++ backend/uv.lock | 296 +----------------- 3 files changed, 75 insertions(+), 299 deletions(-) diff --git a/backend/packages/framework/src/windup_framework/providers/matte.py b/backend/packages/framework/src/windup_framework/providers/matte.py index ffc28866..5695316f 100644 --- a/backend/packages/framework/src/windup_framework/providers/matte.py +++ b/backend/packages/framework/src/windup_framework/providers/matte.py @@ -37,6 +37,30 @@ _KEY_SOFT = 14.0 # 到 _KEY_KILL + _KEY_SOFT 之间线性过渡,避免硬边锯齿 _BG_FLAT_STD = 8.0 # 四角色标准差上限;超过说明底不是纯色,不做任何清理 +# 采样前先丢掉最外圈像素。视频帧的最外一两行/列常是**编码器边缘伪影**,不是底色: +# 2026-08-10 实测 9 段真 i2v 视频 × 16 帧 = 144 帧,贴边采样时 26 帧(18%)判"底不均匀" +# 而跳过清理,逐一查证全部由最外圈造成 —— 白底母版视频最右一列整列纯黑(std 50.4), +# 待机视频最顶一行偏暗(std 8.4,恰好压线越过 8)。往里让 1 px 就降到 1.9、让 2 px 降到 1.88, +# 144 帧零误跳;三张静态母版的取样中位色一个字节都没变(220/64/135、222/39/130、222/41/124)。 +# 取 2 是为容下 2 px 宽的边框;真正不均匀的底(噪声/渐变/拼色)让多少都照样超阈值,守卫不松。 +_EDGE_SKIP = 2 +_CORNER = 12 # 每个角的采样块边长 + + +def _corner_pixels(rgb: np.ndarray) -> np.ndarray: + """四角采样块(跳过最外圈 ``_EDGE_SKIP`` 像素)拼成的 (N, 3) 像素表。 + + 图太小时(四角会互相重叠)不让,退回贴边取 —— 合成测试图和缩略图走这条路。 + """ + k = _CORNER + s = _EDGE_SKIP if min(rgb.shape[:2]) > 2 * (_EDGE_SKIP + k) else 0 + r = rgb[s : rgb.shape[0] - s, s : rgb.shape[1] - s] if s else rgb + return np.concatenate([ + r[:k, :k].reshape(-1, 3), r[:k, -k:].reshape(-1, 3), + r[-k:, :k].reshape(-1, 3), r[-k:, -k:].reshape(-1, 3), + ]) + + # 空洞填充用。_HOLE_ALPHA:低于此 alpha 才算"透明",参与空洞判定。 # _HOLE_BG_TOL:到底色的距离低于此值 → 判为"确实是底色"。取值依据(2026-08-11 实测, # 1280×720 真实视频帧):纯背景区域的色距 p99.9≈6.5、最大 11.1(视频压缩噪点); @@ -50,12 +74,10 @@ def _bg_key(rgb: np.ndarray) -> np.ndarray | None: 抽成独立函数是为了让"底色是什么"只有一个真相源 —— 键控清理(``_flat_bg_penalty``) 和空洞填充(``_fill_enclosed_holes``)必须按同一个 key 判断,否则一个把某块当背景 - 清掉、另一个又把它当主体填回来,互相打架。 + 清掉、另一个又把它当主体填回来,互相打架。取样统一走 :func:`_corner_pixels`, + 连"跳过最外圈编码器伪影"这条也只有一份实现。 """ - corners = np.concatenate([ - rgb[:12, :12].reshape(-1, 3), rgb[:12, -12:].reshape(-1, 3), - rgb[-12:, :12].reshape(-1, 3), rgb[-12:, -12:].reshape(-1, 3), - ]) + corners = _corner_pixels(rgb) # 跳过编码器边缘伪影,见 _EDGE_SKIP if float(corners.std(axis=0).max()) > _BG_FLAT_STD: return None return np.median(corners, axis=0).astype(np.float32) @@ -137,6 +159,12 @@ def _flat_bg_penalty(rgb: np.ndarray) -> np.ndarray: 会被抠穿)。这里主体判据仍然是 u2netp,颜色只用来**做减法** —— 绝不新增主体像素, 最坏情况是少清理一点,不会抠穿角色。底色不够均匀时(std 超阈值)直接返回全 1, 等于不清理。 + + **逐帧独立采样是安全的**(2026-08-10 在真视频帧上验证):同一段视频里逐帧算出的 key 色 + 几乎不动(9 段 i2v 实测帧间位移 <= 1.73/255),故不需要跨帧共享一次采样。序列帧真正的 + 闪烁源是**守卫在序列中途翻转**(部分帧清、部分帧不清):待机那段 16 帧里前 6 帧清、后 10 帧 + 不清,主体面积逐帧变化 CV 从 0.0036 跳到 0.0197、第 6 帧单帧跳 4.25%。跳过最外圈后 + 守卫不再翻转,CV 回到 0.0028 —— 比完全不清理还稳(清理同时抹掉了会自己抖的底色描边)。 """ key = _bg_key(rgb) if key is None: diff --git a/backend/tests/test_matte_provider.py b/backend/tests/test_matte_provider.py index 20837f8c..0ac02076 100644 --- a/backend/tests/test_matte_provider.py +++ b/backend/tests/test_matte_provider.py @@ -96,6 +96,46 @@ def blocked(name, *a, **k): builtins.__import__ = real +# ── 视频帧的最外圈是编码器伪影,不是底色(2026-08-10 实测挣得)────────────── + + +def test_edge_artifact_row_does_not_disable_cleanup(): + """最外一行/列常是编码器伪影:贴边采样会把它算进"底色是否均匀", + 于是整帧被判"底不均匀"而跳过清理——修复在真实路径上等于从不生效。 + + 实测 9 段真 i2v × 16 帧 = 144 帧,贴边采样时 26 帧(18%)因此误跳; + 往里让 2px 后归零。 + """ + import numpy as np + + from windup_framework.providers.matte import _flat_bg_penalty + + bg = (222, 41, 124) + a = np.zeros((80, 80, 3), dtype=np.float32) + a[:, :] = bg + a[:, -1] = (0, 0, 0) # 最右一列纯黑:典型的编码器边缘伪影 + a[0, :] = (180, 30, 100) # 最顶一行偏暗 + p = _flat_bg_penalty(a) + assert p[40, 40] == 0.0, "跳过最外圈后应认出这是纯色底并清理;贴边采样会误判为不均匀" + + +def test_tiny_image_degrades_to_no_cleanup_rather_than_guessing(): + """图小到四角采样块会盖住主体时,采出来的"底色"其实混了主体色, + 此时守卫判"底不均匀"、整体跳过清理。 + + 这是**安全的退化方向**:清理只做减法,跳过等于少清一点;反过来若强行按 + 混了主体色的 key 去清,会把主体本身当背景抠掉——本项目宁可漏,不可误伤。 + """ + import numpy as np + + from windup_framework.providers.matte import _flat_bg_penalty + + a = np.zeros((20, 20, 3), dtype=np.float32) + a[:, :] = (0, 255, 0) + a[8:12, 8:12] = (200, 60, 60) # 主体落在四角采样块的重叠区 + assert (_flat_bg_penalty(a) == 1.0).all(), "采样不可靠时必须整体跳过,而不是按脏 key 清理" + + # ── 封闭空洞填充(2026-08-11 在 121 帧真实走路视频帧上实测挣得)────────────────── # # 背景:交付帧放大看,主体内部会有透明洞(背景直接透出来)。实测拆开成因: diff --git a/backend/uv.lock b/backend/uv.lock index 5e1a6cfd..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,98 +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 = "av" -version = "18.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25" }, - { url = "https://mirrors.aliyun.com/pypi/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44" }, - { url = "https://mirrors.aliyun.com/pypi/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c" }, -] - -[[package]] -name = "bcrypt" -version = "5.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be" }, - { url = "https://mirrors.aliyun.com/pypi/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493" }, - { url = "https://mirrors.aliyun.com/pypi/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10" }, - { url = "https://mirrors.aliyun.com/pypi/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927" }, -] - [[package]] name = "certifi" version = "2026.7.22" @@ -244,105 +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" @@ -352,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" @@ -611,19 +397,6 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" }, ] -[[package]] -name = "imageio" -version = "2.37.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } -dependencies = [ - { name = "numpy" }, - { name = "pillow" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/48/62/aa770a9307508d2a2a2c62d536a49347bffe9e55322db27838d3c93d0b07/imageio-2.37.4.tar.gz", hash = "sha256:e45cbc5e83502047fb138f7f585f7f105a136a57eea5f4b3cfc6ce1b52720bd3" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3e/2d/ca050652104bab2cf55e569db2a178b1b61cb041fef28307f2db383f6d9f/imageio-2.37.4-py3-none-any.whl", hash = "sha256:1ab2e22c8debf700f24c3ac43e8f95f3b3a8110c83b93411e97b4b0b2cd1c7e6" }, -] - [[package]] name = "import-linter" version = "2.13" @@ -1095,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" @@ -1277,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" @@ -1414,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" @@ -1504,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" @@ -1628,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" @@ -2104,8 +1822,6 @@ name = "windup-ai-engine" version = "0.1.0" source = { editable = "packages/ai_engine" } dependencies = [ - { name = "av" }, - { name = "imageio" }, { name = "langchain-core" }, { name = "langgraph" }, { name = "numpy" }, @@ -2116,8 +1832,6 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "av", specifier = ">=14.0" }, - { name = "imageio", specifier = ">=2.36" }, { name = "langchain-core", specifier = ">=0.3" }, { name = "langgraph", specifier = ">=0.2" }, { name = "numpy", specifier = ">=1.26" }, @@ -2132,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"] }, @@ -2144,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" }, @@ -2174,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" }, ] @@ -2194,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 ce364223f85e88fbd1dd597addb14c8587e8e84c Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Sat, 8 Aug 2026 00:24:20 +0800 Subject: [PATCH 07/12] =?UTF-8?q?refactor(ai=5Fengine):=20=E6=94=B6?= =?UTF-8?q?=E6=8B=A2=20PNG=20bytes=20=E2=86=94=20PIL=20=E7=9A=84=E8=BD=AC?= =?UTF-8?q?=E6=8D=A2=EF=BC=8C=E5=B9=B6=E9=87=8D=E6=96=B0=20stack=20?= =?UTF-8?q?=E5=88=B0=E4=B8=89=E4=B8=AA=E5=89=8D=E7=BD=AE=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管线内部按 PIL.Image 处理,跨模块边界(strategy → generator → ports 出参)按 PNG bytes 传递。这对转换此前在 strategy/concrete.py 与 impl/character_generator.py 各写了一份完整 拷贝,收成 _imgio.py 唯一定义(to_png / from_png)。编码参数一旦分叉,会在"某些帧丢了 alpha"这类只在画面上体现、不报错的地方出问题。 同步 stack:本分支重新对齐到 feat/character-domain-models、feat/provider-interfaces-and-matte、 feat/ai-engine-frame-toolkit 的当前终态,同名文件与三者逐字节一致。 --- .../ai_engine/src/windup_ai_engine/_imgio.py | 26 +++ .../impl/character_generator.py | 91 +++++++++++ .../src/windup_ai_engine/strategy/concrete.py | 150 ++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/_imgio.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py diff --git a/backend/packages/ai_engine/src/windup_ai_engine/_imgio.py b/backend/packages/ai_engine/src/windup_ai_engine/_imgio.py new file mode 100644 index 00000000..acec47d6 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/_imgio.py @@ -0,0 +1,26 @@ +"""PNG bytes ↔ PIL 的唯一转换口。 + +管线内部按 ``PIL.Image`` 处理,跨模块边界(strategy → generator → ports 出参)按 PNG +bytes 传递。这对转换此前在 ``strategy.concrete`` 与 ``impl.character_generator`` 各写 +了一份,收成一处 —— 编码参数(如是否强制 RGBA)一旦分叉,会在"某些帧丢了 alpha"这类 +只在画面上体现、不报错的地方出问题。 +""" +from __future__ import annotations + +import io + +from PIL import Image + +__all__ = ["to_png", "from_png"] + + +def to_png(img: Image.Image) -> bytes: + """PIL → PNG bytes。统一转 RGBA:下游脚线对齐靠 alpha 求包围盒。""" + buf = io.BytesIO() + img.convert("RGBA").save(buf, "PNG") + return buf.getvalue() + + +def from_png(png: bytes) -> Image.Image: + """PNG bytes → RGBA 图。""" + return Image.open(io.BytesIO(png)).convert("RGBA") diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py new file mode 100644 index 00000000..bb844684 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -0,0 +1,91 @@ +"""CharacterGenerator —— 装配 strategy + 最后一公里,串起整条生产线(架构串联点)。 + +这是 CharacterGeneratorPort 的实现;server 经 port 调它、不碰这里。 +串联:选路线(ROUTE_MATRIX)→ strategy.derive 出帧 → 最后一公里(脚线对齐)→ GeneratedAction。 + +MVP 边界(与作者对齐):**只出帧 bytes + 逐帧时长**,不打包 sprite sheet、不落存储—— +上传对象存储、写 character_data、拼图集/多格式导出由 server / export 侧做(#22)。 +""" +from __future__ import annotations + + + +from windup_common.models import ActionSpec, CharacterCard, GenRoute + +from windup_ai_engine._imgio import from_png as _img +from windup_ai_engine._imgio import to_png as _png +from windup_ai_engine.ports import ( + CharacterGeneratorPort, + GeneratedAction, + ProgressPort, +) +from windup_ai_engine.postprocess import align_bottom_center, frame_durations +from windup_ai_engine.strategy.base import ROUTE_MATRIX, DerivationStrategy + + + + +class CharacterGenerator(CharacterGeneratorPort): + """由 bootstrap 注入 {GenRoute: DerivationStrategy} 装配表。""" + + def __init__(self, strategies: dict[GenRoute, DerivationStrategy]) -> None: + self._by_route = strategies + + def generate( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> GeneratedAction: + # ① 选路线(架构决策矩阵)。装配表里没有 = 该路线未实现,在边界上炸, + # 不要让"看着成功、内容是空"的结果流到 server 去落库。 + route = ROUTE_MATRIX[action.action] + progress.step("route", 0, 3, f"{action.action} → {route.value}") + strategy = self._by_route.get(route) + if strategy is None: + raise NotImplementedError( + f"动作 {action.action.value} 分流到 {route.value},但未注入该路线的 strategy。" + f"已装配:{sorted(r.value for r in self._by_route)}。" + ) + + # ② 生成帧(交给 strategy —— 串联) + frames = strategy.derive(card, action, master, progress) + + # ③ 最后一公里:脚线对齐成原地序列帧 + frames = self._lastmile(frames, progress) + + # ④ 出参:帧 + 逐帧时长(上传 / 落库在 server 侧) + progress.step("package", 2, 3, f"{len(frames)} 帧 + 逐帧时长") + return GeneratedAction( + frames=frames, + durations=frame_durations(action.action.value, len(frames)), + fps=action.fps, + ) + + def _lastmile(self, frames: list[bytes], progress: ProgressPort) -> list[bytes]: + """脚线对齐:把各帧对齐成原地序列帧(消除逐帧画布漂移,Issue #21)。 + + 位移轨道(root_motion)MVP 先不做(见 #63 / character_data.frames 暂无该字段): + 序列帧保持原地即可,位移留给后续 export / playtest 阶段再算。 + """ + progress.step("lastmile", 1, 3, "脚线对齐(原地)") + # 空帧不再静默跳过:未实现的路线现在在 strategy / 装配表处就抛错(见 generate), + # 走到这里还有空帧说明 provider 或抠图吐了坏数据,同样要炸而不是原样放行。 + if not frames: + raise ValueError("strategy 未产出任何帧") + bad = [i for i, f in enumerate(frames) if not f] + if bad: + raise ValueError(f"strategy 产出了 {len(bad)}/{len(frames)} 个空帧,索引 {bad[:8]}") + imgs = [_img(f) for f in frames] + # 参考姿态高 = 各帧包围盒高的中位数:比"最高帧"稳(不被举过头顶的武器带偏), + # 各动作都以自身中位姿态定标,本体尺寸跨动作一致。 + import numpy as _np + _hs = [] + for _im in imgs: + _ys, _ = _np.where(_np.asarray(_im)[:, :, 3] > 128) + if len(_ys): + _hs.append(float(_ys.max() - _ys.min())) + aligned = align_bottom_center(imgs, ref_height=(float(_np.median(_hs)) if _hs else None)) + # TODO(dev, #21): tail_match 循环闭合(净位移动作先锚点再匹配帧) + return [_png(im) for im in aligned] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py new file mode 100644 index 00000000..8da638a8 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py @@ -0,0 +1,150 @@ +"""三条 DerivationStrategy。 + +- VideoFrameStrategy:**已迁入 windup-pipeline 实测通路**(walk 主链,2026-07-27 验证)。 +- PerFrameStrategy:**未实现**,调用即抛 NotImplementedError(见 #53)。不返回空帧—— + 空帧会伪装成一次成功的生成流到 server 落库,用户看到的是一组裂图。 + +VideoFrameStrategy 实测通路:严格侧面母版 → kling i2v(v2-5-turbo) → 抽单循环 N 帧 → +matte 抠图 → 像素化。返回对齐前的 RGBA PNG 帧(对齐 / 打包在 CharacterGenerator 最后一公里)。 +""" +from __future__ import annotations + + +import numpy as np + +from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute +from windup_framework.providers import ImageProvider, MatteProvider, VideoProvider + +from windup_ai_engine._imgio import from_png as _img +from windup_ai_engine._imgio import to_png as _png +from windup_ai_engine.master_prep import prepare_master +from windup_ai_engine.ports import ProgressPort +from windup_ai_engine.postprocess import master_pixel_spec, pixelate_frames +from windup_ai_engine.slicing import extract_all_frames_bytes, pick_cycle, pick_oneshot +from windup_ai_engine.prompt import ( + build_attack_prompt, + build_idle_prompt, + build_jump_prompt, + build_walk_prompt, +) +from windup_ai_engine.strategy.base import DerivationStrategy + + + + +# 循环类动作走"步态周期抽单周期闭环";一次性动作**不能闭环**(首尾姿态不同,强行闭环 +# 会把落地帧接回蓄力帧=抽搐),改走"裁动作区间 + 区间内均匀取"。 +CYCLIC_ACTIONS = frozenset({ActionType.IDLE, ActionType.WALK, ActionType.RUN}) + + +class VideoFrameStrategy(DerivationStrategy): + """视频路线:母版 → i2v → 抽帧 → 抠图 → 像素化。 + + 覆盖循环类(walk/run)与一次性类(jump/attack)——按 :data:`CYCLIC_ACTIONS` 分流抽帧方式。 + 硬前提:**提示词朝向必须与母版一致**(side/front);给正面母版喂侧走词会让模型靠转身 + 调和图文矛盾(实测 #35)。 + """ + + route = GenRoute.VIDEO_I2V + + def __init__(self, video: VideoProvider, matte: MatteProvider) -> None: + self._video = video + self._matte = matte + + def _build_prompt(self, action: ActionSpec) -> str: + """按动作类型选提示词;朝向随 ActionSpec.facing。""" + builders = { + ActionType.JUMP: build_jump_prompt, + ActionType.IDLE: build_idle_prompt, + ActionType.ATTACK: build_attack_prompt, + } + build = builders.get(action.action, build_walk_prompt) + return build(facing=action.facing) + + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + n = action.n_frames or 8 + progress.step("derive", 0, 3, f"{action.action}: i2v 生成视频") + # 母版按动作预处理:jump 要在顶部补空间,否则角色腾空时头顶顶出视频画面被裁 + framed = prepare_master(master, action.action.value) + video = self._video.i2v(framed, self._build_prompt(action), seconds=5) + + dense = extract_all_frames_bytes(video) + # 跨动作一致性:用视频首帧(=母版姿态)的角色高当共同定标基准。各动作都从同一母版 + # 起手,故此值一致 —— 否则各动作按自己最高帧定标,切状态时角色会忽大忽小。 + ref_h = None + if dense: + _first = _img(self._matte.cutout(_png(dense[0]))) + _ys, _ = np.where(np.asarray(_first)[:, :, 3] > 128) + ref_h = float(_ys.max() - _ys.min()) if len(_ys) else None + if action.action in CYCLIC_ACTIONS: + progress.step("derive", 1, 3, f"步态周期取 {n} 帧(无缝 loop)+ 抠图") + picked = pick_cycle(dense, n) # 单周期闭环(#21) + else: + progress.step("derive", 1, 3, f"裁动作区间取 {n} 帧(不闭环)+ 抠图") + kind = "airborne" if action.action is ActionType.JUMP else "swing" + picked = pick_oneshot(dense, n, kind=kind) # 一次性动作:裁起止 + cut = [_img(self._matte.cutout(_png(im))) for im in picked] + + # 风格化按需(见 ActionSpec.stylize):none=保留 i2v 画风(插画/伪 3D 角色); + # pixel=像素化。原生像素角色**按母版规格**做:吸附母版像素网格 + 锁母版色板, + # 顺带消掉首帧 JPG / H.264 在硬边留下的灰颗粒(实测:通用降采样+量化反而更糊)。 + if action.stylize == "none": + progress.step("derive", 2, 3, "保留 i2v 画风(不像素化)") + return [_png(im) for im in cut] + + target_h, palette = action.pixel_h, None + try: + logical_h, pal = master_pixel_spec(_img(master)) # 用原始母版,不用补过边的 + if logical_h > 8: # 母版确为像素画 → 按它的规格走 + target_h, palette = logical_h, pal + except Exception: # 母版非像素画/量不出 → 回退通用量化 + pass + progress.step( + "derive", 2, 3, + f"像素化(h={target_h}{'·锁母版色板' if palette is not None else '·通用量化'})", + ) + pix = pixelate_frames( + cut, target_h=target_h, palette_size=action.palette_size, + palette=palette, ref_height=ref_h, + ) + return [_png(p) for p in pix] + + +class PerFrameStrategy(DerivationStrategy): + """离散姿势(hit 等,需单帧可编辑):逐帧图生图 → 抠图。**未实现**(#53)。 + + 这条路线的价值在"单帧可重画",与 i2v 是不同的产品能力,不能拿 i2v 顶替。 + """ + + route = GenRoute.PER_FRAME + + def __init__(self, image: ImageProvider, matte: MatteProvider) -> None: + self._image = image + self._matte = matte + + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + # 显式抛错,**不返回空帧**。曾经的桩实现 `return [b""] * n_frames` 会让调用方拿到 + # 一个"帧数对、时长对、无异常"的 GeneratedAction —— server 照常把 N 个 0 字节文件 + # 传上对象存储、写进 character_data,用户看到 N 张裂图,且排查时不会想到是路线没实现。 + # 未实现就要在边界上炸,不能让空数据流下去。 + raise NotImplementedError( + f"生成路线 {self.route.value} 尚未实现(动作 {action.action.value})。" + "见 1024XEngineer/Windup#53。" + ) + + +# 注:曾有 ProcIdleStrategy(GenRoute.PROC_IDLE)—— 待机走"母版抠图 + 程序化局部躯干呼吸" +# 的零 API 路线(Idle-B,#53 原设计)。**2026-08-07 定案放弃**:程序化呼吸做不出可用效果, +# idle 统一走 i2v、认这份钱。GenRoute.PROC_IDLE 一并移除,不留没有实现的枚举值。 From cc048f255ee7cca62c0615e281086d32019a4c30 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 14:10:40 +0800 Subject: [PATCH 08/12] =?UTF-8?q?feat(ai=5Fengine):=20=E6=AF=8D=E7=89=88?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E9=A2=84=E6=A3=80=20+=20=E5=87=BA=E5=8F=82?= =?UTF-8?q?=E6=88=90=E8=89=B2=E4=BF=A1=E5=8F=B7=20+=20=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E7=BC=96=E7=A0=81=E5=99=A8=E8=BE=B9=E7=BC=98?= =?UTF-8?q?=E4=BC=AA=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两头各加一道闸,方向相反:进门那道在**花钱之前**挡住不可能生成好的输入; 出门那道在钱已花完之后,让上层看得出"这次生成得怎么样"。 此前 ports 与 impl 里所有 raise 都在输出侧,对 master 不做任何前置判定。 2026-08-07 实测:喂一张"人物在画板前作画"的图请求 walk,全程无一处报错, 16 帧构图完整的错角色出完、钱花完。 check_master 判三类**本地零成本可判**的形态问题,不通过抛 MasterRejected: - UNDECODABLE 不是图 / 截断 - NO_SUBJECT 全透明或全同色,没有可动的东西 - SUBJECT_TOO_SMALL 包围盒最短边 < 8px(放大 20 倍是色块不是角色), 或主体占比 < 0.1%(对角散落两粒噪点会把包围盒撑到整幅,边长检查全过) - ASPECT_TOO_WIDE 主体 w/h 超阈值,方形画布只能把角色硬缩成一条 REJECT_ASPECT 由交付画布几何推出(2*FILL_W/FILL_H)而非拍脑袋,并有测试锁住 这个推导关系——改了 pack.py 的填充比而这里不动,预检会放行一批下游装不下的母版。 MasterRejected 带机器可读的 code:server 据此选文案、判 4xx-不重试,与 NotImplementedError / 其他 ValueError(引擎侧问题,5xx,要人介入)分工明确。 判不了的(画的是不是角色、朝向对不对)不在此列,模块 docstring 写清"本层不判什么"。 GeneratedAction 此前只能表达"生成完了",不能表达"生成得怎么样":一段每帧都一样的 walk 与一段步态干净的 walk,帧数 / 时长 / fps 完全相同,调用方分辨不出。 三个字段各自不可由其他两个推导: - motion_scale 相邻帧差的**绝对**尺度。必须单独给:dead_frame_mask 两条判据都是 相对的,整段冻结时 d 全为 0、两条不等式变成 0<0,一帧死帧都报不出(实测 12 帧 全同报 0 死帧)——相对判据天生看不见"整体没动"。 - dead_frames 死帧下标(不是 numpy 掩码:跨出 ai_engine 的契约要"哪几帧") - loop_seam 末帧接回首帧的跳幅 ÷ 相邻帧平均步长。在**对齐之后**量,量的是用户真正 看到的那组帧;分母为 0 返回 None 而不是 0.0——0.0 会被读成"完美闭环"。 一次性动作(jump/attack)不给:首尾姿态本就不同,给个必然难看的数会诱导错误决定。 刻意没有糊帧率:2026-08-05 实测 6 段真 i2v 没有一帧糊帧,加进来是恒等于 1 的常数。 引擎只如实报数、不代替上层判决:交付 / 重试 / 换母版是产品决策,阈值该由 server 按 场景定;且到这一步钱已花完,引擎单方面丢弃产物只是把损失变成两份。 底色采样此前贴边取。视频帧最外一两行/列常是**编码器边缘伪影**而非底色:实测 9 段真 i2v × 16 帧 = 144 帧,贴边采样时 26 帧(18%)被判"底不均匀"而跳过清理——底色清理在 真实路径上等于从不生效。逐一查证全部由最外圈造成(某视频最右一列整列纯黑 std 50.4, 待机视频最顶一行 std 8.4 恰好压线越过 8)。往里让 2px 后 144 帧零误跳,三张静态母版的 取样中位色一个字节未变。 17 条新用例。变异测试 6/6 全部被捕获:阈值改成硬编码、去掉占比检查、去掉最短边检查、 motion_scale 恒返回 1、loop_seam 分母为 0 时返回 0.0、贴边采样。 其中"去掉最短边检查"最初**没被杀**——样本用的小方块占比也不达标,占比那条接住了它。 换成细长条(占比 1.3% 远超下限,只有边长这条能拦)后才真正独立。写完就绿的测试等于没写。 --- .../src/windup_ai_engine/_subject.py | 68 +++++++ .../src/windup_ai_engine/master_check.py | 139 ++++++++++++++ .../src/windup_ai_engine/master_prep.py | 10 +- .../src/windup_ai_engine/ports/__init__.py | 161 ++++++++++++++++ .../src/windup_ai_engine/slicing/__init__.py | 9 + .../src/windup_ai_engine/strategy/__init__.py | 12 ++ .../src/windup_ai_engine/strategy/base.py | 66 +++++++ .../src/windup_ai_engine/strategy/concrete.py | 9 +- .../tests/test_master_check_and_quality.py | 177 ++++++++++++++++++ 9 files changed, 635 insertions(+), 16 deletions(-) create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/_subject.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/master_check.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py create mode 100644 backend/tests/test_master_check_and_quality.py diff --git a/backend/packages/ai_engine/src/windup_ai_engine/_subject.py b/backend/packages/ai_engine/src/windup_ai_engine/_subject.py new file mode 100644 index 00000000..bf83ab29 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/_subject.py @@ -0,0 +1,68 @@ +"""「哪些像素是主体」的唯一定义(母版预检 / 脚线 / 补边背景色共用)。 + +此前这套判据有两份:``master_prep._bg_color`` 取四角中位色补边, +``slicing.oneshot._subject_rows`` 用同一套四角中位色 + 容差找脚线。入口预检 +(:mod:`.master_check`)必须与下游用**同一个**主体定义 —— 判据一旦分叉就会出现 +"预检说有主体、下游找不到主体"这种只在画面上体现、不报错的分歧,和 +:mod:`._imgio` / :mod:`.slicing._frames` 当初被收拢是同一个理由。 + +判据本身:有真 alpha(存在低于阈值的像素)就用 alpha;整幅不透明(原始视频帧 / +RGB 母版)则按四角中位背景色的差值。**这是颜色启发式,不是抠图模型** —— +背景带渐变、或角色与背景同色时判不准,见 :func:`subject_mask`。 +""" +from __future__ import annotations + +import numpy as np +from PIL import Image + +__all__ = ["bg_color", "subject_bbox", "subject_mask"] + +ALPHA_THR = 128 # alpha 高于此值算不透明(与 postprocess.pack 求包围盒的口径一致) +BG_TOL = 60 # 与背景色的 RGB 绝对差之和,超过才算主体 + + +def _bg_median(rgb: np.ndarray) -> np.ndarray: + """四角中位色(float)。母版 / 视频帧通常是纯色底,四角取中位比取均值抗单角污染。""" + corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) + return np.median(corners, axis=0) + + +def bg_color(img: Image.Image) -> tuple[int, int, int]: + """背景色(取整),给补边用。""" + rgb = np.asarray(img.convert("RGB")) + return tuple(int(v) for v in _bg_median(rgb)) + + +def subject_mask( + img: Image.Image, alpha_thr: int = ALPHA_THR, bg_tol: int = BG_TOL +) -> np.ndarray: + """主体像素的二维布尔掩码。 + + 必须兼容**不透明**输入:抽帧阶段拿到的是原始视频帧,还没抠图,只看 alpha 会把 + 整幅当主体、脚线恒定,腾空判据立刻误判"已落地"(实测踩过,跳跃被裁在起跳前)。 + + 判不准的已知情形(调用方别当成抠图):背景有渐变 → 整幅都超容差,掩码≈全 True; + 角色主色与背景色接近 → 那部分身体被判成背景。要真分割请走 MatteProvider。 + """ + arr = np.asarray(img.convert("RGBA")) + alpha = arr[:, :, 3] + if not alpha.min() > alpha_thr: # 存在透明像素 = 有真 alpha,直接用 + return alpha > alpha_thr + rgb = arr[:, :, :3].astype(np.int16) + return np.abs(rgb - _bg_median(rgb)).sum(axis=2) > bg_tol + + +def subject_bbox( + img: Image.Image, alpha_thr: int = ALPHA_THR, bg_tol: int = BG_TOL +) -> tuple[tuple[int, int, int, int], int] | None: + """主体包围盒 ``(x0, y0, x1, y1)``(半开,同 PIL crop)+ 主体像素数;无主体返回 None。 + + 包围盒与像素数一起返回:两者判的不是同一件事 —— 包围盒管"主体有多大", + 像素数管"包围盒里是不是真有东西"(散落的几粒噪点能把包围盒撑满整幅)。 + """ + m = subject_mask(img, alpha_thr, bg_tol) + ys, xs = np.where(m) + if not len(ys): + return None + box = (int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1) + return box, int(m.sum()) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_check.py b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py new file mode 100644 index 00000000..f91a4591 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py @@ -0,0 +1,139 @@ +"""母版可生成性预检 —— 入口处**允许拒绝**的那道闸,在花钱之前。 + +为什么有这个模块:ai_engine 此前所有 ``raise`` 都在输出侧,``generate(card, action, +master, progress)`` 对 ``master`` 一个前置判定都没有。2026-08-07 实测:喂一张"人物在 +画板前作画"的图请求 walk,全程无一处报错,最终产出 16 帧构图完整的序列帧,画面是个 +不会走路的错角色 —— 钱已花完才发现。 + +**本层判什么(三条,全部本地零成本、可复现):** + ① 能否解码 —— 坏 bytes / 截断文件不必等 i2v 跑完再发现; + ② 有没有可动的主体 —— 全透明 / 全同色 = 画面里没有东西可动; + ③ 主体宽高比下游装不装得下 —— 见 :data:`REJECT_ASPECT`。 + +**本层不判什么、为什么 —— 别把下面这些当成已经守住了:** + - **画的是不是一个角色、是不是该动作要的姿态**(walk 要侧向、attack 要蓄力,见 + :data:`master_prep.MASTER_POSES`)。需要视觉模型读画面语义,本层只有 numpy。 + **开头那张"人物在画板前作画"的图,本预检拦不住**:它能解码、有主体、比例正常。 + 本层挡的是它的近邻(空图 / 坏图 / 极端比例),挡不住"内容画错"。要真正堵住这个, + 得在预检里接一次廉价的视觉判定(便宜的 VLM 问一句"这是不是一个可行走的角色、 + 朝向是不是侧面"),那是另一件事、要另外的实测与预算。 + - **朝向与 ``ActionSpec.facing`` 是否一致** —— 同上,需要视觉模型。 + - **背景干不干净到能抠图** —— 抠图是 ``MatteProvider``(rembg/u2net)的事;本层的 + 四角中位色启发式判不出"这块背景 rembg 能不能抠掉"。 + - **分辨率下限** —— 故意不判。i2v 供应商对首帧分辨率的真实下限我没有实测数据, + 拍一个阈值就是拿没验证的判据挡掉用户的钱。:data:`MIN_SUBJECT_SIDE` 只挡退化端 + (小到与噪点无从区分),不是画质阈值。 + +纯 PIL / numpy,零 API,不联网。 +""" +from __future__ import annotations + +import io +from dataclasses import dataclass + +from PIL import Image, UnidentifiedImageError + +from windup_ai_engine._subject import subject_bbox +from windup_ai_engine.ports import MasterRejectCode, MasterRejected +from windup_ai_engine.postprocess.pack import FILL_H, FILL_W + +__all__ = ["MIN_SUBJECT_AREA_RATIO", "MIN_SUBJECT_SIDE", "REJECT_ASPECT", + "MasterFacts", "check_master"] + +# 主体宽高比上限。**由交付画布的几何推出,不是拍的**:align_bottom_center 按高定标 +# (cell*FILL_H);主体 w/h 超过 FILL_W/FILL_H(≈1.55)后宽度兜底接管,交付主体高度 +# 退化成 cell*FILL_W/(w/h)。取"退化到目标高度的一半"为界: +# FILL_W / R < FILL_H / 2 ⇒ R > 2*FILL_W/FILL_H ≈ 3.1 +# 再宽就不是"缩小了一点",是把角色压成一条。pack.py 记的实测(2026-08-05):w/h=1.78 +# 的狐狸母版丢 27px、w/h=2.0 只剩 79.9% 内容 —— 那还在兜底能救的区间内(交付变矮), +# 3.1 以上则是"硬缩到没法看"。与其硬缩出一个能落库的错产物,不如在花钱前退回去。 +REJECT_ASPECT = 2 * FILL_W / FILL_H + +# 主体包围盒的最短边下限。下游 align_bottom_center 会把包围盒裁出来、NEAREST 放大到 +# cell*FILL_H≈159px;8px 放大 20 倍是色块不是角色。更要紧的是:这么小的一块,四角 +# 中位色启发式**区分不了它是主体还是一粒压缩噪点/水印**,判"有主体"本身就不成立。 +MIN_SUBJECT_SIDE = 8 + +# 主体像素占画幅的下限。与上一条判的不是同一件事:包围盒管"主体有多大",占比管 +# "包围盒里是不是真有东西" —— 画面对角散落两粒噪点会把包围盒撑到整幅,边长检查全过, +# 占比只有百万分之几。千分之一对真角色是极宽松的下限(侧视角色通常占百分之几以上)。 +MIN_SUBJECT_AREA_RATIO = 0.001 + + +@dataclass(frozen=True) +class MasterFacts: + """预检**量到**的母版形态。返回它而不是只返 None:通过时这些数进进度文案, + 出问题时(比如误拒)一眼看得出引擎当时把什么当成了主体。""" + + size: tuple[int, int] # 母版画布 (w, h) + subject_box: tuple[int, int, int, int] # 主体包围盒 (x0, y0, x1, y1),半开 + subject_ratio: float # 主体 w/h + subject_area_ratio: float # 主体像素 / 画幅像素 + + def note(self) -> str: + """给 ProgressPort 的一行摘要(会经 server 变成用户看到的进度文案)。""" + w, h = self.size + x0, y0, x1, y1 = self.subject_box + return (f"母版 {w}×{h},主体 {x1 - x0}×{y1 - y0}" + f"(w/h {self.subject_ratio:.2f},占幅 {self.subject_area_ratio:.1%})") + + +def _decode(master: bytes) -> Image.Image: + """解码母版;坏 bytes 直接拒。 + + 必须 ``load()`` 强制解完:``Image.open`` 只读文件头,截断的 PNG 在 open 处不报错, + 要到下游某个 ``convert`` / ``np.asarray`` 才炸 —— 那时 i2v 的钱已经花了。 + """ + if not master: + raise MasterRejected(MasterRejectCode.UNDECODABLE, "母版为空 bytes") + try: + img = Image.open(io.BytesIO(master)) + img.load() + return img.convert("RGBA") + except (UnidentifiedImageError, OSError, ValueError) as exc: + raise MasterRejected( + MasterRejectCode.UNDECODABLE, f"解不开这张图({type(exc).__name__}: {exc})" + ) from exc + + +def check_master(master: bytes) -> MasterFacts: + """母版可生成性预检。通过返回量到的形态,不通过抛 :class:`MasterRejected`。 + + 只看母版本身,不看 ``ActionSpec``:三条判据都是"下游画布装不装得下 / 有没有东西可 + 动",与动作类型无关。动作相关的母版要求(侧向 / 蓄力姿态)本层判不了,见模块 docstring。 + """ + img = _decode(master) + w, h = img.size + found = subject_bbox(img) + if found is None: + raise MasterRejected( + MasterRejectCode.NO_SUBJECT, + f"{w}×{h} 的图里找不到主体(全透明或全同色),没有可动的东西", + ) + box, pixels = found + bw, bh = box[2] - box[0], box[3] - box[1] + facts = MasterFacts( + size=(w, h), + subject_box=box, + subject_ratio=bw / bh, + subject_area_ratio=pixels / max(1, w * h), + ) + if min(bw, bh) < MIN_SUBJECT_SIDE: + raise MasterRejected( + MasterRejectCode.SUBJECT_TOO_SMALL, + f"主体包围盒只有 {bw}×{bh}px(下限 {MIN_SUBJECT_SIDE}px)," + "与一粒噪点/水印无从区分", + ) + if facts.subject_area_ratio < MIN_SUBJECT_AREA_RATIO: + raise MasterRejected( + MasterRejectCode.SUBJECT_TOO_SMALL, + f"主体只占画幅 {facts.subject_area_ratio:.4%}" + f"(下限 {MIN_SUBJECT_AREA_RATIO:.1%}),像散落的噪点而不是角色", + ) + if facts.subject_ratio > REJECT_ASPECT: + raise MasterRejected( + MasterRejectCode.ASPECT_TOO_WIDE, + f"主体 w/h={facts.subject_ratio:.2f} 超过 {REJECT_ASPECT:.2f};" + "下游是方形画布,再宽只能把角色硬缩成一条,请换一张主体没这么扁的母版", + ) + return facts diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py index ed2652e3..b4e6d3fe 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py @@ -20,9 +20,10 @@ import io -import numpy as np from PIL import Image +from windup_ai_engine._subject import bg_color as _bg_color + __all__ = ["add_headroom", "prepare_master", "MASTER_POSES"] # 各动作所需的母版姿态(生成专用母版时的姿势描述)。空=可直接用中性站立母版。 @@ -48,13 +49,6 @@ } -def _bg_color(img: Image.Image) -> tuple[int, int, int]: - """取四角中位色当背景色(母版通常是纯色底)。""" - rgb = np.asarray(img.convert("RGB")) - corners = np.stack([rgb[0, 0], rgb[0, -1], rgb[-1, 0], rgb[-1, -1]]) - return tuple(int(v) for v in np.median(corners, axis=0)) - - def add_headroom(master: bytes, ratio: float = 0.6) -> bytes: """在母版上方补空间,让角色坐到画面下部,给腾空留出余量。 diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py new file mode 100644 index 00000000..44d36355 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py @@ -0,0 +1,161 @@ +"""ai_engine 对外契约(ports)—— server 只 import 这里,不碰 slicing / strategy / impl。 + +CI 的 import-linter 分层门禁会强制:app.server 依赖只到 ai_engine.ports。 +换掉内部实现(strategy / provider)时 server 零改动。 + +MVP 边界(与作者对齐):ai_engine **只产出帧 bytes + 进度**,不碰存储 / DB。 +母版(master)由 server 侧从 ``Character.reference_image_url`` 取好、以 bytes 传入; +产出的帧由 server 侧上传对象存储、落 ``character_data``。故本层无 ArtifactStore 依赖。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Protocol, runtime_checkable + +from windup_common.models import ActionSpec, CharacterCard + + +# ---- server 实现、注入给 ai_engine 的进度回调 port ---- +class ProgressPort(Protocol): + """进度上报 —— server 转 SSE / 轮询状态(取代管线里的 print)。""" + + def step(self, stage: str, i: int, total: int, note: str = "") -> None: ... + + +# ---- 入口拒绝(在花钱之前)---- +class MasterRejectCode(str, Enum): + """母版被拒的原因 —— server 据此选文案,别用异常消息做分支(消息会改)。 + + 取值全部是**本地零成本可判**的形态问题;判不了的(画的是不是角色、朝向对不对) + 不在此列,见 :mod:`windup_ai_engine.master_check` 的"本层不判什么"。 + """ + + UNDECODABLE = "undecodable" # 不是图 / 截断 / 编码不支持 + NO_SUBJECT = "no_subject" # 全透明或全同色:没有可动的东西 + SUBJECT_TOO_SMALL = "subject_too_small" # 主体小到与噪点/水印无从区分 + ASPECT_TOO_WIDE = "aspect_too_wide" # 主体太扁,方形 cell 里只能压成一条 + + +class MasterRejected(ValueError): + """母版不具备可生成性,在**调用付费模型之前**拒绝。 + + 与 ai_engine 其他异常的分工(这条分工是给 server 用的): + - ``MasterRejected`` = **调用方的输入不行**,同一张母版重试多少次都一样。 + server 应映射成 4xx、把 ``code`` 翻成"请换一张母版"类文案,**不要重试**。 + - ``NotImplementedError`` / 其他 ``ValueError`` = 引擎侧装配或产出出了问题 + (路线没注入、strategy 吐空帧、帧数对不上),属于 5xx、要人介入, + 让用户换母版是把锅甩错地方。 + """ + + def __init__(self, code: MasterRejectCode, detail: str) -> None: + super().__init__(f"母版不可用({code.value}):{detail}") + self.code = code + self.detail = detail + + +# ---- ai_engine 出参(不含存储引用:上传 / 落库在 server 侧)---- +@dataclass(frozen=True) +class ActionQuality: + """这一次出帧的成色 —— 让上层能判"交付 / 重试 / 让用户换母版"。 + + 没有这个,``GeneratedAction`` 只能表达"生成完了",不能表达"生成得怎么样": + 一段**每帧都一样**的 walk 和一段步态干净的 walk,帧数、时长、fps 完全相同, + 调用方分辨不出 —— 本仓吃过四次的正是这类"看起来成功的错结果"。 + + 三个字段各自不可由其他两个推导(下面逐条说明必要性)。刻意**没有**的字段: + - 糊帧率(``slicing.quality.blur_ratio``):2026-08-05 实测 6 段真 i2v + **没有一帧糊帧**,加进来是个恒等于 1 的常数,上层拿它做不了任何决定。 + 真出现糊帧再加,那时才有阈值可依。 + - 抽帧降级原因(``slicing.pick_cycle`` 的三条退化路径):见该函数 docstring 里 + 记的缺口。降级**对交付物的后果**由 ``loop_seam`` 直接测得,而"降级的原因" + 今天没有任何调用方会据此改变行为,故不塞进出参。 + """ + + motion_scale: float + """交付帧的相邻帧平均差异(48×48 灰度绝对尺度)。**0.0 = N 张同一张图。** + + 上层拿它做的决定:接近 0 → 这不是动画,**不要交付**(退款 / 重试 / 提示母版 + 姿态不适合该动作)。它与 ``dead_frames`` 不重复而是互补 —— ``dead_frames`` + 的两条判据都是相对的(比邻居、比自身 p75),整段完全冻结时全部不成立、 + 一帧死帧都报不出(见 ``slicing.quality.motion_scale`` 的实测说明)。 + """ + + dead_frames: tuple[int, ...] + """与前一帧几乎无变化的帧下标(下标 0 不参与判定:它没有前一帧)。 + + 上层拿它做的决定:``len(dead_frames)/len(frames)`` 偏高 → 用户花 N 帧的钱只拿到 + N-K 个不同姿态,提示重试或换母版。给**下标**而不是个数,是因为分布形态对应两种 + 不同的病、修法不同:连续一段 = 动作停住(母版姿态不对 / 视频后半段衰减), + 隔帧散布 = 有效帧率减半(i2v 复制帧),前者换母版、后者调抽帧密度。 + """ + + loop_seam: float | None + """末帧接回首帧的跳幅 ÷ 相邻帧平均步长。1.0 ≈ 接缝与一个正常帧间步长同量级。 + + 上层拿它做的决定:循环类动作(idle/walk/run)会被引擎反复播放,接缝大就是肉眼 + 可见的"跳一下";超过约 1.2 → 提示重试。取归一化值而不是原始差,是为了让不同 + 动作幅度之间可比。 + + ``None`` = **这个数在本次生成里不可读**,两种情形:一次性动作(jump/attack/hit) + 本就不闭环;或 ``motion_scale`` 为 0(整段静止,连"一个正常步长"都没有,归一化 + 无从谈起)。调用方要区分就看 ``motion_scale``,**不要把 None 当 0.0** —— + 0.0 会被读成"完美闭环",正是本仓忌讳的"貌似合理的默认值"。 + """ + + +@dataclass +class GeneratedAction: + """一个动作的生成产物:对齐后的原地序列帧 + 逐帧时长 + 成色。 + + frames / durations **等长**;server 侧把每帧上传对象存储得 URL,组成 + ``CharacterActionOutput.frames[{index, image_url, duration_ms}]`` 回填 character_data。 + """ + + frames: list[bytes] = field(default_factory=list) # RGBA PNG,按播放序 + durations: list[int] = field(default_factory=list) # 逐帧时长(ms),与 frames 等长 + fps: int = 10 + # 无默认值、且 kw_only 让它能排在有默认值的字段之后:**不给"没测"留缺省**。 + # 给个 None 缺省的话,漏测与"测出来没问题"在调用方看来一模一样,而这个出参的 + # 全部意义就是把这两者分开。 + quality: ActionQuality = field(kw_only=True) + + +# ---- ai_engine 暴露给 server(server 调用的唯一入口)---- +@runtime_checkable +class CharacterGeneratorPort(Protocol): + """生成入口:角色卡 + 动作规格 + 母版 → 帧序列产物。 + + 不关心租户 / 配额 / 任务状态 / 存储(那些在 app.server)。 + + Args: + card: 角色卡。**当前唯一实现的视频路线一个字段都不读**——``git grep 'card\\.'`` + 在 ai_engine 下零命中(2026-08-08 复核)。这不是遗漏:i2v 的角色身份完全由 + ``master`` 这张母版图像承载,身份描述再写一遍反而会和母版打架。本参数是给 + 未实现路线预留的入参:逐帧图生图(#53)要靠 ``name`` / ``desc`` 在每帧提示词里 + 锁一致性,渲染出帧(#81 #122)要靠 ``master_ref`` / ``version`` 定位 3D 资产。 + **调用方不要指望改 card 能影响视频路线的产出。** + action: 动作规格(类型 / 帧数 / 风格化 / 朝向)。视频路线的实际入参在这里: + ``action``、``n_frames``、``facing``、``stylize`` 等。 + master: 定妆母版图 bytes(server 从 reference_image_url 取)。**视频路线的 + 角色一致性靠它,不靠 card。** 进付费模型之前会先过一遍可生成性预检, + 见 Raises。 + progress: 进度回调。 + + Raises: + MasterRejected: 母版形态不可生成(见 :class:`MasterRejectCode`)。**在花钱 + 之前抛**,同一张母版重试无意义 → server 映射 4xx、请用户换母版。 + NotImplementedError: 该动作分流到的路线没有实现或没注入 strategy。 + ValueError: 产出对不上契约(空帧 / 帧数不足)。钱已经花了,但错产物不放行。 + + 出参的 ``GeneratedAction.quality`` 是**必填**的成色读数:帧数对、无异常并不 + 等于产物可用,调用方交付前应据它决定交付 / 重试 / 让用户换母版。 + """ + + def generate( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> GeneratedAction: ... diff --git a/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py index 3489de23..d00d4ed6 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/slicing/__init__.py @@ -2,6 +2,10 @@ 视频路线里"从连续视频里挑出交付用的那几帧"这一步:循环类动作抽单步态周期(无缝 loop),一次性动作裁动作区间。像素化 / 对齐 / 打包在 :mod:`..postprocess`。 + +:mod:`.quality` 原本纯做诊断,现在还兼一份出参职责:交付帧的成色读数 +(``motion_scale`` / ``dead_frame_indices`` / ``loop_seam``)汇成 ``ports.ActionQuality``。 +注意它**仍然不参与选帧** —— 那条消融结论没变,见 :func:`.loop.pick_cycle`。 """ from .extract import extract_all_frames_bytes, extract_frames_bytes @@ -13,12 +17,17 @@ pick_oneshot, split_jump_phases, ) +from .quality import dead_frame_indices, loop_seam, motion_scale __all__ = [ "extract_frames_bytes", "extract_all_frames_bytes", "find_period", "pick_cycle", + # 交付成色的三个读数(汇成 ports.ActionQuality;其余 quality.* 仍是内部诊断) + "dead_frame_indices", + "loop_seam", + "motion_scale", "find_motion_span", "first_action_end", "foot_line_series", diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py new file mode 100644 index 00000000..8435572e --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/__init__.py @@ -0,0 +1,12 @@ +"""strategy:动作 → 生成路线分流(ROUTE_MATRIX)+ 三条 DerivationStrategy。""" + +from .base import CYCLIC_ACTIONS, ROUTE_MATRIX, DerivationStrategy +from .concrete import PerFrameStrategy, VideoFrameStrategy + +__all__ = [ + "ROUTE_MATRIX", + "CYCLIC_ACTIONS", + "DerivationStrategy", + "VideoFrameStrategy", + "PerFrameStrategy", +] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py new file mode 100644 index 00000000..0a1b1c00 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/base.py @@ -0,0 +1,66 @@ +"""DerivationStrategy —— 按动作类型分流到生成路线(本营实测挣得的核心架构决策)。 + +分流依据(有实测证据,非拍脑袋,详见关联 Issue #35 的工程文档): + - 步态位移(walk / run):逐帧独立生成锁不住"哪条腿在前" → 踢踏舞; + 必须走视频 i2v(视频模型天生连贯、腿自然交替)。 + - 动作爆发(attack)与跳跃(jump):同走视频 i2v。但它们是**一次性动作**,抽帧不闭环 + (见本模块 CYCLIC_ACTIONS);jump 还要按状态切段供引擎分段播放。 + - 受击等离散姿势(hit):逐帧图生图(单帧可编辑价值高,无连续步态)。 + - 待机(idle):逐帧生成只抖不呼吸 → 程序化局部呼吸 Idle-B。 + +ROUTE_MATRIX 是人主导的架构契约,改它=改产线,要有实测支撑。 +""" +from __future__ import annotations + +from abc import ABC, abstractmethod + +from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute + +from windup_ai_engine.ports import ProgressPort + +# 动作类型 → 生成路线(架构决策,写死为契约) +ROUTE_MATRIX: dict[ActionType, GenRoute] = { + ActionType.WALK: GenRoute.VIDEO_I2V, + ActionType.RUN: GenRoute.VIDEO_I2V, + ActionType.JUMP: GenRoute.VIDEO_I2V, + ActionType.ATTACK: GenRoute.VIDEO_I2V, + ActionType.HIT: GenRoute.PER_FRAME, + # idle 走 i2v(build_idle_prompt:躯干缓慢起伏呼吸)。 + # **2026-08-07 定案**:#53 原设计的 ¥0 程序化 Idle-B(局部网格呼吸)放弃 —— 做不出 + # 可用效果,idle 认这份 i2v 的钱。GenRoute.PROC_IDLE 与 ProcIdleStrategy 已一并移除。 + ActionType.IDLE: GenRoute.VIDEO_I2V, +} + +# 循环类动作:抽单步态周期闭环。一次性动作**不能闭环**(首尾姿态不同,强行闭环会把 +# 落地帧接回蓄力帧=抽搐),走"裁动作区间 + 区间内均匀取"。 +# 与 ROUTE_MATRIX 并排放在 base 而不是留在 concrete:它同样是「动作类型 → 产线行为」的 +# 契约,且现在有两个消费方 —— strategy.concrete 用它选抽帧方式,impl.CharacterGenerator +# 用它决定交付成色里的 loop_seam 该不该测(不闭环的动作没有"接缝"可言)。放在 concrete +# 会让 generator 为了问一句"这动作循环吗"去 import 一条具体路线的实现。 +CYCLIC_ACTIONS: frozenset[ActionType] = frozenset( + {ActionType.IDLE, ActionType.WALK, ActionType.RUN} +) + +# 本矩阵的形状本身有个已知边界,记录在此以免后来者按错误前提扩展: +# 它是「动作类型 → 路线」的一对一映射,隐含前提是"路线由动作的物理性质唯一决定"。 +# 该前提对逐帧 / 视频两条路线成立(有无连续步态是动作固有属性),但对渲染出帧路线不成立 +# —— 同一个 walk 既可走 i2v 也可走渲染,选哪条取决于"该角色有没有 3D 模型",那是 server +# 才知道的事。接入第三条路线前须先定「路线选择由谁决定」,并可能要把本矩阵改成 +# 「动作类型 → 可选路线集合」+ 一个选择器。Refs 1024XEngineer/Windup#81 #122。 + + +class DerivationStrategy(ABC): + """一条生成路线的骨架:母版 → 对齐前的角色帧序列。""" + + route: GenRoute + + @abstractmethod + def derive( + self, + card: CharacterCard, + action: ActionSpec, + master: bytes, + progress: ProgressPort, + ) -> list[bytes]: + """从母版 bytes 产出对齐前的角色帧(RGBA PNG bytes 列表)。""" + raise NotImplementedError diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py index 8da638a8..2f0b8ab9 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py @@ -27,14 +27,7 @@ build_jump_prompt, build_walk_prompt, ) -from windup_ai_engine.strategy.base import DerivationStrategy - - - - -# 循环类动作走"步态周期抽单周期闭环";一次性动作**不能闭环**(首尾姿态不同,强行闭环 -# 会把落地帧接回蓄力帧=抽搐),改走"裁动作区间 + 区间内均匀取"。 -CYCLIC_ACTIONS = frozenset({ActionType.IDLE, ActionType.WALK, ActionType.RUN}) +from windup_ai_engine.strategy.base import CYCLIC_ACTIONS, DerivationStrategy class VideoFrameStrategy(DerivationStrategy): diff --git a/backend/tests/test_master_check_and_quality.py b/backend/tests/test_master_check_and_quality.py new file mode 100644 index 00000000..bb5ada37 --- /dev/null +++ b/backend/tests/test_master_check_and_quality.py @@ -0,0 +1,177 @@ +"""母版入口预检 + 出参成色信号。 + +两头各一道闸,方向相反:进门那道在**花钱之前**挡住不可能生成好的输入; +出门那道在钱已花完之后,让上层看得出"这次生成得怎么样"。 + +2026-08-07 的教训:喂一张"人物在画板前作画"的图请求 walk,全程无一处报错, +16 帧构图完整的错角色出完、钱花完。而一段每帧都一样的 walk 与一段步态干净的 walk, +帧数 / 时长 / fps 完全相同,调用方分辨不出。 +""" +from __future__ import annotations + +import io + +import pytest +from PIL import Image + +from windup_ai_engine.master_check import ( + MIN_SUBJECT_SIDE, + REJECT_ASPECT, + check_master, +) +from windup_ai_engine.ports import ActionQuality, MasterRejectCode, MasterRejected +from windup_ai_engine.slicing import dead_frame_indices, loop_seam, motion_scale +from windup_ai_engine.postprocess.pack import FILL_H, FILL_W + + +def _png(w: int, h: int, blob: tuple[tuple[int, int, int, int], tuple] | None = None, + bg=(0, 0, 0, 0)) -> bytes: + img = Image.new("RGBA", (w, h), bg) + if blob: + (x0, y0, x1, y1), color = blob + for y in range(y0, y1): + for x in range(x0, x1): + img.putpixel((x, y), color) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +# ── 入口预检:四种拒绝码 ────────────────────────────────────────────────────── + + +def test_undecodable_bytes_rejected_before_spending(): + """坏 bytes 直接炸,不要等 i2v 花完钱才发现输入根本不是图。""" + with pytest.raises(MasterRejected) as e: + check_master(b"not an image at all") + assert e.value.code is MasterRejectCode.UNDECODABLE + + +def test_fully_transparent_has_no_subject(): + with pytest.raises(MasterRejected) as e: + check_master(_png(200, 200)) + assert e.value.code is MasterRejectCode.NO_SUBJECT + + +def test_flat_single_color_has_no_subject(): + """全同色 = 没有可动的东西。不透明但一片死板的图同样该拒。""" + with pytest.raises(MasterRejected) as e: + check_master(_png(200, 200, bg=(120, 90, 60, 255))) + assert e.value.code is MasterRejectCode.NO_SUBJECT + + +def test_subject_smaller_than_min_side_rejected(): + """包围盒最短边不足 → 下游会把它 NEAREST 放大 20 倍,那是色块不是角色。 + + 刻意用**细长条**而不是小方块:细长条的像素占比高达 1.3%(远超 0.1% 下限), + 所以占比那条拦不住它,只有最短边这条能拦。用小方块的话两条判据都会触发, + 删掉任何一条测试都照样绿——那种测试等于没写(2026-08-09 变异测试逮到)。 + """ + thin = MIN_SUBJECT_SIDE - 2 # 6px 宽 + with pytest.raises(MasterRejected) as e: + check_master(_png(300, 300, blob=((100, 40, 100 + thin, 240), (200, 60, 60, 255)))) + assert e.value.code is MasterRejectCode.SUBJECT_TOO_SMALL + + +def test_scattered_specks_pass_side_check_but_fail_area_ratio(): + """对角两粒噪点会把包围盒撑到整幅——边长检查全过,占比才拦得住。 + + 这两条判的不是同一件事,缺了占比这条,一张几乎空白的图会被判成"有主体"。 + """ + # 每粒 10×10=100px(边长过得了 MIN_SUBJECT_SIDE=8),两粒共 200px, + # 占 600×600 的 0.056%,压在 0.1% 下限之下;而包围盒被撑到 ~590×590,边长检查全过。 + img = Image.new("RGBA", (600, 600), (0, 0, 0, 0)) + for (x, y) in ((8, 8), (582, 582)): + for dy in range(10): + for dx in range(10): + img.putpixel((x + dx, y + dy), (200, 60, 60, 255)) + buf = io.BytesIO() + img.save(buf, "PNG") + with pytest.raises(MasterRejected) as e: + check_master(buf.getvalue()) + assert e.value.code is MasterRejectCode.SUBJECT_TOO_SMALL + + +def test_extremely_wide_subject_rejected(): + """主体太扁 → 方形画布只能把角色硬缩成一条,不如在花钱前退回去。""" + w = int(60 * REJECT_ASPECT) + 40 + with pytest.raises(MasterRejected) as e: + check_master(_png(w + 40, 200, blob=((10, 60, 10 + w, 120), (200, 60, 60, 255)))) + assert e.value.code is MasterRejectCode.ASPECT_TOO_WIDE + + +def test_ordinary_humanoid_master_passes_and_reports_facts(): + """人形母版必须放行——预检的价值在于不误伤,误伤一次比漏放一次更贵。""" + facts = check_master(_png(400, 600, blob=((160, 100, 240, 520), (200, 60, 60, 255)))) + assert facts.size == (400, 600) + assert 0.1 < facts.subject_ratio < 1.2 + assert facts.subject_area_ratio > 0.001 + assert facts.note() # 进度文案不能是空串 + + +def test_reject_aspect_is_derived_from_canvas_geometry_not_hardcoded(): + """阈值必须跟着画布几何走。把 pack.py 的 FILL_W/FILL_H 改了而这里不动, + 预检就会放行一批下游装不下的母版——那正是"看起来成功"的来源。""" + assert REJECT_ASPECT == pytest.approx(2 * FILL_W / FILL_H) + + +def test_rejection_carries_machine_readable_code_not_just_a_message(): + """server 要据此选文案 / 决定 4xx-不重试,用消息做分支会在改文案时悄悄失效。""" + with pytest.raises(MasterRejected) as e: + check_master(b"broken") + assert isinstance(e.value.code, MasterRejectCode) + assert e.value.detail + + +# ── 出参成色:三个字段各自不可由其他两个推导 ────────────────────────────────── + + +def _frames(n: int, shift: int = 3) -> list[Image.Image]: + out = [] + for i in range(n): + im = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) + x = 10 + (i * shift) % 30 + for y in range(20, 50): + for xx in range(x, x + 12): + im.putpixel((xx, y), (200, 60, 60, 255)) + out.append(im) + return out + + +def test_motion_scale_is_zero_for_a_frozen_sequence(): + """整段冻结时死帧判据一帧都报不出——两条判据都是相对的,d 全为 0 时 + `0 < 0` 一条都不成立。绝对尺度必须单独给一个,否则"每帧都一样"这种 + 最典型的坏产出在出参上完全看不见。""" + same = _frames(12, shift=0) + assert motion_scale(same) == 0.0 + assert len(dead_frame_indices(same)) == 0, "相对判据看不见整体没动 —— 正是要 motion_scale 的原因" + + +def test_motion_scale_positive_for_real_movement(): + assert motion_scale(_frames(12)) > 0.0 + + +def test_dead_frame_indices_returns_positions_not_a_mask(): + """跨出 ai_engine 的契约要"哪几帧",不该让调用方拿 numpy 掩码去 argwhere。""" + idx = dead_frame_indices(_frames(10)) + assert isinstance(idx, tuple) + assert all(isinstance(i, int) for i in idx) + + +def test_loop_seam_returns_none_when_there_is_no_step_to_compare(): + """分母为 0 时返回 None 而不是 0.0——0.0 会被读成"完美闭环", + 而真相是"没有可比的步长,这个数不可读"。""" + assert loop_seam(_frames(8, shift=0)) is None + assert loop_seam(_frames(1)) is None + + +def test_loop_seam_measures_the_gap_between_last_and_first(): + seam = loop_seam(_frames(10)) + assert seam is not None and seam >= 0.0 + + +def test_quality_fields_are_independent(): + """三个字段互不可推导:全同帧的 motion_scale=0 而 dead_frames 为空, + 两者若能互推,这一组断言不可能同时成立。""" + q = ActionQuality(motion_scale=0.0, dead_frames=(), loop_seam=None) + assert q.motion_scale == 0.0 and q.dead_frames == () and q.loop_seam is None From cbc6667c0234cbc7e201f424059505e3ad083844 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Mon, 10 Aug 2026 21:59:22 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix(ai=5Fengine):=20=E5=87=BA=E5=8F=82?= =?UTF-8?q?=E5=8F=AA=E7=95=99=E4=B8=80=E4=B8=AA=E6=92=AD=E6=94=BE=E6=97=B6?= =?UTF-8?q?=E5=BA=8F=E7=9C=9F=E7=9B=B8=E6=BA=90=EF=BC=8C=E5=B9=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E4=B8=8A=E6=B8=B8=E4=B8=A4=E5=A4=84=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 机器审在本 PR 报的三条 P2,两条同源:契约里存在"能填/能读、但与另一处矛盾或不生效"的 字段。 一、GeneratedAction.fps 删除。它抄自入参,而 durations 按动作查表得来,两者描述同一段 素材的不同播放速度:fps=20 宣称 50ms/帧,walk 实际给 125ms/帧,取哪个看消费方心情。 逐帧 ms 严格更能表达(关键帧定格),所以保 durations、删 fps;真要单一帧率由消费方算。 连带删除 ActionSpec.fps(在 feat/character-domain-models 里,本分支同步)。 二、删掉一条为缺陷背书的测试。此处曾有 test_loop_mode_currently_changes_nothing,把 "传 pingpong / none 不改变任何一帧"钉成可执行事实,理由是"将来真接线时它会变红提醒 删注释"。那是把缺陷固化:调用方能为一段往返动画付费、拿到一段线性循环,而测试为这个 行为背书。现改为断言字段确实不存在——ActionSpec.loop 与 LoopMode 都已移除。 同理,test_generate_walk_is_wired_end_to_end 里的 `assert out.fps == action.fps` 换成断言时长确实来自动作查表(walk = 125ms/帧)。 三、抽帧改流式(改动本体在 feat/ai-engine-frame-toolkit,本分支同步)。121 帧 720p 真实 视频抽 16 帧,进程 RSS 峰值 488 → 126 MiB。 变异测试:把 GeneratedAction.fps 加回去 1 条红;把 durations 改成固定 50ms 不查表 1 条红。 CI:ruff / import-linter 2 contracts / pytest 276 passed。 --- .../src/windup_ai_engine/ports/__init__.py | 5 +- backend/tests/test_ai_engine_skeleton.py | 321 ++++++++++++++++++ 2 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_ai_engine_skeleton.py diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py index 44d36355..b384f7e0 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py @@ -113,8 +113,11 @@ class GeneratedAction: """ frames: list[bytes] = field(default_factory=list) # RGBA PNG,按播放序 + # 播放时序的**唯一**真相源。曾另有一个 fps 字段抄自入参,与本字段互相矛盾: + # fps=20 宣称 50ms/帧,而 walk 这里给的是 125ms/帧 —— 同一段素材两个播放速度, + # 取哪个看消费方心情(2026-08-10 机器审 P2)。逐帧 ms 严格更能表达(关键帧定格), + # 所以删 fps 保 durations;真要单一帧率,由消费方从本字段算。 durations: list[int] = field(default_factory=list) # 逐帧时长(ms),与 frames 等长 - fps: int = 10 # 无默认值、且 kw_only 让它能排在有默认值的字段之后:**不给"没测"留缺省**。 # 给个 None 缺省的话,漏测与"测出来没问题"在调用方看来一模一样,而这个出参的 # 全部意义就是把这两者分开。 diff --git a/backend/tests/test_ai_engine_skeleton.py b/backend/tests/test_ai_engine_skeleton.py new file mode 100644 index 00000000..88db15d6 --- /dev/null +++ b/backend/tests/test_ai_engine_skeleton.py @@ -0,0 +1,321 @@ +"""ai_engine 串联 smoke —— 验证架构串联成立:路由正确 + generate 端到端跑通。 + +策略内部(真实 i2v)用 mock / monkeypatch 顶替(真实生成联网、抽帧要解码 mp4); +本测证明"选路线 → derive → 最后一公里(真实对齐)→ GeneratedAction(帧 + 时长)"这条串联为真。 +""" +from __future__ import annotations + +import io + +from PIL import Image + +from windup_ai_engine.impl import CharacterGenerator +from windup_ai_engine.ports import GeneratedAction +from windup_ai_engine.postprocess.rootmotion import DEFAULT_FPS_MS +from windup_ai_engine.strategy import ( + ROUTE_MATRIX, + DerivationStrategy, + VideoFrameStrategy, +) +from windup_common.models import ( + ActionSpec, + ActionType, + CharacterCard, + Facing, + GenRoute, + Stylize, +) + + +def _tiny_png(color=(200, 60, 60, 255), shift=0) -> bytes: + """一张带主体的小 RGBA PNG(四周留透明边,供真实对齐 / 抠图链处理)。""" + img = Image.new("RGBA", (64, 96), (0, 0, 0, 0)) + for y in range(20, 80): + for x in range(24 + shift, 40 + shift): + img.putpixel((x, y), color) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +class _NullProgress: + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + pass + + +class _MockWalkStrategy(DerivationStrategy): + """顶替真实 VideoFrameStrategy:返回 N 张真 PNG,让对齐真跑。""" + + route = GenRoute.VIDEO_I2V + + def derive(self, card, action, master, progress) -> list[bytes]: + return [_tiny_png() for _ in range(action.n_frames)] + + +def _make_generator() -> CharacterGenerator: + return CharacterGenerator({GenRoute.VIDEO_I2V: _MockWalkStrategy()}) + + +def test_route_matrix_is_the_measured_contract(): + # 实测挣得的架构决策:走路/跑/攻击走视频,受击逐帧,待机程序化 + assert ROUTE_MATRIX[ActionType.WALK] is GenRoute.VIDEO_I2V + assert ROUTE_MATRIX[ActionType.RUN] is GenRoute.VIDEO_I2V + assert ROUTE_MATRIX[ActionType.ATTACK] is GenRoute.VIDEO_I2V + assert ROUTE_MATRIX[ActionType.JUMP] is GenRoute.VIDEO_I2V + assert ROUTE_MATRIX[ActionType.HIT] is GenRoute.PER_FRAME + assert ROUTE_MATRIX[ActionType.IDLE] is GenRoute.VIDEO_I2V + + +def test_generate_walk_is_wired_end_to_end(): + card = CharacterCard(name="rogue", desc="hooded ranger, dual daggers") + # 视频路线只需声明帧数;poses 是逐帧路线的入参,这里不传(以前必须编 8 条假描述)。 + action = ActionSpec(action=ActionType.WALK, n_frames=8) + out = _make_generator().generate(card, action, master=_tiny_png(), progress=_NullProgress()) + assert isinstance(out, GeneratedAction) + assert len(out.frames) == 8 # 选路线→derive→对齐 全串通 + assert len(out.durations) == 8 # 逐帧时长与帧等长 + # 时长是按动作查表来的,不是从入参帧率算的 —— walk 的基准是 125ms/帧。 + # 原先这里断言的是 `out.fps == action.fps`,把"照抄一个不生效的入参"锁成了契约。 + assert all(d > 0 for d in out.durations) + assert set(out.durations) == {DEFAULT_FPS_MS["walk"]} + assert all(f and f[:8] == b"\x89PNG\r\n\x1a\n" for f in out.frames) # 真 PNG + + +def test_action_spec_stylize_defaults_and_toggle(): + # 像素化是开关(默认 pixel),可关成 none 保留 i2v 画风 + assert ActionSpec(action=ActionType.WALK).stylize is Stylize.PIXEL + a = ActionSpec(action=ActionType.WALK, stylize="none") + assert a.stylize is Stylize.NONE + + +def _offline_video_strategy(monkeypatch, video=None) -> VideoFrameStrategy: + """离线版 VideoFrameStrategy:抽帧被顶替,不解码 mp4 / 不联网 / 不花钱。""" + dense = [Image.open(io.BytesIO(_tiny_png(shift=i % 6))).convert("RGBA") for i in range(24)] + monkeypatch.setattr( + "windup_ai_engine.strategy.concrete.extract_all_frames_bytes", + lambda video, cap=150: dense, + ) + + class _StubVideo: + def i2v(self, first_frame, prompt, seconds=5, size="1280x720"): + return b"fake-mp4" + + class _StubMatte: + def cutout(self, frame): # 透传:合成帧已带 alpha + return frame + + return VideoFrameStrategy(video or _StubVideo(), _StubMatte()) + + +def test_video_strategy_derive_runs_offline(monkeypatch): + """真实 VideoFrameStrategy.derive 离线跑通。 + + 证明 derive 的真实链路:i2v → 抽帧 → 抠图 → 选帧 → 出帧,产物是合法 RGBA PNG。 + """ + strat = _offline_video_strategy(monkeypatch) + card = CharacterCard(name="knight", desc="plate armor, sword") + action = ActionSpec(action=ActionType.WALK, stylize="none", n_frames=8) + out = strat.derive(card, action, master=_tiny_png(), progress=_NullProgress()) + assert out and all(f[:8] == b"\x89PNG\r\n\x1a\n" for f in out) + + +def test_video_strategy_honours_n_frames_without_any_poses(monkeypatch): + """帧数由 ActionSpec.n_frames 决定,**不必传 poses** —— A2 的落地验证。 + + 以前只能靠 len(poses) 表达帧数,于是"要 6 帧"得先编 6 条视频路线根本不读的姿势描述; + 读代码的人会以为那 6 条描述真的进了提示词。 + """ + strat = _offline_video_strategy(monkeypatch) + card = CharacterCard(name="knight", desc="plate armor, sword") + for n in (4, 6, 11): + action = ActionSpec(action=ActionType.WALK, stylize="none", n_frames=n) + out = strat.derive(card, action, master=_tiny_png(), progress=_NullProgress()) + assert len(out) == n, f"要 {n} 帧,实得 {len(out)} 帧" + + +def test_video_strategy_stylize_switch_actually_changes_the_pixels(monkeypatch): + """stylize 分支不能接反 —— 只验"两条分支都不抛错"验不出接反。 + + none=原样出帧(与输入同尺寸);pixel=裁包围盒 + 重采样到目标像素高,尺寸必然不同。 + """ + strat = _offline_video_strategy(monkeypatch) + card = CharacterCard(name="knight", desc="plate armor, sword") + plain = strat.derive( + card, ActionSpec(action=ActionType.WALK, stylize=Stylize.NONE, n_frames=4), + master=_tiny_png(), progress=_NullProgress(), + ) + pixel = strat.derive( + card, ActionSpec(action=ActionType.WALK, stylize=Stylize.PIXEL, n_frames=4), + master=_tiny_png(), progress=_NullProgress(), + ) + assert Image.open(io.BytesIO(plain[0])).size == (64, 96) # 未像素化:原尺寸 + assert Image.open(io.BytesIO(pixel[0])).size != (64, 96) # 像素化:重采样过 + + +def test_video_strategy_prompt_follows_facing(monkeypatch): + """喂给 i2v 的提示词随 ActionSpec.facing 走 —— 朝向约束真的传到了付费调用那一层。 + + 这是 facing 枚举化要保护的东西:枚举保证值合法,本测保证合法值被用对。 + """ + seen: list[str] = [] + + class _SpyVideo: + def i2v(self, first_frame, prompt, seconds=5, size="1280x720"): + seen.append(prompt) + return b"fake-mp4" + + strat = _offline_video_strategy(monkeypatch, video=_SpyVideo()) + card = CharacterCard(name="knight", desc="plate armor, sword") + for facing in (Facing.SIDE, Facing.FRONT): + strat.derive( + card, + ActionSpec(action=ActionType.WALK, stylize=Stylize.NONE, n_frames=4, facing=facing), + master=_tiny_png(), progress=_NullProgress(), + ) + assert "SIDE VIEW facing right" in seen[0] + assert "FACING THE VIEWER" in seen[1] + + +def test_real_video_strategy_is_registered_for_video_route(): + # 真实 VideoFrameStrategy 可构造且声明视频路线(derive 联网,不在此跑) + class _V: + def i2v(self, first_frame, prompt, seconds=5, size="1280x720"): + return b"" + + class _M: + def cutout(self, frame): + return frame + + strat = VideoFrameStrategy(_V(), _M()) + assert strat.route is GenRoute.VIDEO_I2V + + +# ── 未实现的路线必须炸,不能吐空帧(2026-08-07)───────────────────────────── +# +# 旧行为:PerFrameStrategy.derive 返回 [b""] * n_frames,CharacterGenerator._lastmile +# 见到空帧就静默跳过对齐、原样返回。调用方拿到的 GeneratedAction 帧数对、时长对、 +# 无异常 —— 完全像一次成功的生成。server 会把 N 个 0 字节文件传上对象存储、写进 +# character_data,用户看到 N 张裂图,且排查时不会想到是"路线没实现"。 +# +# 新行为:在最早能判定的边界上抛错。下面三条分别覆盖三个入口。 + + +def test_unimplemented_route_raises_instead_of_returning_empty_frames(): + """PerFrameStrategy 调用即抛,不返回空帧。""" + import pytest + + from windup_ai_engine.strategy import PerFrameStrategy + + s = PerFrameStrategy(image=None, matte=None) + card = CharacterCard(name="t", desc="t") + action = ActionSpec(action=ActionType.HIT, poses=["a", "b", "c"]) + with pytest.raises(NotImplementedError, match="per_frame"): + s.derive(card, action, _tiny_png(), _NullProgress()) + + +def test_missing_strategy_for_route_raises_with_what_is_wired(): + """装配表里没有该路线时抛错,并报出已装配了哪些 —— 便于定位是漏注入还是没实现。""" + import pytest + + # 只装 VIDEO_I2V,请求 hit(分流到 PER_FRAME) + gen = CharacterGenerator({GenRoute.VIDEO_I2V: _MockWalkStrategy()}) + card = CharacterCard(name="t", desc="t") + action = ActionSpec(action=ActionType.HIT, poses=["a", "b"]) + with pytest.raises(NotImplementedError, match="video_i2v"): + gen.generate(card, action, _tiny_png(), _NullProgress()) + + +def test_empty_frames_from_strategy_are_rejected(): + """strategy 吐出空帧(provider / 抠图坏了)时同样要炸,不原样放行。""" + import pytest + + class _EmptyStrategy(DerivationStrategy): + route = GenRoute.VIDEO_I2V + + def derive(self, card, action, master, progress) -> list[bytes]: + return [b"", b"", b""] + + gen = CharacterGenerator({GenRoute.VIDEO_I2V: _EmptyStrategy()}) + card = CharacterCard(name="t", desc="t") + action = ActionSpec(action=ActionType.WALK, poses=["a", "b", "c"]) + with pytest.raises(ValueError, match="空帧"): + gen.generate(card, action, _tiny_png(), _NullProgress()) + + +def test_short_frame_count_from_strategy_is_rejected(): + """产出帧数少于 ``n_frames`` 时要炸 —— 少给不会崩,只会"短一截"。 + + 这不是假想:slicing.pick_cycle / pick_oneshot 在源帧不足(i2v 视频太短 / 动作区间 + 过窄)时 ``return frames`` / ``return span``,长度不足且不报错。时长表由 + frame_durations(…, len(frames)) 现算,所以产物内部自洽 —— server 看不出异常, + 用户拿到一段步子没走完的循环。A2 之后 n_frames 是调用方的明确承诺,必须对账。 + """ + import pytest + + class _ShortStrategy(DerivationStrategy): + route = GenRoute.VIDEO_I2V + + def derive(self, card, action, master, progress) -> list[bytes]: + return [_tiny_png() for _ in range(action.n_frames - 1)] # 少给一帧 + + gen = CharacterGenerator({GenRoute.VIDEO_I2V: _ShortStrategy()}) + card = CharacterCard(name="t", desc="t") + with pytest.raises(ValueError, match="要 8 帧,实际产出 7 帧"): + gen.generate( + card, ActionSpec(action=ActionType.WALK, n_frames=8), + _tiny_png(), _NullProgress(), + ) + + +def test_progress_notes_carry_enum_values_not_python_reprs(): + """进度文案里不能出现 "ActionType.WALK"。 + + Python 3.11 改了 str-mixin 枚举的 __format__:f"{ActionType.WALK}" 从 "walk" 变成 + "ActionType.WALK"(3.12.13 实测)。这串字经 server 变成用户看到的 SSE 进度文案, + 没有任何测试会因此变红 —— 属于"跑得通但对外是错的"那一类。 + """ + notes: list[str] = [] + + class _SpyProgress: + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + notes.append(note) + + _make_generator().generate( + CharacterCard(name="t", desc="t"), + ActionSpec(action=ActionType.WALK, n_frames=4), + _tiny_png(), _SpyProgress(), + ) + assert notes, "没收到任何进度上报" + assert not any("ActionType." in n for n in notes), notes + assert any("walk" in n for n in notes), notes + + +def test_generated_action_has_a_single_timing_source(): + """出参不许有第二个描述播放速度的字段。 + + 此处曾有一条 ``test_loop_mode_currently_changes_nothing``,把"传 pingpong / none + 不改变任何一帧"钉成可执行事实,理由是"将来真接线时它会变红提醒删注释"。 + 那是把缺陷固化:调用方能为一段往返动画付费、拿到一段线性循环,而测试为这个行为背书。 + 2026-08-10 按机器审意见改成删字段 —— ``ActionSpec.loop`` 与 ``LoopMode`` 都已移除, + 真要支持 pingpong,连同 pick_cycle 的分支与出参时序契约一起加回。 + + 同批删掉的 ``GeneratedAction.fps`` 同理:它抄自入参、与 durations 互相矛盾。 + """ + from dataclasses import fields + + names = {f.name for f in fields(GeneratedAction)} + assert "fps" not in names, "fps 与 durations 会给出两个不同的播放速度" + assert "durations" in names + + +def test_genroute_only_lists_implemented_routes(): + """GenRoute 只列有实现的路线 —— 没有实现的枚举值等于死代码。 + + 这条同时管住两个方向: + - PROC_IDLE(程序化待机,#53 原设计)已证否,连同 ProcIdleStrategy 一并移除; + - 未来路线(三渲二渲染出帧)**不提前留位**,契约需求记在 Issue,随实现一起加成员。 + 枚举加成员是纯加法,不构成破坏性变更,所以"提前留位免得二次改形"不成立。 + """ + assert {r.value for r in GenRoute} == {"video_i2v", "per_frame"} + import windup_ai_engine.strategy as strat + assert not hasattr(strat, "ProcIdleStrategy") From f8f53e466c8bdbee934711b802bb59e70bbf92a0 Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Tue, 11 Aug 2026 11:27:02 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix(ai=5Fengine):=20=E6=AF=8D=E7=89=88?= =?UTF-8?q?=E9=A2=84=E6=A3=80=E7=9A=84=E6=AF=94=E4=BE=8B=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E8=B7=9F=E7=9D=80=E4=BA=A4=E4=BB=98=E7=94=BB=E5=B8=83=E8=B5=B0?= =?UTF-8?q?=EF=BC=8C=E9=9D=9E=E6=96=B9=E7=94=BB=E5=B8=83=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=88=A4=E5=AE=BD=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一个提交让交付画布可以非方,这条紧接着补上被它架空的东西:master_check 的 REJECT_ASPECT 推导默认画布是方形 —— FILL_W 与 FILL_H 是**同一条边长**的两个比例。 画布能非方之后前提不成立了,同一条推导做下来是 R = 2 * (cw/ch) * FILL_W / FILL_H = REJECT_ASPECT * (cw/ch) 不跟着收的后果正是这条阈值最怕的那件事:**预检按方形判、出帧按非方出**。 2026-08-11 实测,一个刚好过检(w/h=3.0968)的主体在各档画布上的交付占高: 256×256 0.3086 512×512 0.3105 1024×1024 0.3105 384×512 0.2324 ← 阈值本意保证的下限是 FILL_H/2 = 0.31,被架空 新增 reject_aspect_for(canvas) 算实际上限,check_master 收可选 canvas, CharacterGenerator 把**出帧用的同一个 canvas** 传给预检。 canvas=None 或方形画布时与本提交之前完全一致(用例钉死 128/256/512/1024 四档 以及 None 都等于原 REJECT_ASPECT)。 修好之后的不变式实测(源画幅放大到 3000×600 杜绝主体被源边界裁掉;处在各自比例 上限的主体,交付占高应恒等于 FILL_H/2 = 0.31): 256×256 上限 3.0968 → 0.3086 512×512 上限 3.0968 → 0.3105 1024×1024 上限 3.0968 → 0.3105 384×512 上限 2.3226 → 0.3105 512×384 上限 4.1290 → 0.3099 128×192 上限 2.0645 → 0.3125 640×480 上限 4.1290 → 0.3104 2048×2048 上限 3.0968 → 0.3101 与 FILL_H/2 的最大偏差 0.0025(取整噪声量级) 即窄高画布收紧、宽扁画布放宽,两侧都回到同一条几何。 变异测试(5 个变异逐个改坏 → 确认变红 → 还原,全部被杀): M1 非方画布不收紧阈值 → narrow_canvas_tightens 等 3 条红 M2 宽高比取倒数(方向反了) → narrow_canvas_tightens 等 3 条红 M3 方形画布也被改动 → square_canvas_is_unchanged 红 M4 判定仍用写死的 REJECT_ASPECT → check_master_uses_the_canvas 红 M5 预检不吃 canvas → precheck_and_output_share_geometry 红 M5 一开始杀不掉(没有任何用例覆盖"预检与出帧用了不同 canvas"),补 test_precheck_and_output_share_the_same_canvas_geometry 之后才杀掉 —— 取一个夹在 方形阈值与 384×512 阈值之间的母版,方形放行、窄高必拒。 **依赖上游分支**:同 013520f,需要 feat/ai-engine-frame-toolkit 的 5da358e。 在工作区打上该提交的 pack.py 后跑,CI 全绿:ruff / lint-imports(2 contracts kept) / pytest 288 passed。 Co-Authored-By: Claude Opus 5 --- .../src/windup_ai_engine/master_check.py | 35 +++++++++-- .../tests/test_master_check_and_quality.py | 59 +++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_check.py b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py index f91a4591..8b3376ea 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/master_check.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_check.py @@ -38,7 +38,7 @@ from windup_ai_engine.postprocess.pack import FILL_H, FILL_W __all__ = ["MIN_SUBJECT_AREA_RATIO", "MIN_SUBJECT_SIDE", "REJECT_ASPECT", - "MasterFacts", "check_master"] + "MasterFacts", "check_master", "reject_aspect_for"] # 主体宽高比上限。**由交付画布的几何推出,不是拍的**:align_bottom_center 按高定标 # (cell*FILL_H);主体 w/h 超过 FILL_W/FILL_H(≈1.55)后宽度兜底接管,交付主体高度 @@ -49,6 +49,26 @@ # 3.1 以上则是"硬缩到没法看"。与其硬缩出一个能落库的错产物,不如在花钱前退回去。 REJECT_ASPECT = 2 * FILL_W / FILL_H + +def reject_aspect_for(canvas: tuple[int, int] | None) -> float: + """给定交付画布下的实际比例上限。方形画布(或不指定)即 :data:`REJECT_ASPECT`。 + + 上面那条推导默认画布是方形 —— ``FILL_W`` 与 ``FILL_H`` 是同一条边长的两个比例。 + 画布可以非方之后这个前提就不成立了:宽度兜底是 ``cw*FILL_W/主体宽``、高度目标是 + ``ch*FILL_H/主体高``,同一条推导做下来是 + + R = 2 * (cw/ch) * FILL_W / FILL_H = REJECT_ASPECT * (cw/ch) + + 即窄高画布(cw Image.Image: ) from exc -def check_master(master: bytes) -> MasterFacts: +def check_master(master: bytes, canvas: tuple[int, int] | None = None) -> MasterFacts: """母版可生成性预检。通过返回量到的形态,不通过抛 :class:`MasterRejected`。 只看母版本身,不看 ``ActionSpec``:三条判据都是"下游画布装不装得下 / 有没有东西可 动",与动作类型无关。动作相关的母版要求(侧向 / 蓄力姿态)本层判不了,见模块 docstring。 + + ``canvas``:交付画布 ``(宽, 高)``。只影响比例上限 —— 见 :func:`reject_aspect_for`。 + 不给即按方形判(与加这个入参之前完全一致)。**必须与出帧用的是同一个 canvas**, + 否则就成了"预检按一套几何判、出帧按另一套出"。 """ img = _decode(master) w, h = img.size @@ -130,10 +154,11 @@ def check_master(master: bytes) -> MasterFacts: f"主体只占画幅 {facts.subject_area_ratio:.4%}" f"(下限 {MIN_SUBJECT_AREA_RATIO:.1%}),像散落的噪点而不是角色", ) - if facts.subject_ratio > REJECT_ASPECT: + limit = reject_aspect_for(canvas) + if facts.subject_ratio > limit: raise MasterRejected( MasterRejectCode.ASPECT_TOO_WIDE, - f"主体 w/h={facts.subject_ratio:.2f} 超过 {REJECT_ASPECT:.2f};" - "下游是方形画布,再宽只能把角色硬缩成一条,请换一张主体没这么扁的母版", + f"主体 w/h={facts.subject_ratio:.2f} 超过 {limit:.2f};" + "下游画布装不下,再宽只能把角色硬缩成一条,请换一张主体没这么扁的母版", ) return facts diff --git a/backend/tests/test_master_check_and_quality.py b/backend/tests/test_master_check_and_quality.py index bb5ada37..cc3bfd43 100644 --- a/backend/tests/test_master_check_and_quality.py +++ b/backend/tests/test_master_check_and_quality.py @@ -18,6 +18,7 @@ MIN_SUBJECT_SIDE, REJECT_ASPECT, check_master, + reject_aspect_for, ) from windup_ai_engine.ports import ActionQuality, MasterRejectCode, MasterRejected from windup_ai_engine.slicing import dead_frame_indices, loop_seam, motion_scale @@ -115,6 +116,64 @@ def test_reject_aspect_is_derived_from_canvas_geometry_not_hardcoded(): assert REJECT_ASPECT == pytest.approx(2 * FILL_W / FILL_H) +# ── 非方形交付画布下的比例上限(2026-08-11 挣得)──────────────────────────── +# +# REJECT_ASPECT 的推导默认画布是方形(FILL_W / FILL_H 是同一条边长的两个比例)。 +# 交付画布可以非方之后前提不再成立:同一条推导做下来是 REJECT_ASPECT*(cw/ch)。 +# 不跟着收的后果是预检按方形判、出帧按非方出 —— 一个刚好过检的主体在 384×512 +# 画布上交付占高只有 0.2324,而这条阈值本意保证的下限是 FILL_H/2=0.31(实测)。 + + +def test_reject_aspect_for_square_canvas_is_unchanged(): + """方形画布(以及不指定)必须与原来完全一致 —— 默认行为不变。""" + assert reject_aspect_for(None) == REJECT_ASPECT + for c in (128, 256, 512, 1024): + assert abs(reject_aspect_for((c, c)) - REJECT_ASPECT) < 1e-12 + + +def test_reject_aspect_for_narrow_canvas_tightens_proportionally(): + """窄高画布容得下的主体更窄,阈值按 cw/ch 收紧;宽扁画布反之放宽。""" + assert reject_aspect_for((384, 512)) < REJECT_ASPECT + assert reject_aspect_for((512, 384)) > REJECT_ASPECT + assert abs(reject_aspect_for((384, 512)) - REJECT_ASPECT * 384 / 512) < 1e-12 + + +def test_threshold_delivers_exactly_half_target_height_on_any_canvas(): + """**预检几何与出帧几何是同一套**的直接证据。 + + 阈值的定义就是交付主体高退化到目标高度的一半。拿真实出帧验证:处在各自比例 + 上限的主体,在任何形状的画布上交付占高都必须落在 FILL_H/2 附近(差的是取整)。 + """ + import numpy as np + + from windup_ai_engine.postprocess.pack import align_bottom_center + + for cw, ch in ((256, 256), (512, 512), (384, 512), (512, 384), (128, 192)): + limit = reject_aspect_for((cw, ch)) + base_h, src_w = 200, 3000 # 源画幅给足,别让主体被源边界裁掉 + blob_w = int(base_h * limit) + img = Image.new("RGBA", (src_w, 600), (0, 0, 0, 0)) + img.paste((200, 60, 60, 255), (100, 100, 100 + blob_w, 100 + base_h)) + out = align_bottom_center([img], cell=cw, cell_h=ch, ref_height=float(base_h)) + ys, _ = np.nonzero(np.asarray(out[0])[:, :, 3] > 128) + ratio = (int(ys.max()) - int(ys.min()) + 1) / ch + assert abs(ratio - FILL_H / 2) < 0.01, ( + f"{cw}×{ch}: 阈值处交付占高 {ratio:.4f},应为 {FILL_H / 2}" + ) + + +def test_check_master_uses_the_canvas_it_is_given(): + """同一张母版:方形画布放行,窄高画布上超限 → 必须被拒。""" + ratio = (REJECT_ASPECT + reject_aspect_for((384, 512))) / 2 # 夹在两个阈值中间 + bw = int(60 * ratio) + png = _png(bw + 80, 200, blob=((10, 60, 10 + bw, 120), (200, 60, 60, 255))) + + check_master(png, canvas=(512, 512)) # 方形:放行 + with pytest.raises(MasterRejected) as e: + check_master(png, canvas=(384, 512)) # 窄高:同一张图装不下 + assert e.value.code is MasterRejectCode.ASPECT_TOO_WIDE + + def test_rejection_carries_machine_readable_code_not_just_a_message(): """server 要据此选文案 / 决定 4xx-不重试,用消息做分支会在改文案时悄悄失效。""" with pytest.raises(MasterRejected) as e: From d4ac9b6e46f60cfd5f762396580255e03f169b2d Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Wed, 12 Aug 2026 10:03:26 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix:=20rebase=20=E5=88=B0=E6=96=B0=20main?= =?UTF-8?q?=20=E5=90=8E=E5=8F=96=E5=9B=9E=E8=A2=AB=E5=9F=BA=E5=BA=A7?= =?UTF-8?q?=E7=89=88=E8=A6=86=E7=9B=96=E7=9A=84=20ai=5Fengine=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #179 合入 main 后重排本分支,解冲突时对 7 个文件取了基座版,把本分支自己的实现覆盖了: character_generator 丢了母版预检与成色量化(退回 123 行前的旧版)、prompt 三个模板丢了 装备参数化、concrete 丢了 canvas 传递、impl/__init__ 整个丢失。 同时把 framework/pyproject.toml 与 uv.lock 取回 main 版再重锁:本分支的旧 lock 少 254 行、 且 pyproject 删掉了 #179 已入库的 passlib/redis/resend 三条声明,导致 bcrypt 找不到。 现在 lock 相对 main 是纯新增 43 行(imageio + av)。302 passed。 --- .../src/windup_ai_engine/impl/__init__.py | 5 + .../impl/character_generator.py | 123 +++++-- .../src/windup_ai_engine/ports/__init__.py | 7 + .../src/windup_ai_engine/prompt/actions.py | 16 +- .../src/windup_ai_engine/prompt/jump.py | 11 +- .../src/windup_ai_engine/prompt/walk.py | 16 +- .../src/windup_ai_engine/strategy/concrete.py | 13 +- backend/packages/framework/pyproject.toml | 4 + backend/uv.lock | 302 +++++++++++++++++- 9 files changed, 448 insertions(+), 49 deletions(-) create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py new file mode 100644 index 00000000..456b868a --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py @@ -0,0 +1,5 @@ +"""impl:CharacterGeneratorPort 的装配实现(串联 strategy + 最后一公里)。""" + +from .character_generator import CharacterGenerator + +__all__ = ["CharacterGenerator"] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py index bb844684..1a4458d7 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -1,26 +1,38 @@ """CharacterGenerator —— 装配 strategy + 最后一公里,串起整条生产线(架构串联点)。 这是 CharacterGeneratorPort 的实现;server 经 port 调它、不碰这里。 -串联:选路线(ROUTE_MATRIX)→ strategy.derive 出帧 → 最后一公里(脚线对齐)→ GeneratedAction。 +串联:母版预检(可拒绝)→ 选路线(ROUTE_MATRIX)→ strategy.derive 出帧 → +最后一公里(脚线对齐)→ 量交付成色 → GeneratedAction。 + +两头各有一道闸,方向相反:进门那道(master_check)在**花钱之前**挡住不可能生成好的 +输入;出门那几道(空帧 / 帧数 / 成色)在钱已经花完之后,挡住"看起来成功的错产物"。 MVP 边界(与作者对齐):**只出帧 bytes + 逐帧时长**,不打包 sprite sheet、不落存储—— 上传对象存储、写 character_data、拼图集/多格式导出由 server / export 侧做(#22)。 """ from __future__ import annotations - +import numpy as np +from PIL import Image from windup_common.models import ActionSpec, CharacterCard, GenRoute from windup_ai_engine._imgio import from_png as _img from windup_ai_engine._imgio import to_png as _png +from windup_ai_engine.master_check import check_master from windup_ai_engine.ports import ( + ActionQuality, CharacterGeneratorPort, GeneratedAction, ProgressPort, ) from windup_ai_engine.postprocess import align_bottom_center, frame_durations -from windup_ai_engine.strategy.base import ROUTE_MATRIX, DerivationStrategy +from windup_ai_engine.slicing import dead_frame_indices, loop_seam, motion_scale +from windup_ai_engine.strategy.base import ( + CYCLIC_ACTIONS, + ROUTE_MATRIX, + DerivationStrategy, +) @@ -37,11 +49,23 @@ def generate( action: ActionSpec, master: bytes, progress: ProgressPort, + canvas: tuple[int, int] | None = None, ) -> GeneratedAction: - # ① 选路线(架构决策矩阵)。装配表里没有 = 该路线未实现,在边界上炸, + # ① 入口预检 —— 唯一一道在**花钱之前**的闸,故排在选路线之前。 + # 之前这里什么都不判:一张"人物在画板前作画"的图请求 walk,全程无一处报错, + # 16 帧构图完整的错角色出完、钱花完(2026-08-07 实测)。预检拦不住"内容画错" + # (那要视觉模型),但坏图 / 空图 / 极端比例这几类不必等到出帧才发现。 + # 预检与出帧必须用**同一个** canvas:比例上限是由交付画布几何推出来的, + # 传一个、出另一个就等于预检按方形判、出帧按非方出(见 master_check)。 + facts = check_master(master, canvas) + progress.step("precheck", 0, 4, facts.note()) + + # ② 选路线(架构决策矩阵)。装配表里没有 = 该路线未实现,在边界上炸, # 不要让"看着成功、内容是空"的结果流到 server 去落库。 route = ROUTE_MATRIX[action.action] - progress.step("route", 0, 3, f"{action.action} → {route.value}") + # .value 而不是枚举本身:Python 3.11+ 的 str-mixin 枚举 __format__ 会给出 + # "ActionType.WALK",这串字最终是用户看到的进度文案(3.12.13 实测)。 + progress.step("route", 1, 4, f"{action.action.value} → {route.value}") strategy = self._by_route.get(route) if strategy is None: raise NotImplementedError( @@ -49,27 +73,78 @@ def generate( f"已装配:{sorted(r.value for r in self._by_route)}。" ) - # ② 生成帧(交给 strategy —— 串联) + # ③ 生成帧(交给 strategy —— 串联) frames = strategy.derive(card, action, master, progress) - # ③ 最后一公里:脚线对齐成原地序列帧 - frames = self._lastmile(frames, progress) + # ③.5 帧数必须与契约相符。A2 把 n_frames 从 len(poses) 的推导值改成调用方直接声明的 + # 承诺,而抽帧那两个函数都会**静默少给**:slicing.pick_cycle / pick_oneshot 在 + # `len(dense) <= n`(或动作区间比 n 短)时 return frames/span,长度不足且不报错 + # (2026-08-08 读码复核)。少给的后果不是崩溃而是"短一截的动作":时长表由 + # frame_durations(…, len(frames)) 现算,长度自洽,server 看不出异常,用户拿到 + # 一段步子没走完的循环。故在此对账 —— 钱已经花了,但至少不让错产物流下去。 + # 放在 generator 而不是某个 strategy 里:这样将来任何新路线都受同一条约束。 + if len(frames) != action.n_frames: + raise ValueError( + f"{route.value} 要 {action.n_frames} 帧,实际产出 {len(frames)} 帧。" + "抽帧源帧数不足(i2v 视频太短 / 动作区间过窄)时会静默少给," + "请调小 n_frames 或加长视频。" + ) + + # ④ 最后一公里:脚线对齐成原地序列帧(直接对齐到调用方要的画布尺寸) + aligned = self._lastmile(frames, progress, canvas) + + # ⑤ 量交付成色。在**对齐之后**量,量的是用户真正会看到的那组帧:抠图 / 像素化 / + # 对齐都会改像素,在中间任何一步量出来的数都描述不了交付物。 + quality = self._assess(aligned, action) - # ④ 出参:帧 + 逐帧时长(上传 / 落库在 server 侧) - progress.step("package", 2, 3, f"{len(frames)} 帧 + 逐帧时长") + # ⑥ 出参:帧 + 逐帧时长 + 成色(上传 / 落库在 server 侧) + progress.step( + "package", 3, 4, + f"{len(aligned)} 帧 + 逐帧时长(动量 {quality.motion_scale:.2f}," + f"死帧 {len(quality.dead_frames)}/{len(aligned)})", + ) return GeneratedAction( - frames=frames, - durations=frame_durations(action.action.value, len(frames)), - fps=action.fps, + frames=[_png(im) for im in aligned], + durations=frame_durations(action.action.value, len(aligned)), + quality=quality, + ) + + def _assess(self, frames: list[Image.Image], action: ActionSpec) -> ActionQuality: + """量交付帧的成色。这些数只上报、**不改动产物**,也不在此处代替调用方做判决。 + + 为什么不在这里直接对着阈值抛错:交付 / 重试 / 让用户换母版是产品决策,阈值该由 + server 按场景定;而且到这一步钱已经花完,引擎单方面丢弃产物只是把损失变成两份。 + 引擎负责"如实报数",不负责"替上层决定这次算不算数"。 + + ``loop_seam`` 只对循环类动作量:一次性动作(jump/attack)首尾姿态本就不同, + 给它算一个"接缝"再交出去,等于发一个必然难看的数让上层照着做错误决定。 + """ + return ActionQuality( + motion_scale=motion_scale(frames), + dead_frames=dead_frame_indices(frames), + loop_seam=loop_seam(frames) if action.action in CYCLIC_ACTIONS else None, ) - def _lastmile(self, frames: list[bytes], progress: ProgressPort) -> list[bytes]: + def _lastmile( + self, + frames: list[bytes], + progress: ProgressPort, + canvas: tuple[int, int] | None = None, + ) -> list[Image.Image]: """脚线对齐:把各帧对齐成原地序列帧(消除逐帧画布漂移,Issue #21)。 + 返回 PIL 而不是 PNG bytes:紧接着的成色测量要按图看帧,再编码回 PNG 只为了 + 让上一句话好听、下一句话又得解码回来。编码统一在 ``generate`` 出参那一步做。 + 位移轨道(root_motion)MVP 先不做(见 #63 / character_data.frames 暂无该字段): 序列帧保持原地即可,位移留给后续 export / playtest 阶段再算。 + + ``canvas`` 给定时直接对齐到该尺寸,而不是恒出 256 再让上层缩。上层那次缩放 + (``Image.thumbnail`` 补边)**只缩不放**:项目要 512 时 256 的帧不会被放大,而是 + 原尺寸居中贴进 512 画布,于是这里刚对齐好的脚线 0.92 被挪到 0.709(2026-08-11 + 实测),角色不站在地上、跨动作对齐也失效。在这里一次出到位就没有那一步了。 """ - progress.step("lastmile", 1, 3, "脚线对齐(原地)") + progress.step("lastmile", 2, 4, "脚线对齐(原地)") # 空帧不再静默跳过:未实现的路线现在在 strategy / 装配表处就抛错(见 generate), # 走到这里还有空帧说明 provider 或抠图吐了坏数据,同样要炸而不是原样放行。 if not frames: @@ -80,12 +155,14 @@ def _lastmile(self, frames: list[bytes], progress: ProgressPort) -> list[bytes]: imgs = [_img(f) for f in frames] # 参考姿态高 = 各帧包围盒高的中位数:比"最高帧"稳(不被举过头顶的武器带偏), # 各动作都以自身中位姿态定标,本体尺寸跨动作一致。 - import numpy as _np - _hs = [] - for _im in imgs: - _ys, _ = _np.where(_np.asarray(_im)[:, :, 3] > 128) - if len(_ys): - _hs.append(float(_ys.max() - _ys.min())) - aligned = align_bottom_center(imgs, ref_height=(float(_np.median(_hs)) if _hs else None)) + hs = [] + for im in imgs: + ys, _ = np.where(np.asarray(im)[:, :, 3] > 128) + if len(ys): + hs.append(float(ys.max() - ys.min())) # TODO(dev, #21): tail_match 循环闭合(净位移动作先锚点再匹配帧) - return [_png(im) for im in aligned] + ref = float(np.median(hs)) if hs else None + if canvas is None: + return align_bottom_center(imgs, ref_height=ref) + cw, ch = canvas + return align_bottom_center(imgs, cell=cw, cell_h=ch, ref_height=ref) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py index b384f7e0..c3eb1684 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py @@ -144,6 +144,12 @@ class CharacterGeneratorPort(Protocol): 角色一致性靠它,不靠 card。** 进付费模型之前会先过一遍可生成性预检, 见 Raises。 progress: 进度回调。 + canvas: 交付画布 ``(宽, 高)``,单位像素。``None`` = 引擎默认(256 方形)。 + **给上层传项目 sprite 尺寸用的。** 不给的话引擎恒出 256,上层要缩到项目 + 尺寸就得再来一次重采样;而那一步用 ``Image.thumbnail``(只缩不放),放大 + 方向根本不放大、还会把脚线从 0.92 挪到 0.709(2026-08-11 实测),角色不 + 站在地上。让引擎一次出到目标尺寸,那次二次缩放就整个消掉。 + 画布几何按比例定义,故任何尺寸下构图不变、母版预检阈值同样有效。 Raises: MasterRejected: 母版形态不可生成(见 :class:`MasterRejectCode`)。**在花钱 @@ -161,4 +167,5 @@ def generate( action: ActionSpec, master: bytes, progress: ProgressPort, + canvas: tuple[int, int] | None = None, ) -> GeneratedAction: ... diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py index 7a6538c2..07116470 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py @@ -11,6 +11,8 @@ from __future__ import annotations +from windup_common.models import Facing + __all__ = ["build_idle_prompt", "build_attack_prompt"] _IDLE_SIDE = ( @@ -56,10 +58,12 @@ DEFAULT_GARMENT = "the cape" -def _build(side: str, front: str, weapon: str, garment: str, feet: str, facing: str) -> str: - if facing not in ("side", "front"): - raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") - body = (side if facing == "side" else front).format(weapon=weapon, garment=garment) +def _build( + side: str, front: str, weapon: str, garment: str, feet: str, facing: Facing | str +) -> str: + # 非法值在此炸掉,别静默落到 FRONT 模板(理由见 walk.py 同处注释)。 + tpl = side if Facing(facing) is Facing.SIDE else front + body = tpl.format(weapon=weapon, garment=garment) return body.replace("boot", feet) if feet != "boot" else body @@ -67,7 +71,7 @@ def build_idle_prompt( weapon: str = DEFAULT_WEAPON, garment: str = DEFAULT_GARMENT, feet: str = "boot", - facing: str = "side", + facing: Facing | str = Facing.SIDE, ) -> str: """待机正文(循环类)。``facing`` 须与母版朝向一致。""" return _build(_IDLE_SIDE, _IDLE_FRONT, weapon, garment, feet, facing) @@ -77,7 +81,7 @@ def build_attack_prompt( weapon: str = DEFAULT_WEAPON, garment: str = DEFAULT_GARMENT, feet: str = "boot", - facing: str = "side", + facing: Facing | str = Facing.SIDE, ) -> str: """攻击正文(一次性类)。``facing`` 须与母版朝向一致。""" return _build(_ATTACK_SIDE, _ATTACK_FRONT, weapon, garment, feet, facing) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py index dac08595..b60cdd2a 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py @@ -14,6 +14,8 @@ from __future__ import annotations +from windup_common.models import Facing + __all__ = ["JUMP_BODY_SIDE", "JUMP_BODY_FRONT", "JUMP_PHASES", "build_jump_prompt"] # 跳跃的五个状态(引擎侧按这个切段;顺序即时间顺序)。 @@ -45,18 +47,17 @@ def build_jump_prompt( - garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: str = "side" + garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: Facing | str = Facing.SIDE ) -> str: """按角色装备 + 母版朝向生成跳跃正文。 Args: garment: 起跳时上飘的衣饰。 feet: 落脚部件用词(替换 boot)。 - facing: "side" 或 "front",**必须与母版朝向一致**。 + facing: :class:`Facing` 成员(或其等价字符串),**必须与母版朝向一致**。 """ - if facing not in ("side", "front"): - raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") - template = JUMP_BODY_SIDE if facing == "side" else JUMP_BODY_FRONT + facing = Facing(facing) # 非法值在此炸掉,别静默落到 FRONT 模板(理由见 walk.py 同处注释) + template = JUMP_BODY_SIDE if facing is Facing.SIDE else JUMP_BODY_FRONT body = template.format(garment=garment) if feet != "boot": body = body.replace("boot", feet) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py index 5d3c4a6e..3597e77c 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py @@ -11,6 +11,8 @@ from __future__ import annotations +from windup_common.models import Facing + __all__ = ["WALK_BODY_SIDE", "WALK_BODY_FRONT", "DEFAULT_GARMENT", "build_walk_prompt"] # 侧走(横版):整体向右推进 + 锁侧视。 @@ -38,19 +40,21 @@ def build_walk_prompt( - garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: str = "side" + garment: str = DEFAULT_GARMENT, feet: str = "boot", facing: Facing | str = Facing.SIDE ) -> str: """按角色装备 + 母版朝向生成走路正文。 Args: garment: 随步伐摆动的衣饰(如 "the cape and tabard" / "the red scarf and tabard")。 feet: 落脚部件用词(如 "boot" / "bare bony foot"),替换机制句里的 boot。 - facing: "side"(横版侧走,母版朝侧向)或 "front"(俯视/2.5D,母版朝观者)。 - **必须与母版朝向一致**,否则模型会靠转身调和矛盾。 + facing: :class:`Facing` 成员(或其等价字符串)。**必须与母版朝向一致**, + 否则模型会靠转身调和矛盾。 """ - if facing not in ("side", "front"): - raise ValueError(f"facing 只能是 'side' 或 'front',收到 {facing!r}") - template = WALK_BODY_SIDE if facing == "side" else WALK_BODY_FRONT + # 注解不是运行期约束:build_* 是普通函数,传 "sidee" 仍进得来。这里显式过一遍 + # Facing() 构造,非法值抛 ValueError —— 若改成 `if facing == Facing.SIDE else FRONT` + # 的二分,"sidee" 会静默落到 FRONT 模板,拿到一段正面走的视频却没有任何报错。 + facing = Facing(facing) + template = WALK_BODY_SIDE if facing is Facing.SIDE else WALK_BODY_FRONT body = template.format(garment=garment) if feet != "boot": body = body.replace("boot", feet) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py index 2f0b8ab9..eff5521c 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py @@ -12,7 +12,7 @@ import numpy as np -from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute +from windup_common.models import ActionSpec, ActionType, CharacterCard, GenRoute, Stylize from windup_framework.providers import ImageProvider, MatteProvider, VideoProvider from windup_ai_engine._imgio import from_png as _img @@ -61,8 +61,13 @@ def derive( master: bytes, progress: ProgressPort, ) -> list[bytes]: - n = action.n_frames or 8 - progress.step("derive", 0, 3, f"{action.action}: i2v 生成视频") + # 帧数直接读契约字段:缺省值已收进 ActionSpec(DEFAULT_N_FRAMES),不再由本层 + # 用 `or 8` 兜底 —— 那等于把契约的缺省值写在实现里,换条 strategy 就换个默认值。 + n = action.n_frames + # 进度文案里的枚举一律取 .value:Python 3.11+ 改了 str-mixin 枚举的 __format__, + # f"{action.action}" 现在给的是 "ActionType.WALK" 而不是 "walk"(3.12.13 实测), + # 而这串字会经 server 变成用户看到的 SSE 进度。 + progress.step("derive", 0, 3, f"{action.action.value}: i2v 生成视频") # 母版按动作预处理:jump 要在顶部补空间,否则角色腾空时头顶顶出视频画面被裁 framed = prepare_master(master, action.action.value) video = self._video.i2v(framed, self._build_prompt(action), seconds=5) @@ -87,7 +92,7 @@ def derive( # 风格化按需(见 ActionSpec.stylize):none=保留 i2v 画风(插画/伪 3D 角色); # pixel=像素化。原生像素角色**按母版规格**做:吸附母版像素网格 + 锁母版色板, # 顺带消掉首帧 JPG / H.264 在硬边留下的灰颗粒(实测:通用降采样+量化反而更糊)。 - if action.stylize == "none": + if action.stylize is Stylize.NONE: progress.step("derive", 2, 3, "保留 i2v 画风(不像素化)") return [_png(im) for im in cut] diff --git a/backend/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..6f63dd7c 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,98 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494" }, ] +[[package]] +name = "av" +version = "18.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c" }, +] + +[[package]] +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 +244,105 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934" }, ] +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -160,6 +352,28 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4" }, +] + [[package]] name = "fastapi" version = "0.139.2" @@ -397,6 +611,19 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" }, ] +[[package]] +name = "imageio" +version = "2.37.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/48/62/aa770a9307508d2a2a2c62d536a49347bffe9e55322db27838d3c93d0b07/imageio-2.37.4.tar.gz", hash = "sha256:e45cbc5e83502047fb138f7f585f7f105a136a57eea5f4b3cfc6ce1b52720bd3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3e/2d/ca050652104bab2cf55e569db2a178b1b61cb041fef28307f2db383f6d9f/imageio-2.37.4-py3-none-any.whl", hash = "sha256:1ab2e22c8debf700f24c3ac43e8f95f3b3a8110c83b93411e97b4b0b2cd1c7e6" }, +] + [[package]] name = "import-linter" version = "2.13" @@ -750,7 +977,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 +989,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 +1095,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, ] +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + [[package]] name = "pillow" version = "12.3.0" @@ -1036,6 +1277,11 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.4" @@ -1168,6 +1414,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1244,6 +1504,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/1d/56/9368cd96d2132017f5748a812cb20a87263498de314e1823472cb4bdbad9/qiniu-7.18.0-py3-none-any.whl", hash = "sha256:0f1be608ac6800ad5f32690d1aa02353b6b2ff5edc78f32a2318859d375a27df" }, ] +[[package]] +name = "redis" +version = "8.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb" }, +] + [[package]] name = "regex" version = "2026.7.19" @@ -1359,6 +1628,19 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, ] +[[package]] +name = "resend" +version = "2.35.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f0/b9/e07f2f2bd992ef4565b9c315dfd46b3df66ce89df1d928f442b9b39cbe3b/resend-2.35.0.tar.gz", hash = "sha256:26ced7b22cbd89f7b8c7ba9719d0708ab10c06ddd0f91ba6ec861f3a328101b3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/cc/92/1c0912b68ae082a55dfdd32e4b5117905ae9fd1b6efc5f5e7c69a4ee6894/resend-2.35.0-py2.py3-none-any.whl", hash = "sha256:cd75299d626f4735af52910989b3f51919032ef316e2d60563c79c319e7b24a6" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1822,6 +2104,8 @@ name = "windup-ai-engine" version = "0.1.0" source = { editable = "packages/ai_engine" } dependencies = [ + { name = "av" }, + { name = "imageio" }, { name = "langchain-core" }, { name = "langgraph" }, { name = "numpy" }, @@ -1832,6 +2116,8 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "av", specifier = ">=14.0" }, + { name = "imageio", specifier = ">=2.36" }, { name = "langchain-core", specifier = ">=0.3" }, { name = "langgraph", specifier = ">=0.2" }, { name = "numpy", specifier = ">=1.26" }, @@ -1846,7 +2132,7 @@ version = "0.1.0" source = { editable = "packages/app" } dependencies = [ { name = "fastapi" }, - { name = "pydantic" }, + { name = "pydantic", extra = ["email"] }, { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, @@ -1858,7 +2144,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, - { name = "pydantic", specifier = ">=2.7" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.7" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, @@ -1888,12 +2174,15 @@ dependencies = [ { name = "langchain-openai" }, { name = "numpy" }, { name = "onnxruntime" }, + { name = "passlib", extra = ["bcrypt"] }, { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "qiniu" }, + { name = "redis" }, + { name = "resend" }, { name = "sqlalchemy" }, { name = "windup-common" }, ] @@ -1905,12 +2194,15 @@ requires-dist = [ { name = "langchain-openai", specifier = ">=0.3" }, { name = "numpy", specifier = ">=1.26" }, { name = "onnxruntime", specifier = ">=1.17,<1.24" }, + { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7" }, { name = "pillow", specifier = ">=10.4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.4" }, { name = "pyjwt", specifier = ">=2.9" }, { name = "qiniu", specifier = ">=7.14" }, + { name = "redis", specifier = ">=5.0" }, + { name = "resend", specifier = ">=2.0" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "windup-common", editable = "packages/common" }, ] From 693d311208abe72f334aa6d4d69ae3a2ea6f77cb Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Wed, 12 Aug 2026 11:25:00 +0800 Subject: [PATCH 12/12] =?UTF-8?q?fix(ai=5Fengine):=20=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E7=BB=9F=E4=B8=80=E5=88=B0=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E5=88=BB=E5=BA=A6=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=80=92=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审实跑逮到的:generator 按 i/4 报,中间夹着的 strategy.derive 按 i/3 报到**同一个** ProgressPort 上。消费方按 i/total 画条会看到倒退两次 —— route 25.0% → derive 0.0%、 derive 66.7% → lastmile 50.0%,totals 同时出现 3 和 4。一个量两个真相源,取哪个看 消费方心情,与本分片删掉 fps / loop / palette 是同一条理由。 改法取评审给的第一条(generator 传偏移):generator 独占全局刻度 total=10,strategy 的 子进度由 _BandProgress 线性映射进 derive 区间 [2,7]。 - strategy 侧零改动。适配器只读它每次调用时自报的 total,不要求它声明自己有几步—— 声明值与实际值又是一对可以对不上的真相源。也不让它知道外层有几步:它是可插拔件, 各路线步数本就不同。 - 刻度取 10 不取 5,是为了给 derive 段留出中间刻度;否则子进度全落同一格,虽不倒退 但最慢的那段整段不动。 - 修后序列:0 → 10 → 20 → 30 → 50 → 80 → 90%,单一 total,零倒退。 测试 +3:同一次生成只允许一个 total、进度非递减、子进度必须落在 derive 区间内且 区间内确实动过(只断言"不倒退"的话,把适配器换成"永远报区间起点"也能过)。这三条 打在修复前的代码上全部 FAIL,报的正是评审给的那两处倒退。 一处如实说明:ProgressPort 的 docstring 写的是"server 转 SSE / 轮询状态",但 #182 目前 唯一的实现是 executor.py:137 的 logger.info,SSE payload 里没有进度字段。所以今天这个 倒退只落在日志里,还没被用户看到 —— 也正因为没有活消费方依赖 total==4,才能直接改刻度 而不必兼容旧值。 Refs 1024XEngineer/Windup#171 #53 Co-Authored-By: Claude Opus 5 --- .../impl/character_generator.py | 54 +++++++++++++-- backend/tests/test_ai_engine_skeleton.py | 68 +++++++++++++++++++ 2 files changed, 116 insertions(+), 6 deletions(-) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py index 1a4458d7..6b387612 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_generator.py @@ -34,7 +34,47 @@ DerivationStrategy, ) - +# ── 进度刻度:整条生产线只有一个 total ──────────────────────────────────────── +# +# 这里曾经是两套刻度:本类按 i/4 报,而中间夹着的 strategy.derive 按 i/3 报到**同一个** +# ProgressPort 上。消费方按 i/total 画进度条就会看到它倒退两次(25.0% → 0.0%、 +# 66.7% → 50.0%,2026-08-12 实跑确认)。一个量有两个真相源,取哪个看消费方心情—— +# 与本分片删掉 fps / loop / palette 是同一条理由。 +# +# 现在:本类独占全局刻度,strategy 的子进度由 _BandProgress 线性映射进 derive 区间。 +# 刻度取 10 而不是 5,是为了给 derive 段留出中间刻度 —— 否则子进度只能全部落在同一格, +# 虽不倒退但也不动。 +_TOTAL = 10 +_TICK_PRECHECK = 0 +_TICK_ROUTE = 1 +_DERIVE_FROM, _DERIVE_TO = 2, 7 # strategy 的 0..sub_total 映射到 [2, 7] +_TICK_LASTMILE = 8 +_TICK_PACKAGE = 9 + + +class _BandProgress(ProgressPort): + """把子组件自报的 ``(i, sub_total)`` 线性映射进外层刻度的 ``[lo, hi]`` 区间。 + + 为什么由适配器换算,而不是让 strategy 直接按全局刻度报:strategy 是可插拔件, + 步数各路线不同(视频路线 3 步,逐帧路线未实现、步数必然不同),让它知道外层有几步 + 就把它钉死在 generator 当前的步骤布局上。也不要求 strategy **声明**自己有几步—— + 那又是一个"声明值 vs 实际值"的第二真相源,声明错了没人拦。 + + 只读 strategy 每次调用时自报的 ``total``,故 strategy 侧零改动。 + """ + + __slots__ = ("_inner", "_lo", "_hi") + + def __init__(self, inner: ProgressPort, lo: int, hi: int) -> None: + self._inner = inner + self._lo = lo + self._hi = hi + + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + # total<=0 时按 0 处理:子组件报了个没法换算的刻度,不能因此炸掉一条已经花过钱的 + # 生产线,退化成"停在区间起点"即可(仍然单调)。 + frac = 0.0 if total <= 0 else min(1.0, max(0.0, i / total)) + self._inner.step(stage, self._lo + int((self._hi - self._lo) * frac), _TOTAL, note) class CharacterGenerator(CharacterGeneratorPort): @@ -58,14 +98,14 @@ def generate( # 预检与出帧必须用**同一个** canvas:比例上限是由交付画布几何推出来的, # 传一个、出另一个就等于预检按方形判、出帧按非方出(见 master_check)。 facts = check_master(master, canvas) - progress.step("precheck", 0, 4, facts.note()) + progress.step("precheck", _TICK_PRECHECK, _TOTAL, facts.note()) # ② 选路线(架构决策矩阵)。装配表里没有 = 该路线未实现,在边界上炸, # 不要让"看着成功、内容是空"的结果流到 server 去落库。 route = ROUTE_MATRIX[action.action] # .value 而不是枚举本身:Python 3.11+ 的 str-mixin 枚举 __format__ 会给出 # "ActionType.WALK",这串字最终是用户看到的进度文案(3.12.13 实测)。 - progress.step("route", 1, 4, f"{action.action.value} → {route.value}") + progress.step("route", _TICK_ROUTE, _TOTAL, f"{action.action.value} → {route.value}") strategy = self._by_route.get(route) if strategy is None: raise NotImplementedError( @@ -74,7 +114,9 @@ def generate( ) # ③ 生成帧(交给 strategy —— 串联) - frames = strategy.derive(card, action, master, progress) + frames = strategy.derive( + card, action, master, _BandProgress(progress, _DERIVE_FROM, _DERIVE_TO) + ) # ③.5 帧数必须与契约相符。A2 把 n_frames 从 len(poses) 的推导值改成调用方直接声明的 # 承诺,而抽帧那两个函数都会**静默少给**:slicing.pick_cycle / pick_oneshot 在 @@ -99,7 +141,7 @@ def generate( # ⑥ 出参:帧 + 逐帧时长 + 成色(上传 / 落库在 server 侧) progress.step( - "package", 3, 4, + "package", _TICK_PACKAGE, _TOTAL, f"{len(aligned)} 帧 + 逐帧时长(动量 {quality.motion_scale:.2f}," f"死帧 {len(quality.dead_frames)}/{len(aligned)})", ) @@ -144,7 +186,7 @@ def _lastmile( 原尺寸居中贴进 512 画布,于是这里刚对齐好的脚线 0.92 被挪到 0.709(2026-08-11 实测),角色不站在地上、跨动作对齐也失效。在这里一次出到位就没有那一步了。 """ - progress.step("lastmile", 2, 4, "脚线对齐(原地)") + progress.step("lastmile", _TICK_LASTMILE, _TOTAL, "脚线对齐(原地)") # 空帧不再静默跳过:未实现的路线现在在 strategy / 装配表处就抛错(见 generate), # 走到这里还有空帧说明 provider 或抠图吐了坏数据,同样要炸而不是原样放行。 if not frames: diff --git a/backend/tests/test_ai_engine_skeleton.py b/backend/tests/test_ai_engine_skeleton.py index 88db15d6..1280d653 100644 --- a/backend/tests/test_ai_engine_skeleton.py +++ b/backend/tests/test_ai_engine_skeleton.py @@ -10,6 +10,7 @@ from PIL import Image from windup_ai_engine.impl import CharacterGenerator +from windup_ai_engine.impl.character_generator import _DERIVE_FROM, _DERIVE_TO from windup_ai_engine.ports import GeneratedAction from windup_ai_engine.postprocess.rootmotion import DEFAULT_FPS_MS from windup_ai_engine.strategy import ( @@ -290,6 +291,73 @@ def step(self, stage: str, i: int, total: int, note: str = "") -> None: assert any("walk" in n for n in notes), notes +class _SubSteppingStrategy(DerivationStrategy): + """按自己的刻度报 3 步 —— 与真实 VideoFrameStrategy 的上报形状一致。 + + ``_MockWalkStrategy`` 一步都不报,用它测不出跨层刻度问题:必须有个子组件真的 + 往同一个 ProgressPort 上报自己的 (i, total)。 + """ + + route = GenRoute.VIDEO_I2V + + def derive(self, card, action, master, progress) -> list[bytes]: + progress.step("derive", 0, 3, "i2v 生成视频") + progress.step("derive", 1, 3, "抽帧 + 抠图") + progress.step("derive", 2, 3, "风格化") + return [_tiny_png() for _ in range(action.n_frames)] + + +def _run_and_collect_progress() -> list[tuple[str, int, int]]: + seen: list[tuple[str, int, int]] = [] + + class _SpyProgress: + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + seen.append((stage, i, total)) + + CharacterGenerator({GenRoute.VIDEO_I2V: _SubSteppingStrategy()}).generate( + CharacterCard(name="t", desc="t"), + ActionSpec(action=ActionType.WALK, n_frames=4), + _tiny_png(), _SpyProgress(), + ) + return seen + + +def test_progress_reports_one_scale_end_to_end(): + """整条生产线只能有一个 total。 + + 修之前 generator 报 i/4、中间夹着的 strategy 报 i/3,两套刻度混在同一个 + ProgressPort 上,消费方按 i/total 画条会看到 totals={3,4}。 + """ + totals = {t for _, _, t in _run_and_collect_progress()} + assert len(totals) == 1, f"同一次生成里出现了多个 total: {sorted(totals)}" + + +def test_progress_never_goes_backwards(): + """进度不许倒退 —— 这是 #181 评审实跑逮到的那个症状。 + + 修之前实测倒退两次:route 25.0% → derive 0.0%、derive 66.7% → lastmile 50.0%。 + """ + seen = _run_and_collect_progress() + pcts = [i / t for _, i, t in seen] + back = [ + (seen[k - 1], seen[k]) for k in range(1, len(pcts)) if pcts[k] < pcts[k - 1] + ] + assert not back, f"进度倒退 {len(back)} 次: {back}" + + +def test_strategy_sub_progress_lands_inside_the_derive_band(): + """子进度必须落在 derive 区间内,且区间内确实动了。 + + 只断言"不倒退"是不够的:把 _BandProgress 换成"永远报区间起点"也能通过那一条, + 进度条会在 derive 段整段卡住不动 —— 而 derive 是最慢的一段。 + """ + seen = _run_and_collect_progress() + derive = [i for stage, i, _ in seen if stage == "derive"] + assert derive, "没收到 derive 段的进度" + assert min(derive) >= _DERIVE_FROM and max(derive) <= _DERIVE_TO, derive + assert len(set(derive)) > 1, f"derive 段整段没动: {derive}" + + def test_generated_action_has_a_single_timing_source(): """出参不许有第二个描述播放速度的字段。