diff --git a/backend/packages/framework/src/windup_framework/config/provider.py b/backend/packages/framework/src/windup_framework/config/provider.py index 57182ce5..faeede24 100644 --- a/backend/packages/framework/src/windup_framework/config/provider.py +++ b/backend/packages/framework/src/windup_framework/config/provider.py @@ -16,11 +16,24 @@ class AIProviderSettings(BaseSettings): provider: str = "openai-compatible" base_url: str = "https://api.openai.com/v1" api_key: str = "" - model: str = "" + model: str = "" # 通用兜底(chat 类调用),下面三个各自专用 timeout: float = 120.0 max_retries: int = 2 chat_completions_path: str = "/chat/completions" + # ── 各能力用哪个模型 ────────────────────────────────────────────────── + # 分成三个字段而不是共用上面那个 ``model``:三条能力同时在用不同模型,共用一个 + # 字段意味着换其中一个就把另外两个也换了。默认值即当前实测在用的型号, + # 部署侧可用 AI_VIDEO_MODEL / AI_IMAGE_MODEL 覆盖。 + # + # **只有型号可配,请求形状不可配**:哪个模型吃 image_list、哪个吃 + # input_reference、FAL 队列路径长什么样,都是该模型的 API 事实而非运行参数, + # 写在 providers.sufy 的映射表里。放进配置会把"填错了会怎样"从部署期推到 + # 运行期 —— 字段塞错不会立刻报错,任务照常 queued,直到生成阶段才 failed, + # 而费用可能已经产生(2026-07-29 实测)。 + video_model: str = "kling-v2-5-turbo" + image_model: str = "gemini-2.5-flash-image" + @property def normalized_base_url(self) -> str: return self.base_url.rstrip("/") diff --git a/backend/packages/framework/src/windup_framework/providers/__init__.py b/backend/packages/framework/src/windup_framework/providers/__init__.py index 3524bbf3..fd1f448e 100644 --- a/backend/packages/framework/src/windup_framework/providers/__init__.py +++ b/backend/packages/framework/src/windup_framework/providers/__init__.py @@ -1,8 +1,18 @@ -"""按模型能力划分的 AI Provider 接口。""" +"""按模型能力划分的 AI Provider:官方客户端工厂 + 能力接口 + SUFY 实现。""" from windup_framework.config.provider import AIProviderSettings from windup_framework.providers.chat import create_chat_model from windup_framework.providers.image import create_image_client +from windup_framework.providers.interfaces import ( + ImageProvider, + MatteProvider, + VideoProvider, +) +from windup_framework.providers.matte import OnnxU2NetMatteProvider +from windup_framework.providers.sufy import ( + SufyImageProvider, + SufyVideoProvider, +) from windup_framework.providers.video import create_video_client __all__ = [ @@ -10,4 +20,13 @@ "create_chat_model", "create_image_client", "create_video_client", + # 能力接口(ai_engine 依赖这些稳定契约) + "ImageProvider", + "VideoProvider", + "MatteProvider", + # 实现 + "SufyVideoProvider", + # FAL 队列面的 i2v(现役接口形态);首帧要公网 URL,故与 uploader 成对出现 + "SufyImageProvider", + "OnnxU2NetMatteProvider", ] diff --git a/backend/packages/framework/src/windup_framework/providers/interfaces.py b/backend/packages/framework/src/windup_framework/providers/interfaces.py new file mode 100644 index 00000000..4addf0f0 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/interfaces.py @@ -0,0 +1,43 @@ +"""AI 模型底层适配器接口(framework)—— behind interface,key 由 config 注入。 + +ai_engine 经这些接口调模型,不直接读 env、不锁死具体供应商 / 模型名(可 A/B 换)。 +实测在用:图像 = gemini-flash-image;视频 = kling-v2-5-turbo(2026-07-27 端到端实测 +到 completed;#53 早期"仅 o1 可用、v2-5-turbo 下架"的结论已被该实测推翻);抠图 = rembg。 + +本文件是接口契约(真);具体 HTTP 实现见 :mod:`.sufy`。 +""" +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class ImageProvider(Protocol): + """文 + 参考图 → 图(视角规整 / 定妆 / 逐帧生成)。""" + + def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: ... + + +@runtime_checkable +class VideoProvider(Protocol): + """首帧图 + 动作 prompt → 视频(i2v,步态位移动作用)。 + + **入参恒为 bytes,不是 URL。** 上游(ai_engine.strategy)手里只有母版 bytes,而且它 + 必须有 bytes —— ``master_check`` 预检、``master_prep`` 预处理、像素化锁色板全都读 + 母版**像素**。让调用点改传 URL 的话,ai_engine 还得自己下载回 bytes 才能干活。 + + 某些供应商的接口只吃公网 URL。那属于**该 provider 自己的适配问题**:在 provider + 内部完成 bytes → URL 的转换(需要一个上传能力时由组装层注入),而不是把这个差异 + 漏给上层。这样"用哪个厂商"不会改变 ai_engine 的一行代码。 + """ + + def i2v( + self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720" + ) -> bytes: ... + + +@runtime_checkable +class MatteProvider(Protocol): + """主体抠图(rembg / u2net)—— 按主体抠,不抠颜色(浅色角色撞背景会抠穿)。""" + + def cutout(self, frame: bytes) -> bytes: ... diff --git a/backend/packages/framework/src/windup_framework/providers/matte.py b/backend/packages/framework/src/windup_framework/providers/matte.py new file mode 100644 index 00000000..ffc28866 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/matte.py @@ -0,0 +1,205 @@ +"""主体抠图 MatteProvider —— onnxruntime 直跑 u2netp,不依赖 rembg。 + +为什么不用 rembg:rembg → pymatting → numba 0.53 / llvmlite 0.36 这条老链在 Python +3.12 无轮子(实测装不上)。而 rembg 内核就是"u2netp.onnx 过一遍 onnxruntime";默认 +``alpha_matting=False`` 时根本不碰 pymatting。故直调 onnxruntime,甩掉整条死重依赖, +3.12 干净可装、可进 lock。同模型(u2netp),同质量。 + +模型解析顺序:显式 ``model_path`` → 缓存目录已存在 → 从 ``model_url`` 惰性下载。 +onnxruntime 惰性导入(启动慢、按需加载),会话按需构建一次。 +""" +from __future__ import annotations + +import io +import urllib.request +from pathlib import Path + +import numpy as np +from PIL import Image + +from .interfaces import MatteProvider + +# u2netp:轻量版(~4.7MB)。rembg 官方 release 托管;国内不可达时可预置 model_path。 +_U2NETP_URL = "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2netp.onnx" +_DEFAULT_CACHE = Path.home() / ".cache" / "windup" / "u2netp.onnx" + +# u2net 预处理常量(与 rembg 一致)。 +_MEAN = (0.485, 0.456, 0.406) +_STD = (0.229, 0.224, 0.225) +_SIZE = (320, 320) + + +# 只清理"几乎精确等于底色"的像素。阈值必须窄:2026-08-07 实测,一个铁锈橙毛 +# (222,130,70)的角色配玫红底(222,41,124),两者红通道完全相同、欧氏距离仅 104 —— +# 宽阈值会把毛判成半透明并去"反解",越解越坏(先成橄榄绿再成亮绿)。橙毛 d≈117, +# 阈值 38 完全碰不到它;而闭合空隙里的背景 d≈0,能干净移除。 +_KEY_KILL = 38.0 # d < 此值 → 判为纯背景 +_KEY_SOFT = 14.0 # 到 _KEY_KILL + _KEY_SOFT 之间线性过渡,避免硬边锯齿 +_BG_FLAT_STD = 8.0 # 四角色标准差上限;超过说明底不是纯色,不做任何清理 + +# 空洞填充用。_HOLE_ALPHA:低于此 alpha 才算"透明",参与空洞判定。 +# _HOLE_BG_TOL:到底色的距离低于此值 → 判为"确实是底色"。取值依据(2026-08-11 实测, +# 1280×720 真实视频帧):纯背景区域的色距 p99.9≈6.5、最大 11.1(视频压缩噪点); +# 而被误杀的浅肤色像素连通域中位色距 ≥17.1。14 落在这条 1.5 倍间隙里。 +_HOLE_ALPHA = 0.03 +_HOLE_BG_TOL = 14.0 + + +def _bg_key(rgb: np.ndarray) -> np.ndarray | None: + """四角取样估底色 key;底不够均匀(std 超阈值)时返回 None = 不做任何基于底色的判断。 + + 抽成独立函数是为了让"底色是什么"只有一个真相源 —— 键控清理(``_flat_bg_penalty``) + 和空洞填充(``_fill_enclosed_holes``)必须按同一个 key 判断,否则一个把某块当背景 + 清掉、另一个又把它当主体填回来,互相打架。 + """ + corners = np.concatenate([ + rgb[:12, :12].reshape(-1, 3), rgb[:12, -12:].reshape(-1, 3), + rgb[-12:, :12].reshape(-1, 3), rgb[-12:, -12:].reshape(-1, 3), + ]) + if float(corners.std(axis=0).max()) > _BG_FLAT_STD: + return None + return np.median(corners, axis=0).astype(np.float32) + + +def _spread(seed: np.ndarray, region: np.ndarray) -> np.ndarray: + """在 ``region`` 内从 ``seed`` 出发做 4-邻接连通扩散,返回可达集合。 + + 为什么不写逐像素 BFS:交付前的帧是 1280×720(约 92 万像素),纯 Python BFS 要几十秒, + 抠图是逐帧调用的,扛不住。这里按**行/列游程**传播 —— 一个 pass 就能把可达性推过 + 整条连续游程(距离不限),而不是每 pass 只推进一个像素,真实角色轮廓几个 pass 收敛。 + + 同一行里被非 region 像素隔断的两段游程,``cumsum(~region)`` 必然取到不同的 id, + 因此可以用 ``bincount`` 一次算出"每条游程里有没有种子"。 + """ + reach = seed & region + while True: + before = int(reach.sum()) + for transposed in (False, True): + reg = region.T if transposed else region + rch = reach.T if transposed else reach + rows, cols = reg.shape + run = np.cumsum(~reg, axis=1) + keys = run + np.arange(rows)[:, None] * (cols + 1) + hit = np.bincount(keys[rch], minlength=rows * (cols + 1)) > 0 + new = reg & hit[keys] + reach = new.T if transposed else new + if int(reach.sum()) == before: + return reach + + +def _fill_enclosed_holes(alpha: np.ndarray, rgb: np.ndarray) -> np.ndarray: + """把"被主体围住、且整块都不是底色"的透明连通域填回主体(alpha=1)。 + + 要解决的问题:u2netp 判错或键控误杀会在主体内部留下透明洞,放大看是背景直接透出来。 + + **为什么只判"不与边界连通"不够 —— 会把两腿之间填实。** 直觉上腿间空隙从下方通到 + 画面底边,所以"从边界出发的连通域"就能保护它。2026-08-11 在真实走路帧上实测: + **不成立**。迈步相里两只靴子在下方交叠,把腿间空隙彻底封死 —— 它就是一块不与边界 + 连通的背景域(实测 src_017 有 530 像素、归档 frame_03 有 129 像素),只按连通性判, + 这一整块会被填成主体,两条腿直接焊在一起。 + + 所以判据是**连通性 + 颜色**两条一起:一个透明连通域只要"碰到画面边界"或者"里面 + 存在任何一个确实是底色的像素",就不是洞。腿间空隙整块就是底色(实测中位色距 6.2, + 远低于 _HOLE_BG_TOL),必然被这条否决;而被误杀的主体像素(实测中位色距 ≥17.1) + 不含底色像素,才会被填。两条否决合成一次扩散:种子 = 边界上的透明像素 ∪ 底色像素。 + + 与 ``_flat_bg_penalty`` 的分工:那个函数按颜色**做减法**(把闭合空隙里的底色清掉), + 这个函数按颜色**决定不加回来** —— 同一个 key、同一个方向,不会互相拆台。 + """ + key = _bg_key(rgb) + if key is None: + return alpha # 底不是纯色 → 无从判断哪块是真空隙,一律不填 + transparent = alpha < _HOLE_ALPHA + if not transparent.any(): + return alpha + border = np.zeros_like(transparent) + border[0, :] = border[-1, :] = True + border[:, 0] = border[:, -1] = True + is_bg_color = np.linalg.norm(rgb - key, axis=2) < _HOLE_BG_TOL + seed = transparent & (border | is_bg_color) + holes = transparent & ~_spread(seed, transparent) + if not holes.any(): + return alpha + out = alpha.copy() + out[holes] = 1.0 + return out + + +def _flat_bg_penalty(rgb: np.ndarray) -> np.ndarray: + """底色清理系数(0=纯背景,1=主体),形状与图同宽高。 + + 为什么需要它:u2netp 是显著性模型,对**闭合区域**天然失灵 —— 四足角色腿间的 + 背景是一块被主体围住的空隙,显著性把它当成主体内部,整块底色留在产物里 + (2026-08-07 实测)。而母版底色是刻意生成的纯色,均匀度极高(实测四角标准差 1.0~1.2), + 用它做一次窄阈值清理就能补上这个洞。 + + 与"按颜色抠是死路"那条规则的边界:那条说的是**拿颜色当主体判据**(白底浅色角色 + 会被抠穿)。这里主体判据仍然是 u2netp,颜色只用来**做减法** —— 绝不新增主体像素, + 最坏情况是少清理一点,不会抠穿角色。底色不够均匀时(std 超阈值)直接返回全 1, + 等于不清理。 + """ + key = _bg_key(rgb) + if key is None: + return np.ones(rgb.shape[:2], dtype=np.float32) # 底不是纯色 → 不动 + d = np.linalg.norm(rgb - key, axis=2) + return np.clip((d - _KEY_KILL) / _KEY_SOFT, 0.0, 1.0).astype(np.float32) + + +class OnnxU2NetMatteProvider(MatteProvider): + """u2netp.onnx via onnxruntime。frame bytes → 抠好的 PNG(RGBA) bytes。""" + + def __init__(self, model_path: str | Path | None = None, model_url: str = _U2NETP_URL) -> None: + self._model_path = Path(model_path) if model_path else _DEFAULT_CACHE + self._model_url = model_url + self._session = None # 惰性 + + def _ensure_model(self) -> Path: + if not self._model_path.exists(): + self._model_path.parent.mkdir(parents=True, exist_ok=True) + urllib.request.urlretrieve(self._model_url, self._model_path) + return self._model_path + + def _get_session(self): + if self._session is None: + try: + import onnxruntime as ort # 惰性:导入慢 + except ImportError as e: # pragma: no cover - 取决于安装环境 + # **不静默降级。** 这里曾在 ImportError 时回落到"取四角主色做 chroma-key", + # 有两个问题:①猜背景色 —— 白底母版四角就是白色,浅色角色(骨白/银甲)与背景 + # 撞色会被抠穿;②静默 —— 开发机上看着能跑、输出其实是坏的,要到产物验收才发现。 + raise RuntimeError( + "onnxruntime 不可用,无法做主体抠图。请安装 onnxruntime" + "(注意 <1.24 才有 macOS Intel 轮子)。" + ) from e + self._session = ort.InferenceSession( + str(self._ensure_model()), providers=["CPUExecutionProvider"] + ) + return self._session + + def _predict_mask(self, img: Image.Image) -> Image.Image: + """u2netp 前向 → 单通道显著性 mask(L,原图尺寸)。""" + im = img.convert("RGB").resize(_SIZE, Image.LANCZOS) + ary = np.array(im).astype(np.float32) + ary = ary / max(float(ary.max()), 1e-6) + tmp = np.zeros((_SIZE[1], _SIZE[0], 3), dtype=np.float32) + for c in range(3): + tmp[:, :, c] = (ary[:, :, c] - _MEAN[c]) / _STD[c] + tensor = np.expand_dims(tmp.transpose(2, 0, 1), 0).astype(np.float32) + + session = self._get_session() + pred = session.run(None, {session.get_inputs()[0].name: tensor})[0][:, 0, :, :] + mi, ma = float(pred.min()), float(pred.max()) + pred = (pred - mi) / max(ma - mi, 1e-6) + mask = (pred.squeeze() * 255).astype(np.uint8) + return Image.fromarray(mask, "L").resize(img.size, Image.LANCZOS) + + def cutout(self, frame: bytes) -> bytes: + img = Image.open(io.BytesIO(frame)).convert("RGBA") + rgb = np.asarray(img.convert("RGB"), dtype=np.float32) + alpha = np.asarray(self._predict_mask(img), dtype=np.float32) / 255.0 + alpha = alpha * _flat_bg_penalty(rgb) + alpha = _fill_enclosed_holes(alpha, rgb) + out = np.dstack([np.asarray(img.convert("RGB")), alpha * 255.0]).astype(np.uint8) + buf = io.BytesIO() + Image.fromarray(out, "RGBA").save(buf, "PNG") + return buf.getvalue() diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py new file mode 100644 index 00000000..31ec80f7 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -0,0 +1,357 @@ +"""Provider 接口的 SUFY / qnaigc(Modelink 网关)同步实现。 + +本模块实现三个 provider:视频(i2v)、图像(文生图 / 图生图)、以及它们共用的下载与首帧 +处理。抠图另在 :mod:`.matte`。 + +视频走 OpenAI 风格面(:class:`SufyVideoProvider`),首帧是 base64 dataURI:: + + POST /v1/videos {model, prompt, size, seconds, mode, input_reference} + 轮询 GET /v1/videos/{id} → status==completed → task_result.videos[0].url → 下载 mp4 + +2026-07-27 对 kling-v2-5-turbo 端到端实测到 completed。 + +图像走 OpenAI 兼容的 ``/chat/completions``(:class:`SufyImageProvider`),参考图以 data URI +塞进 ``content`` 数组 —— 与视频的提交-轮询-下载三段式完全不同的调用形状。 + +**网关上还有另一套 FAL 队列面**(veo / seedance / vidu 只在那一面)。曾实现过,但因为 +从未被真实调用过而移除,见本文件中段那条注释里记下的两个实测事实。 + +型号与 key / base_url 均由 ``AIProviderSettings`` 注入,provider 内不读 env;哪个模型吃 +什么请求字段属该模型的 API 事实,写在代码里而不是配置里(填错只会在生成阶段才 failed, +而费用可能已产生)。重依赖(PIL)惰性导入,保证模块导入零成本。 +""" +from __future__ import annotations + +import base64 +import io +import json +import logging +import re +import time + +import httpx + +from windup_framework.config.provider import AIProviderSettings, settings + +from .interfaces import ImageProvider, VideoProvider + +logger = logging.getLogger("windup.providers.sufy") + +# 只有 kling-video-o1 走 image_list;v2 系列 / sora 走 input_reference(字段按模型选,塞错任务会 failed)。 +_IMAGE_LIST_MODELS = ("kling-video-o1",) +DEFAULT_VIDEO_MODEL = "kling-v2-5-turbo" + + +def _fit_first_frame(frame: bytes, size: str) -> bytes: + """首帧 bytes → 等比缩放 + 背景色补边到目标尺寸 → JPG(RGB,q90) bytes。 + + 不强拉到目标尺寸(母版多为横幅,强压成方会把角色压成瘦长鬼影);JPG 因 PNG base64 + 会 VENDOR_FAILED(实测)。 + + 这一步同时是 kling 系"输出画幅"的唯一控制点:kling 的 i2v 端点没有 resolution/size + 字段,成片画幅跟随首帧,所以 ``size`` 只能在这里生效。 + """ + from PIL import Image + + w, h = (int(x) for x in size.split("x")) + im = Image.open(io.BytesIO(frame)).convert("RGB") + pad = im.getpixel((0, 0)) + fitted = im.copy() + fitted.thumbnail((w, h), Image.LANCZOS) + canvas = Image.new("RGB", (w, h), pad) + canvas.paste(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2)) + buf = io.BytesIO() + canvas.save(buf, "JPEG", quality=90) + return buf.getvalue() + + +def _first_frame_datauri(frame: bytes, size: str) -> str: + """首帧 → base64 dataURI(OpenAI 风格 ``/v1/videos`` 面专用;FAL 面不吃 dataURI)。""" + return "data:image/jpeg;base64," + base64.b64encode(_fit_first_frame(frame, size)).decode() + + +class SufyVideoProvider(VideoProvider): + """kling i2v(默认 v2-5-turbo)。首帧 + 动作 prompt → mp4 bytes。""" + + def __init__( + self, + config: AIProviderSettings = settings, + model: str | None = None, + mode: str = "std", + poll_interval: float = 60.0, + max_min: int = 30, + ) -> None: + # 轮询间隔必须 > 0:下面用 `max_min * 60 // poll` 算预算次数,传 0 直接除零 + # (2026-08-11 补 i2v 主流程测试时逮到)。0 的语义本身也不成立 —— 那是忙等, + # 会把网关打满。测试要跑快就把 time.sleep 打桩掉,别把间隔设成 0。 + if poll_interval <= 0: + raise ValueError(f"poll_interval 必须为正数,收到 {poll_interval}") + self._cfg = config + self._model = model or config.video_model + self._mode = mode + self._poll = poll_interval + self._max_min = max_min + + def _client(self) -> httpx.Client: + return httpx.Client( + base_url=self._cfg.normalized_base_url, + headers={"Authorization": f"Bearer {self._cfg.api_key}"}, + timeout=self._cfg.timeout, + ) + + def i2v( + self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720" + ) -> bytes: + body: dict = { + "model": self._model, + "prompt": prompt, + "size": size, + "seconds": str(seconds), + "mode": self._mode, + } + if self._model in _IMAGE_LIST_MODELS: + b64 = _first_frame_datauri(first_frame, size).split(",", 1)[1] + body["image_list"] = [{"image": b64}] + else: + body["input_reference"] = _first_frame_datauri(first_frame, size) + + with self._client() as client: + job = client.post("/videos", json=body).raise_for_status().json() + jid = job.get("id") + url = None + for _ in range(max(1, int(self._max_min * 60 // self._poll))): + time.sleep(self._poll) + st = client.get(f"/videos/{jid}").raise_for_status().json() + status = st.get("status") + if status == "completed": + vids = (st.get("task_result") or {}).get("videos") or [] + url = vids[0].get("url") if vids else None + break + if status in ("failed", "cancelled"): + raise RuntimeError(f"i2v 失败: {status} — {st.get('error')}") + if not url: + raise RuntimeError("i2v 未取得视频 URL(超时或失败)") + return _download(client, url) + + +class IncompleteDownloadError(RuntimeError): + """视频下载到的字节数与 ``Content-Length`` 不符。""" + + +class UnsafeDownloadUrlError(RuntimeError): + """成品 URL 的协议不是 http(s) —— 不下载。 + + 这个 URL 来自网关响应,是外部输入。直接丢给 httpx 去 GET 一个 ``file://`` / ``data:`` + 只会在重试三次之后报一个跟协议无关的传输错,不如在这里就说清是地址不对。 + """ + + +def _same_origin(url: httpx.URL, other: httpx.URL) -> bool: + """同源判定(scheme + host + 端口,默认端口按 scheme 补齐)。 + + 语义对齐 httpx 自己在跨源重定向时摘凭证用的 ``Client._redirect_headers``; + 没直接 import 它的私有 ``_same_origin``,免得被上游改名。 + + "默认端口补齐"这一步在 httpx 0.28 下其实判不出新差别(它已把 ``:443`` / ``:80`` + 归一化成 ``port is None``,2026-08-10 变异测试确认单独拆掉这行无用例失败)。留着的理由 + 是与 httpx 保持同一套判据:一旦上游不再归一化,少了它 ``https://gw`` 与 ``https://gw:443`` + 就成了跨源,会把该带的凭证摘掉、把同源下载打成 401。 + """ + default = {"http": 80, "https": 443} + return ( + url.scheme == other.scheme + and url.host == other.host + and (url.port or default.get(url.scheme)) == (other.port or default.get(other.scheme)) + ) + + +def _download_request(client: httpx.Client, url: str) -> httpx.Request: + """构造成品下载请求;目标不在网关同源时,把 client 级凭证摘掉。 + + 为什么必须摘(2026-08-10 机器审提出):成品 URL 是**网关响应里的绝对地址**,正常情况 + 指向 CDN 域名,异常情况可以是网关返回的任意地址。而 httpx 只在跨源**重定向**时才自动 + 摘 Authorization,对这种一开始就跨源的直连请求,client 级 headers 会原样带过去 —— + 于是 ``Authorization: Bearer/Key `` 被发给了那个域名,等于把 API key 交出去。 + + 同源时保留凭证:网关也可能签发自己域名下的下载链接,那条路径摘了头就是 401。 + 所以按目标地址判定,不是一律摘、也不是一律留。 + """ + request = client.build_request("GET", url) + if request.url.scheme not in ("http", "https"): + raise UnsafeDownloadUrlError(f"成品 URL 必须是 http(s),收到 {str(request.url)!r}") + if not _same_origin(request.url, client.base_url): + # 只摘目标域名不该看到的:Proxy-Authorization 是给代理的,与目标是否同源无关,别动它。 + request.headers.pop("Authorization", None) + request.headers.pop("Cookie", None) + return request + + +def _download(client: httpx.Client, url: str, tries: int = 3) -> bytes: + """下载已生成好的视频,带重试 + 长度校验。 + + 为什么单次读取不够(2026-08-05 实测,同一角色连续两单复现):原实现是 + ``client.get(url).raise_for_status().content``。**视频此时已经生成、费用已经产生**, + 只要读 body 时连接断一次,整单就废:: + + peer closed connection without sending complete message body + (received 720450 bytes, expected 929531) + + 重试是安全的:这是对成品 URL 的 GET,幂等且不再计费——**代价是一次重下, + 不重试的代价是一次重新生成**。 + + 长度校验是因为截断不一定抛异常:服务端提前关流而客户端已收到部分 body 时, + ``.content`` 可能直接返回短 bytes,那样坏视频会一路流到出帧环节才暴露, + 在那里看起来像"解码失败",很难回溯到这里。``Content-Length`` 缺失(分块传输)时跳过校验。 + + 凭证处理见 :func:`_download_request`。请求在进循环之前就构造好:地址不合法要在 + 发出任何一次请求之前炸,而不是重试三次之后。 + """ + request = _download_request(client, url) + last: Exception | None = None + for attempt in range(tries): + try: + # send 不会再合并 client 级 headers(build_request 时已合并过), + # 所以上面摘掉的 Authorization 不会被重新加回来。 + response = client.send(request) + response.raise_for_status() + body = response.content + expected = response.headers.get("content-length") + if expected and len(body) != int(expected): + raise IncompleteDownloadError(f"视频下载不完整: {len(body)}/{expected} 字节") + return body + except (httpx.HTTPError, IncompleteDownloadError) as exc: + last = exc + if attempt < tries - 1: + time.sleep(2**attempt) + raise RuntimeError(f"视频下载失败(已重试 {tries} 次): {last}") from last + + +# ── FAL 队列面 ────────────────────────────────────────────────────────────── +# 2026-08-07 拉网关 OpenAPI spec 核对得到:平台的 22 个图生视频端点全在 /queue/ 下, +# 首帧字段一律是 URL 形态(image_url / start_image_url),同日实测送 dataURI 无一能用。 +# (spec 里 seedance / vidu-q3 / kling-v3-turbo 三家的字段说明写着"URL 或 base64", +# 与实测冲突,未复验。本实现一律只发公网 URL —— 那是 22 个端点的共同解。) +# +# 每家有三样东西不一样,而且**没有一条能靠拼字符串猜出来**,所以下面是一张硬表: +# 1. 提交路径:型号段各不相同(o3 / v3 / v3/turbo / v2.6 / v2.5-turbo / o1), +# 有的带 {mode} 路径参数、有的不带(veo / seedance / minimax / vidu 不带)。 +# 2. 首帧字段名:同是 kling,o3 与 v2.5-turbo 叫 image_url,v3 / v2.6 / o1 却叫 +# start_image_url。塞错字段 = 送了图但模型没收到。 +# 3. 轮询前缀:**不是**提交路径加个 /requests。kling 六个型号共用一个 +# /queue/fal-ai/kling-video/requests/{id},型号段与 mode 段都不出现。 +# 这一条是最容易想当然拼错的地方。 +# +# 另有两处形态差异也写进表里,因为取值形式不同会被网关 400: +# - 时长字段都叫 duration,但取值分三种形态:"5"(kling/seedance)、"8s"(veo)、 +# 5(minimax/vidu,整数)。 +# - 分辨率:kling 系**没有**这个字段(成片画幅跟随首帧,所以 size 只能靠补边生效); +# 其余各家的档位枚举各不相同。 + + +# ── FAL 队列面(veo / seedance / vidu)已移除 ──────────────────────────────── +# +# 曾有一整套 FalQueueVideoProvider + FirstFrameUploader + 端点映射表(412 行、28 条 +# 测试)。删掉的理由与 GenRoute 只列有实现的路线是同一条:**它从未被真实调用过** +# —— app / ai_engine 里零引用,产品链路走不到,而"代码在仓里"会让人以为该能力已具备。 +# +# 真要接 veo / seedance 时连同一次真实调用一起加回。届时的两个已知事实(实测挣得, +# 别再摸索一遍): +# 1. FAL 面只吃**公网 URL**,不吃 base64;塞 base64 会 status=queued 之后在生成阶段 +# 才 failed,费用可能已经产生。 +# 2. 鉴权头是 `Authorization: Key `,不是 `Bearer`;路径与 /v1 平级,不是它的子路径。 +# 归档实测记录见项目参考资料(图生视频 API 实测文档)。 + + +DEFAULT_IMAGE_MODEL = "gemini-2.5-flash-image" + +# "调用成功但没返回有效图"的重试次数。与 _download 的网络重试是两码事:那个治连接断, +# 这个治模型返回了一条不含图的正常响应(实测偶发)。也是为什么下面要判 base64 长度 —— +# 返回里可能带一个几十字节的占位串,当图存下去就是一个打不开的文件。 +_IMAGE_TRIES = 3 +_MIN_IMAGE_BYTES = 5000 +_CONNECT_RETRIES = 3 + +# 从响应里捞 data URI。模型把图放在 message.content 里,而不同网关的包裹层级不一样 +# (有的 content 是字符串、有的是 parts 数组),故对整个响应 JSON 做一次正则, +# 不去猜层级 —— 猜错的代价是"调用成功、费用已产生、但我们说没图"。 +_DATA_URI = re.compile(r"data:image/[^;]+;base64,([A-Za-z0-9+/=]{100,})") + + +class SufyImageProvider(ImageProvider): + """文生图 / 图生图 provider(OpenAI 兼容的 ``/chat/completions`` 面)。 + + 调用形状与 i2v 那两个 provider 完全不同:图像走 chat 接口、参考图以 data URI 塞进 + ``content`` 数组,没有提交-轮询-下载三段式。 + + 2026-08-10 修:此前 ``gen_image`` 直接抛 NotImplementedError,而 + ``POST /generation/image`` 端点是可达的、``ImageTaskExecutor`` 又默认实例化本类 —— + 于是每个图像任务都稳定走到 FAILED。端点看着可用、实际必失败,正是本仓最忌讳的形态 + (机器审逮到)。实现取自管线仓已跑通的通路(同日用它出过三张角色母版)。 + """ + + def __init__( + self, + config: AIProviderSettings = settings, + model: str | None = None, + ) -> None: + self._cfg = config + self._model = model or config.image_model + + def _client(self) -> httpx.Client: + return httpx.Client( + base_url=self._cfg.normalized_base_url, + headers={"Authorization": f"Bearer {self._cfg.api_key}"}, + timeout=self._cfg.timeout, + # retries 只覆盖建连阶段的失败(SSL 握手、连接被重置)。本机走代理时这类抖动 + # 常见,已跑通的管线实现正是靠一层网络重试扛住的;不加会在人家能恢复的地方 + # 放弃。它不重试读超时与 5xx —— 那两种请求可能已达上游,重发会重复计费。 + transport=httpx.HTTPTransport(retries=_CONNECT_RETRIES), + ) + + def _post(self, client: httpx.Client, body: dict) -> dict: + """发一次请求。把"网关没有这个模型"翻译成能照着修的错误。 + + 为什么值得专门处理:同一把 key 下不同网关的模型目录**不一样**。实测 + ``GET /v1/models``:一个网关 73 个模型、一个图像模型都没有;另一个 134 个、 + 含本模块默认的那个(2026-08-10)。配错 ``AI_BASE_URL`` 时原始报错只是一条 + 404,读的人无从知道该去改配置还是改模型名。 + """ + resp = client.post(self._cfg.chat_completions_path, json=body) + if resp.status_code in (400, 404): + raise RuntimeError( + f"网关 {self._cfg.normalized_base_url} 拒绝了模型 {self._model!r}" + f"(HTTP {resp.status_code})。先确认该网关的目录里有它:" + f"GET {self._cfg.normalized_base_url}/models —— 不同网关目录不同," + f"同一把 key 也是。原始响应:{resp.text[:200]}" + ) + return resp.raise_for_status().json() + + def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: + """提示词 + 参考图 → 一张 PNG bytes。拿不到有效图就抛,不返回空 bytes。 + + 为什么不返回空 bytes 兜底:上游 ``ImageTaskExecutor`` 会把返回值直接上传对象存储 + 并写进任务结果,一个 0 字节的"成功"会变成用户看到的一张裂图。 + """ + content: list[dict] = [{"type": "text", "text": prompt}] + for raw in refs: + b64 = base64.b64encode(raw).decode() + content.append({ + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{b64}"}, + }) + body = {"model": self._model, "messages": [{"role": "user", "content": content}]} + + last = "" + with self._client() as client: + for attempt in range(1, _IMAGE_TRIES + 1): + payload = self._post(client, body) + found = _DATA_URI.search(json.dumps(payload)) + if found: + data = base64.b64decode(found.group(1)) + if len(data) >= _MIN_IMAGE_BYTES: + return data + last = f"图只有 {len(data)} 字节(下限 {_MIN_IMAGE_BYTES})" + else: + last = "响应里没有 data URI" + logger.warning("文生图第 %d/%d 次没拿到有效图:%s", attempt, _IMAGE_TRIES, last) + raise RuntimeError(f"文生图 {_IMAGE_TRIES} 次均未取得有效图:{last}") diff --git a/backend/tests/test_matte_provider.py b/backend/tests/test_matte_provider.py new file mode 100644 index 00000000..20837f8c --- /dev/null +++ b/backend/tests/test_matte_provider.py @@ -0,0 +1,402 @@ +"""OnnxU2NetMatteProvider 契约测试(不加载模型 / 不联网:构造 + 协议合规)。""" + +from windup_framework.providers import MatteProvider, OnnxU2NetMatteProvider + + +def test_onnx_matte_satisfies_matte_provider_protocol(): + # 运行时可检查协议:有 cutout 即满足 MatteProvider(server/ai_engine 依赖此契约) + provider = OnnxU2NetMatteProvider(model_path="/nonexistent/u2netp.onnx") + assert isinstance(provider, MatteProvider) + assert callable(provider.cutout) + + +def test_onnx_matte_lazy_no_model_load_on_construct(): + # 构造不触发下载 / 会话创建(惰性),模型缺失也不报错 + provider = OnnxU2NetMatteProvider(model_path="/nonexistent/u2netp.onnx") + assert provider._session is None + + +# ── 底色清理(2026-08-07 实测挣得)──────────────────────────────────────────── + + +def _rgb(w, h, bg, blob=None): + import numpy as np + a = np.zeros((h, w, 3), dtype=np.float32) + a[:, :] = bg + if blob: + (x0, y0, x1, y1), c = blob + a[y0:y1, x0:x1] = c + return a + + +def test_flat_background_is_killed_but_subject_untouched(): + """纯色底 → 系数 0(会被清掉);主体色 → 系数 1(一像素不动)。""" + from windup_framework.providers.matte import _flat_bg_penalty + + bg = (222, 41, 124) # 实测的玫红底 + fur = (222, 130, 70) # 铁锈橙毛:与底色红通道相同,欧氏距离仅约 104 + a = _rgb(80, 60, bg, blob=((20, 15, 60, 45), fur)) + p = _flat_bg_penalty(a) + assert p[2, 2] == 0.0, "四角纯背景必须被判为 0" + assert p[30, 40] == 1.0, "橙毛必须完全不受影响 —— 宽阈值会把它反解成绿色" + + +def test_enclosed_background_gap_is_killed(): + """被主体围住的背景空隙也要清掉 —— u2netp 对闭合区域天然失灵。""" + from windup_framework.providers.matte import _flat_bg_penalty + + bg = (222, 41, 124) + a = _rgb(80, 60, bg, blob=((16, 16, 64, 44), (100, 120, 140))) # 避开取样用的 12×12 角落 + a[24:34, 30:50] = bg # 主体内部挖一个洞,填回底色 + p = _flat_bg_penalty(a) + assert p[30, 40] == 0.0, "闭合空隙里的底色必须被清掉" + assert p[20, 20] == 1.0, "洞外的主体不受影响" + + +def test_non_flat_background_disables_cleanup_entirely(): + """底色不均匀时一律不清理 —— 宁可漏,不可误伤。""" + import numpy as np + + from windup_framework.providers.matte import _flat_bg_penalty + + rng = np.random.default_rng(0) + noisy = rng.uniform(0, 255, (60, 80, 3)).astype(np.float32) + assert (_flat_bg_penalty(noisy) == 1.0).all() + + +def test_cleanup_only_subtracts_never_adds_subject(): + """系数恒在 [0,1] —— 只做减法,最坏情况是少清理,不会凭空造出主体。""" + from windup_framework.providers.matte import _flat_bg_penalty + + a = _rgb(40, 40, (0, 255, 0), blob=((5, 5, 35, 35), (200, 60, 60))) + p = _flat_bg_penalty(a) + assert p.min() >= 0.0 and p.max() <= 1.0 + + +def test_missing_onnxruntime_raises_instead_of_guessing_background(): + """装不上就报出来,不能回落到"猜四角主色"——白底浅色角色会被抠穿。""" + import builtins + + import pytest + + from windup_framework.providers.matte import OnnxU2NetMatteProvider + + real = builtins.__import__ + + def blocked(name, *a, **k): + if name == "onnxruntime": + raise ImportError("blocked for test") + return real(name, *a, **k) + + builtins.__import__ = blocked + try: + with pytest.raises(RuntimeError, match="onnxruntime"): + OnnxU2NetMatteProvider()._get_session() + finally: + builtins.__import__ = real + + +# ── 封闭空洞填充(2026-08-11 在 121 帧真实走路视频帧上实测挣得)────────────────── +# +# 背景:交付帧放大看,主体内部会有透明洞(背景直接透出来)。实测拆开成因: +# · u2netp 自身在主体内部造的洞:8 帧抽样里 6 帧为 0 —— 不是主要成因; +# · 键控误杀:_flat_bg_penalty 每帧杀掉 820~2346 个 u2netp 判为主体的像素 —— +# 浅肤色 (243,221,200) 到灰底 (219,219,220) 的欧氏距离只有 31.3,窄于 _KEY_KILL=38。 +# 这些被误杀的像素被主体围住,正是"封闭空洞",填回来即修复。 +# +# 但**只按"不与画面边界连通"判定会把两腿之间填实**:迈步相里两只靴子在下方交叠, +# 把腿间空隙彻底封死。实测 121 帧中 80 帧存在这种封闭的底色空隙,共 25173 像素; +# 朴素版(只判连通性)把这 25173 像素全部填成主体(最惨单帧 3172 像素,两腿焊死), +# 加了颜色守卫后填掉 0 像素。下面的用例把这条守住。 + + +def _walk_frame(*, gap_closed: bool, hole: bool = False, eroded_leg: bool = False): + """造一帧"迈步相":灰底 + 躯干 + 两条腿 + 腿间底色空隙。 + + ``gap_closed=True`` 时靴子在下方交叠、把腿间空隙封死(实测 80/121 帧是这形状)。 + 颜色取实测值:底 (219,219,220)、浅肤 (243,221,200)(两者距离 31.3,窄于 _KEY_KILL)。 + 返回 (rgb float32, alpha float32)。 + """ + import numpy as np + + bg, skin, cloth = (219, 219, 220), (243, 221, 200), (110, 130, 100) + h, w = 64, 64 + rgb = np.full((h, w, 3), bg, dtype=np.float32) + alpha = np.zeros((h, w), dtype=np.float32) + + def paint(y0, y1, x0, x1, color): + rgb[y0:y1, x0:x1] = color + alpha[y0:y1, x0:x1] = 1.0 + + paint(8, 32, 20, 44, cloth) # 躯干 + paint(32, 52, 20, 28, skin) # 后腿 + paint(32, 52, 36, 44, skin) # 前腿 + if gap_closed: + paint(52, 58, 20, 44, cloth) # 靴子交叠 → 腿间空隙被封死 + else: + paint(52, 58, 20, 28, cloth) # 两只靴子分开 → 空隙通到画面底边 + paint(52, 58, 36, 44, cloth) + if hole: + alpha[14:20, 28:36] = 0.0 # 躯干内部的洞:颜色还是衣服色 + if eroded_leg: + alpha[36:46, 22:26] = 0.0 # 腿内部被键控误杀的一条:颜色是浅肤色 + return rgb, alpha + + +def _gap_slice(): + """腿间空隙区域(rgb 一直是底色,alpha 一直应为 0)。""" + return (slice(32, 52), slice(28, 36)) + + +def test_enclosed_hole_in_subject_is_filled(): + """被主体围住、颜色不是底色的透明块 = 洞,填成主体。""" + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=True, hole=True) + out = _fill_enclosed_holes(alpha, rgb) + assert (out[14:20, 28:36] == 1.0).all(), "躯干内部的洞必须被填成主体" + + +def test_keyed_out_skin_inside_leg_is_filled(): + """被 _flat_bg_penalty 误杀的浅肤色(实测每帧 820~2346 px)要能填回来。""" + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=True, eroded_leg=True) + out = _fill_enclosed_holes(alpha, rgb) + assert (out[36:46, 22:26] == 1.0).all(), "浅肤色距底色 31.3,不是底色,必须填回主体" + + +def test_closed_leg_gap_is_never_filled(): + """**核心回归**:靴子交叠把腿间空隙封死时,它照样不能被填 —— 否则两腿焊在一起。 + + 实测:只判"不与边界连通"的朴素版在这里会把整块空隙填掉(121 帧共 25173 px)。 + """ + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=True) + ys, xs = _gap_slice() + assert (alpha[ys, xs] == 0.0).all(), "前提:空隙本来是透明的" + out = _fill_enclosed_holes(alpha, rgb) + assert (out[ys, xs] == 0.0).all(), "腿间空隙整块是底色,一个像素都不能填" + + +def test_open_leg_gap_is_never_filled(): + """空隙通到画面底边时同样不能填 —— 这条也钉死"绝不能按行/按列填"。 + + 按行填会看到"这一行左右都是主体"就把中间填上,正是这里要拦的。 + """ + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=False) + ys, xs = _gap_slice() + out = _fill_enclosed_holes(alpha, rgb) + assert (out[ys, xs] == 0.0).all(), "与边界连通的空隙不是洞" + assert (out == alpha).all(), "没有洞的帧必须逐像素不变" + + +def test_border_touching_transparent_area_is_never_filled(): + """贴着画幅边缘的透明区域不是洞 —— 哪怕它的颜色一点也不像底色。 + + 真实场景:i2v 出的帧经常把角色下半身裁出画,两腿之间是一条暗投影(不是干净底色), + 这条投影只从画幅下沿通向画外,左右被两条腿封死。只靠"颜色像不像底色"判断会把 + 它当成洞、填成主体(两腿又焊上了),所以"从边界出发"这条种子必须保留。 + """ + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=False) + for x0, x1 in ((20, 28), (36, 44)): # 两条腿一直延到画幅下沿 + rgb[52:64, x0:x1] = (110, 130, 100) + alpha[52:64, x0:x1] = 1.0 + rgb[32:64, 28:36] = (60, 55, 50) # 腿间暗投影:远离底色 + alpha[32:64, 28:36] = 0.0 # 只从下沿通向画外,左右被腿封死 + + out = _fill_enclosed_holes(alpha, rgb) + assert (out[32:64, 28:36] == 0.0).all(), "连到画幅边界的透明区域一律不是洞" + + +def test_frame_without_holes_is_pixel_identical(): + """没有洞 → 逐像素不变(防回归硬指标)。""" + import numpy as np + + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=True) + out = _fill_enclosed_holes(alpha, rgb) + assert np.array_equal(out, alpha) + + +def test_fill_only_adds_alpha_never_removes(): + """只做加法:alpha 绝不被改小,改动值只能是 1.0 —— 填洞不该顺手抠掉别的。""" + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=True, hole=True, eroded_leg=True) + out = _fill_enclosed_holes(alpha, rgb) + assert (out >= alpha).all() + assert (out[out != alpha] == 1.0).all() + + +def test_non_flat_background_disables_fill_entirely(): + """底色不均匀 → 无从判断哪块是真空隙,一律不填(与键控清理同一条纪律)。""" + import numpy as np + + from windup_framework.providers.matte import _fill_enclosed_holes + + rgb, alpha = _walk_frame(gap_closed=True, hole=True) + rng = np.random.default_rng(0) + noisy = rng.uniform(0, 255, rgb.shape).astype(np.float32) + out = _fill_enclosed_holes(alpha, noisy) + assert np.array_equal(out, alpha), "底不是纯色时必须整帧不动" + + +def test_bg_key_returns_none_when_background_is_not_flat(): + """底色真相源:不均匀时返回 None,键控与填洞都据此停手。""" + import numpy as np + + from windup_framework.providers.matte import _bg_key + + rng = np.random.default_rng(1) + assert _bg_key(rng.uniform(0, 255, (40, 40, 3)).astype(np.float32)) is None + assert _bg_key(np.full((40, 40, 3), 219, dtype=np.float32)) is not None + + +def test_spread_is_four_connected_not_scanline(): + """扩散必须是真 4-邻接连通:L 形走廊要能拐弯走通,断开的孤岛不能被沾到。 + + 只做行传播(或只做列传播)都会让 L 形的另一条臂走不通,这条用例把两个方向都钉死。 + """ + import numpy as np + + from windup_framework.providers.matte import _spread + + region = np.zeros((20, 20), dtype=bool) + region[2, 2:18] = True # 横臂 + region[2:18, 17] = True # 竖臂(拐弯) + island = (15, 3) + region[island] = True # 孤岛:与走廊不连通 + seed = np.zeros_like(region) + seed[2, 2] = True + + reach = _spread(seed, region) + assert reach[2, 17], "横臂尽头要走通(需要行传播)" + assert reach[17, 17], "竖臂尽头要走通(需要列传播)" + assert not reach[island], "不连通的孤岛绝不能被标记为可达" + + +def test_spread_matches_bruteforce_bfs_on_random_masks(): + """与逐像素 BFS 逐点等价 —— 向量化只是为了快,不能改语义。""" + from collections import deque + + import numpy as np + + from windup_framework.providers.matte import _spread + + def bfs(seed, region): + h, w = region.shape + out = np.zeros_like(region) + q = deque() + for y, x in zip(*np.nonzero(seed & region), strict=True): + out[y, x] = True + q.append((y, x)) + while q: + cy, cx = q.popleft() + for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): + ny, nx = cy + dy, cx + dx + if 0 <= ny < h and 0 <= nx < w and region[ny, nx] and not out[ny, nx]: + out[ny, nx] = True + q.append((ny, nx)) + return out + + rng = np.random.default_rng(7) + for _ in range(25): + h, w = int(rng.integers(3, 30)), int(rng.integers(3, 30)) + region = rng.random((h, w)) < rng.uniform(0.3, 0.9) + seed = rng.random((h, w)) < 0.05 + assert (_spread(seed, region) == bfs(seed, region)).all() + + +# ── cutout 的装配顺序(不碰真模型)───────────────────────────────────────── +# +# 真实推理需要 4.7MB 的 onnx 权重,CI 里既下不到也不该下。但 cutout 本身的**装配顺序** +# 是有语义的,可以用一个假 session 覆盖: +# 预测 mask → 乘键控清理系数 → 填封闭空洞 → 合成 RGBA +# 顺序错了会静默出错结果:先填洞再清理,会把刚填上的像素又清掉。 + + +class _FakeSession: + """假 onnxruntime session:返回一个中间为主体的 mask。""" + + class _In: + name = "input" + + def get_inputs(self): + return [self._In()] + + def run(self, _out, feed): + import numpy as np + + t = next(iter(feed.values())) + h, w = t.shape[2], t.shape[3] + m = np.zeros((1, 1, h, w), dtype="float32") + m[:, :, h // 4 : h * 3 // 4, w // 4 : w * 3 // 4] = 1.0 + return [m] + + +def _provider_with_fake_session(monkeypatch): + from windup_framework.providers.matte import OnnxU2NetMatteProvider + + p = OnnxU2NetMatteProvider() + monkeypatch.setattr(p, "_get_session", lambda: _FakeSession()) + return p + + +def _png(w=64, h=64, color=(220, 220, 220)): + import io + + from PIL import Image + + buf = io.BytesIO() + Image.new("RGB", (w, h), color).save(buf, "PNG") + return buf.getvalue() + + +def test_cutout_outputs_rgba_png_with_alpha(monkeypatch): + import io + + from PIL import Image + + out = _provider_with_fake_session(monkeypatch).cutout(_png()) + im = Image.open(io.BytesIO(out)) + assert im.format == "PNG" and im.mode == "RGBA" + assert im.size == (64, 64) + + +def test_cutout_keeps_rgb_untouched(monkeypatch): + """抠图只动 alpha。改 RGB 会让后续像素化锁色板取到被改过的颜色。""" + import io + + import numpy as np + from PIL import Image + + src = _png(color=(31, 41, 59)) + out = _provider_with_fake_session(monkeypatch).cutout(src) + a = np.asarray(Image.open(io.BytesIO(out))) + b = np.asarray(Image.open(io.BytesIO(src)).convert("RGB")) + assert np.array_equal(a[:, :, :3], b), "RGB 通道被改了" + + +def test_cutout_applies_flat_bg_cleanup_before_filling_holes(monkeypatch): + """顺序:清理 → 填洞。反过来会把刚填上的像素又清掉,且不报错。 + + 用调用顺序断言而不是像素结果 —— 结果层面两种顺序在简单图上可能相同, + 那样的用例杀不掉顺序颠倒这个变异。 + """ + import windup_framework.providers.matte as M + + order: list[str] = [] + real_pen, real_fill = M._flat_bg_penalty, M._fill_enclosed_holes + monkeypatch.setattr(M, "_flat_bg_penalty", + lambda rgb: (order.append("clean"), real_pen(rgb))[1]) + monkeypatch.setattr(M, "_fill_enclosed_holes", + lambda a, rgb: (order.append("fill"), real_fill(a, rgb))[1]) + _provider_with_fake_session(monkeypatch).cutout(_png()) + assert order == ["clean", "fill"], order diff --git a/backend/tests/test_sufy_video_download.py b/backend/tests/test_sufy_video_download.py new file mode 100644 index 00000000..907094f9 --- /dev/null +++ b/backend/tests/test_sufy_video_download.py @@ -0,0 +1,596 @@ +"""视频成品下载的凭证边界、重试与完整性校验(不联网:用 httpx MockTransport)。 + +两个回归对象: + +1. 2026-08-05 实测两次连续复现:视频已生成、费用已产生,却因为读 body 时断了一次连接 + 就整单丢弃。见 ``providers.sufy._download`` 的 docstring。 +2. 2026-08-10 机器审(PR #179 P1):成品 URL 是网关返回的绝对地址,复用带 Authorization + 的 client 去下载 = 把 API key 发给了 CDN(或网关返回的任意地址)。 + 见 ``providers.sufy._download_request`` 的 docstring。 +""" + +import json + +import httpx +import pytest + +from windup_framework.providers.sufy import ( + IncompleteDownloadError, + UnsafeDownloadUrlError, + _download, +) + +VIDEO = b"\x00\x01mp4-bytes" * 64 +GATEWAY = "https://gw.invalid/v1" + + +def _client(handler) -> httpx.Client: + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def _authed_client(handler, base_url: str = GATEWAY) -> httpx.Client: + """带凭证的网关 client —— provider 真正持有的就是这种(Authorization + cookie jar)。""" + return httpx.Client( + transport=httpx.MockTransport(handler), + base_url=base_url, + headers={"Authorization": "Key secret-api-key"}, + cookies={"session": "s3cr3t"}, + ) + + +def test_retries_after_peer_closed_connection(monkeypatch): + """第一次断连、第二次成功 —— 原实现在这里会整单丢弃。""" + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] == 1: + raise httpx.RemoteProtocolError( + "peer closed connection without sending complete message body", request=request + ) + return httpx.Response(200, content=VIDEO) + + with _client(handler) as client: + assert _download(client, "https://example.invalid/v.mp4") == VIDEO + assert calls["n"] == 2 + + +def test_rejects_truncated_body_that_does_not_raise(monkeypatch): + """服务端声明的长度与实收不符时必须失败,而不是把坏视频往下游送。 + + 截断不一定抛异常。放过去的话,坏视频要到出帧环节才暴露成"解码失败", + 很难回溯到下载这一步。 + """ + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + + def handler(request: httpx.Request) -> httpx.Response: + # 只回一半 body,但 Content-Length 仍声明全长 + return httpx.Response( + 200, content=VIDEO[: len(VIDEO) // 2], headers={"content-length": str(len(VIDEO))} + ) + + with _client(handler) as client, pytest.raises(RuntimeError, match="已重试 3 次"): + _download(client, "https://example.invalid/v.mp4") + + +def test_accepts_chunked_response_without_content_length(monkeypatch): + """分块传输没有 Content-Length,此时跳过校验而不是误判为不完整。""" + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, stream=httpx.ByteStream(VIDEO)) + + with _client(handler) as client: + assert _download(client, "https://example.invalid/v.mp4") == VIDEO + + +def test_gives_up_after_three_tries_and_reports_the_last_cause(monkeypatch): + """一直断连时要显式失败,并把最后一次的真实原因带出来。""" + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + raise httpx.ConnectError("connection reset", request=request) + + with _client(handler) as client, pytest.raises(RuntimeError, match="connection reset"): + _download(client, "https://example.invalid/v.mp4") + assert calls["n"] == 3 + + +def test_incomplete_download_error_is_a_runtime_error(): + """调用方按 RuntimeError 兜底即可,不必单独 import 这个子类。""" + assert issubclass(IncompleteDownloadError, RuntimeError) + + +# ── 凭证边界:成品 URL 是网关给的外部地址,不能带着 API key 去取 ────────────── + + +def test_cross_origin_download_does_not_leak_the_api_key(monkeypatch): + """跨源下载必须摘掉 client 级凭证。 + + 这是 PR #179 P1 的直接回归:httpx 只在跨源**重定向**时自动摘 Authorization, + 对一开始就跨源的直连请求会原样带上 —— 于是 CDN 域名收到了 API key。 + """ + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + seen: dict[str, str | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["authorization"] = request.headers.get("authorization") + seen["cookie"] = request.headers.get("cookie") + return httpx.Response(200, content=VIDEO) + + with _authed_client(handler) as client: + assert _download(client, "https://cdn.invalid/out.mp4") == VIDEO + + assert seen["authorization"] is None, "API key 被发给了 CDN" + assert seen["cookie"] is None, "会话 cookie 被发给了 CDN" + + +def test_same_origin_download_keeps_the_gateway_credential(monkeypatch): + """同源(网关自己签发的下载链接)必须保留凭证,否则那条路径就是 401。 + + 一律摘头会把这个功能弄坏,所以判据是目标地址,不是"下载一律不带凭证"。 + """ + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + seen: list[str | None] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.headers.get("authorization")) + return httpx.Response(200, content=VIDEO) + + with _authed_client(handler) as client: + # 第二个地址显式写出默认端口 443。httpx 0.28 会把默认端口归一化掉(URL.port -> None), + # 所以这条今天走不到"补默认端口"那行;留着是钉住这个前提 —— httpx 哪天不再归一化, + # 少了默认端口补齐就会把它误判成跨源、把凭证摘掉,这条会先叫。 + assert _download(client, "https://gw.invalid/files/out.mp4") == VIDEO + assert _download(client, "https://gw.invalid:443/files/out.mp4") == VIDEO + + assert seen == ["Key secret-api-key", "Key secret-api-key"] + + +def test_downgrade_to_plain_http_is_treated_as_cross_origin(monkeypatch): + """同 host 但 scheme 从 https 掉到 http —— 也要摘凭证。 + + 默认端口被 httpx 归一化成 None,host 又相同,所以同源判定里**少比一个 scheme** + 就会把它当自己人,于是 API key 走明文 HTTP 发出去。httpx 自己在重定向那侧也是 + 单独处理 http/https 的(``_is_https_redirect``),方向只允许 http→https,不允许反过来。 + """ + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + seen: dict[str, str | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["authorization"] = request.headers.get("authorization") + return httpx.Response(200, content=VIDEO) + + with _authed_client(handler) as client: + assert _download(client, "http://gw.invalid/files/out.mp4") == VIDEO + assert seen["authorization"] is None, "API key 走明文 HTTP 发了出去" + + # 再来一格显式非默认端口:两边端口都是 8443,"补默认端口"那行判不出差别, + # 只有 scheme 比较能拦住。少了这一格,scheme 比较会显得可以删(实际不行)。 + with _authed_client(handler, base_url="https://gw.invalid:8443/v1") as client: + assert _download(client, "http://gw.invalid:8443/files/out.mp4") == VIDEO + assert seen["authorization"] is None, "非默认端口上的 https->http 降级没拦住" + + +def test_relative_result_path_stays_authenticated(monkeypatch): + """网关返回相对路径时,它解析到网关自己身上,凭证照带。""" + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + seen: dict[str, str | None] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["authorization"] = request.headers.get("authorization") + return httpx.Response(200, content=VIDEO) + + with _authed_client(handler) as client: + assert _download(client, "files/out.mp4") == VIDEO + + assert seen["url"] == "https://gw.invalid/v1/files/out.mp4" + assert seen["authorization"] == "Key secret-api-key" + + +def test_non_http_result_url_is_refused_before_any_request_goes_out(monkeypatch): + """协议不是 http(s) 就不发请求 —— 地址不对要立刻炸,不是重试三次后报传输错。 + + 注意 httpx 的边界:只有**带 host** 的绝对地址才保留原 scheme(``ftp://cdn/...``); + ``file:///etc/passwd`` 这种没有 host 的会被 httpx 当相对地址并入 base_url, + 结果是一个打到网关的 404,不经过这个分支。 + """ + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError(f"不该发出任何请求: {request.url}") + + for url in ("ftp://cdn.invalid/out.mp4", "file://cdn.invalid/out.mp4"): + with _authed_client(handler) as client: + with pytest.raises(UnsafeDownloadUrlError, match="http"): + _download(client, url) + + +# ── 文生图 provider(2026-08-10 实现;此前 gen_image 必抛错而端点可达)────────── + + +def _img_payload(b64: str) -> dict: + """模型把图放在 message.content 里,不同网关包裹层级不同。""" + return {"choices": [{"message": {"content": f"data:image/png;base64,{b64}"}}]} + + +def _big_b64(n: int = 6000) -> str: + import base64 + return base64.b64encode(b"\x89PNG" + b"\x00" * n).decode() + + +def _image_provider(handler): + import httpx + + from windup_framework.config.provider import AIProviderSettings + from windup_framework.providers.sufy import SufyImageProvider + + p = SufyImageProvider( + config=AIProviderSettings(base_url="https://gw.example.com/v1", api_key="k"), + ) + client = httpx.Client( + base_url="https://gw.example.com/v1", + headers={"Authorization": "Bearer k"}, + transport=httpx.MockTransport(handler), + ) + p._client = lambda: client + return p + + +def test_gen_image_returns_the_decoded_png(): + """端点可达而 provider 必抛错 = 每个图像任务稳定 FAILED。实现后必须真能出图。""" + def h(request): + import httpx + return httpx.Response(200, json=_img_payload(_big_b64())) + + data = _image_provider(h).gen_image("a knight", []) + assert data.startswith(b"\x89PNG") and len(data) > 5000 + + +def test_reference_images_are_sent_as_data_uris(): + """参考图走 content 数组里的 image_url,不是 multipart、不是单独字段。""" + import json as _json + + seen: dict = {} + + def h(request): + import httpx + seen["body"] = _json.loads(request.content) + return httpx.Response(200, json=_img_payload(_big_b64())) + + _image_provider(h).gen_image("x", [b"\x89PNGref"]) + content = seen["body"]["messages"][0]["content"] + kinds = [c["type"] for c in content] + assert kinds == ["text", "image_url"] + assert content[1]["image_url"]["url"].startswith("data:image/png;base64,") + + +def test_response_without_an_image_is_retried_then_raises(): + """模型偶发返回一条不含图的正常响应。重试后仍拿不到必须抛,不能返回空 bytes—— + 上游会把返回值直接上传对象存储并写进任务结果,0 字节的"成功"就是用户看到的裂图。""" + import pytest + + calls = {"n": 0} + + def h(request): + import httpx + calls["n"] += 1 + return httpx.Response(200, json={"choices": [{"message": {"content": "抱歉"}}]}) + + with pytest.raises(RuntimeError, match="未取得有效图"): + _image_provider(h).gen_image("x", []) + assert calls["n"] == 3, "应重试到上限而不是一次就放弃" + + +def test_undersized_image_is_rejected_not_returned(): + """响应里可能带一个几十字节的占位串,当图存下去就是打不开的文件。""" + import base64 + + import pytest + + tiny = base64.b64encode(b"\x89PNG" + b"\x00" * 200).decode() + + def h(request): + import httpx + return httpx.Response(200, json=_img_payload(tiny)) + + with pytest.raises(RuntimeError, match="字节"): + _image_provider(h).gen_image("x", []) + + +def test_first_successful_attempt_stops_retrying(): + calls = {"n": 0} + + def h(request): + import httpx + calls["n"] += 1 + if calls["n"] == 1: + return httpx.Response(200, json={"choices": [{"message": {"content": "空"}}]}) + return httpx.Response(200, json=_img_payload(_big_b64())) + + assert _image_provider(h).gen_image("x", []) + assert calls["n"] == 2 + + +def test_image_client_retries_connection_failures(): + """本机走代理时建连抖动常见;已跑通的管线实现靠一层网络重试扛住。 + + 只断言"配了连接重试"这个结构 —— 真去模拟 SSL 握手失败需要一个假 TCP 端点, + 那验的是 httpx 而不是我们的代码。 + """ + from windup_framework.providers.sufy import _CONNECT_RETRIES, SufyImageProvider + + assert _CONNECT_RETRIES >= 1 + client = SufyImageProvider()._client() + try: + assert client._transport._pool._retries == _CONNECT_RETRIES + finally: + client.close() + + +def test_request_path_comes_from_config_not_a_literal(): + """路径用配置里的 chat_completions_path —— 它此前零消费方,正是今天在删的那类字段。""" + + seen: dict = {} + + def h(request): + import httpx + seen["path"] = request.url.path + return httpx.Response(200, json=_img_payload(_big_b64())) + + p = _image_provider(h) + p._cfg = p._cfg.model_copy(update={"chat_completions_path": "/v9/custom-chat"}) + p.gen_image("x", []) + assert seen["path"].endswith("/v9/custom-chat"), seen["path"] + + +@pytest.mark.parametrize("code", [400, 404]) +def test_model_missing_from_the_gateway_catalogue_says_so(code): + """同一把 key 下不同网关的模型目录不一样(实测:一个 73 个模型零图像模型、 + 另一个 134 个含默认模型)。配错 AI_BASE_URL 时错误必须指向配置,不能只是裸 404。 + """ + def h(request): + import httpx + return httpx.Response(code, text='{"error":{"message":"model not found"}}') + + with pytest.raises(RuntimeError, match=r"/models"): + _image_provider(h).gen_image("x", []) + + +# ── 模型型号可配置(2026-08-11 人工评审:providers 层硬编码太多)─────────────── + + +def _cfg(**kw): + from windup_framework.config.provider import AIProviderSettings + + return AIProviderSettings(base_url="https://gw.example.com/v1", api_key="k", **kw) + + +@pytest.mark.parametrize(("cls_name", "field", "value"), [ + ("SufyVideoProvider", "video_model", "kling-v9-test"), + ("SufyImageProvider", "image_model", "gemini-9-flash-image"), +]) +def test_each_provider_reads_its_own_model_field(cls_name, field, value): + """三条能力同时在用不同模型,所以是三个独立字段而不是共用一个 ``model``。 + + 共用一个的后果是换其中一条把另外两条也换了 —— 这条用例把"各读各的"钉住: + 只设自己那个字段,另外两个保持默认,断言取到的是自己的。 + """ + import windup_framework.providers.sufy as S + + cls = getattr(S, cls_name) + assert cls(config=_cfg(**{field: value}))._model == value + + +def test_explicit_model_argument_still_wins_over_config(): + """显式传参优先于配置 —— A/B 对比时不必改环境变量。""" + from windup_framework.providers.sufy import SufyImageProvider + + p = SufyImageProvider(config=_cfg(image_model="from-config"), model="from-arg") + assert p._model == "from-arg" + + +def test_request_shape_is_not_configurable(): + """**只有型号可配,请求形状不可配。** + + (FAL 队列面已随「从未真实调用过」一并移除,故这里只剩两个型号字段。) + + 哪个模型吃 image_list、FAL 队列路径长什么样,是该模型的 API 事实而非运行参数。 + 放进配置会把"填错了会怎样"从部署期推到运行期:字段塞错不会立刻报错,任务照常 + queued,直到生成阶段才 failed,而费用可能已经产生(2026-07-29 实测)。 + + 故断言配置类**没有**这类字段 —— 将来有人想加会先撞到这条用例和它的理由。 + """ + from windup_framework.config.provider import AIProviderSettings + + fields = set(AIProviderSettings.model_fields) + for banned in ("image_list_models", "fal_endpoints", "first_frame_field"): + assert banned not in fields, f"{banned} 不该进配置,见本用例 docstring" + assert {"video_model", "image_model"} <= fields + + +# ── i2v 主流程(付费路径,此前零覆盖)───────────────────────────────────────── + + +def _jpeg_first_frame(w: int = 200, h: int = 300) -> bytes: + """一张竖长的图,用来验首帧被按目标画布补边而不是拉伸。""" + import io as _io + + from PIL import Image as _Image + + buf = _io.BytesIO() + _Image.new("RGB", (w, h), (40, 80, 160)).save(buf, "PNG") + return buf.getvalue() + + +@pytest.fixture(autouse=True) +def _no_sleep(monkeypatch): + """轮询里的 time.sleep 打桩 —— 用例不该真等。""" + import windup_framework.providers.sufy as _S + + monkeypatch.setattr(_S.time, "sleep", lambda *_: None) + + +def _video_provider(handler, **kw): + import httpx as _httpx + + from windup_framework.config.provider import AIProviderSettings + from windup_framework.providers.sufy import SufyVideoProvider + + p = SufyVideoProvider( + config=AIProviderSettings(base_url="https://gw.example.com/v1", api_key="k"), + # 轮询预算 = max_min * 60 // poll。poll 取大值让预算只有几次, + # 再把 time.sleep 打桩掉,用例就既快又不空转(第一版 poll=0.001 配 + # max_min=1 会真轮询 6 万次,单文件跑了 96 秒)。 + poll_interval=30.0, + **kw, + ) + client = _httpx.Client( + base_url="https://gw.example.com/v1", + headers={"Authorization": "Bearer k"}, + transport=_httpx.MockTransport(handler), + ) + p._client = lambda: client + return p + + +def _i2v_handler(seen: dict, *, statuses=("completed",), video=b"MP4DATA" * 200): + """提交 → 轮询 → 下载 三段式的假网关。""" + import httpx as _httpx + + calls = {"n": 0} + + def h(request): + path = request.url.path + if request.method == "POST" and path.endswith("/videos"): + seen["body"] = json.loads(request.content) + return _httpx.Response(200, json={"id": "job-1"}) + if request.method == "GET" and "/videos/" in path: + i = min(calls["n"], len(statuses) - 1) + calls["n"] += 1 + st = statuses[i] + if st == "completed": + return _httpx.Response(200, json={ + "status": "completed", + "task_result": {"videos": [{"url": "https://gw.example.com/out.mp4"}]}, + }) + return _httpx.Response(200, json={"status": st, "error": "boom"}) + seen["download_headers"] = dict(request.headers) + return _httpx.Response(200, content=video, + headers={"Content-Length": str(len(video))}) + + return h + + +def test_i2v_submits_polls_and_downloads(): + """一条完整的付费路径:提交拿 job id → 轮询到 completed → 下载 mp4。""" + seen: dict = {} + data = _video_provider(_i2v_handler(seen)).i2v(_jpeg_first_frame(), "walk right") + assert data.startswith(b"MP4DATA") + body = seen["body"] + assert body["prompt"] == "walk right" + assert body["seconds"] == "5" and isinstance(body["seconds"], str), "seconds 必须是字符串" + assert body["mode"] == "std" + + +def test_first_frame_goes_as_a_jpeg_data_uri(): + """PNG base64 会让任务 status=failed(VENDOR_FAILED,2026-07-22 实测,33s fail-fast)。 + 首帧必须转 JPEG —— 这条错在提交后才报,本地看不出来。 + """ + seen: dict = {} + _video_provider(_i2v_handler(seen)).i2v(_jpeg_first_frame(), "x") + uri = seen["body"]["input_reference"] + assert uri.startswith("data:image/jpeg;base64,"), uri[:40] + + import base64 as _b64 + import io as _io + + from PIL import Image as _Image + + im = _Image.open(_io.BytesIO(_b64.b64decode(uri.split(",", 1)[1]))) + assert im.format == "JPEG" + + +def test_first_frame_is_padded_to_the_target_canvas_not_stretched(): + """按目标画布补边、不拉伸:拉伸会让角色比例变形,而母版比例是角色一致性的一部分。""" + import base64 as _b64 + import io as _io + + from PIL import Image as _Image + + # 源图放一个偏心的亮块:拉伸会把它拉宽,补边会保持它的宽高比。 + # 只看"对称两点颜色相同"是无效判据 —— 纯色图拉伸后照样相同 + # (2026-08-11 变异测试逮到第一版正是如此,M3 存活)。 + buf = _io.BytesIO() + src = _Image.new("RGB", (200, 300), (40, 80, 160)) + src.paste((250, 250, 250), (80, 100, 120, 140)) # 40x40 的方块 + src.save(buf, "PNG") + + seen: dict = {} + _video_provider(_i2v_handler(seen)).i2v(buf.getvalue(), "x", size="1280x720") + im = _Image.open(_io.BytesIO(_b64.b64decode(seen["body"]["input_reference"].split(",", 1)[1]))) + assert im.size == (1280, 720), "首帧应铺满目标画布" + + # 量那个方块在成品里的宽高比。补边:源 40x40 等比缩放后仍是 1:1。 + # 拉伸:横向被拉 1280/200=6.4 倍、纵向 720/300=2.4 倍,比例变成 ~2.67:1。 + import numpy as _np + + a = _np.asarray(im.convert("L")) + ys, xs = _np.where(a > 200) + ratio = (xs.max() - xs.min() + 1) / (ys.max() - ys.min() + 1) + assert 0.8 < ratio < 1.25, f"方块宽高比 {ratio:.2f},说明被拉伸了(补边应≈1.0)" + + +@pytest.mark.parametrize("bad", ["failed", "cancelled"]) +def test_terminal_failure_raises_instead_of_polling_to_timeout(bad): + """网关报 failed/cancelled 要立刻抛,别把剩下的轮询次数耗完 —— 钱已经花了, + 尽快把原因暴露给上层比多等几分钟有用。 + """ + with pytest.raises(RuntimeError, match=bad): + _video_provider(_i2v_handler({}, statuses=(bad,))).i2v(_jpeg_first_frame(), "x") + + +def test_never_completing_job_raises_after_the_poll_budget(): + """轮询预算用尽仍未 completed → 抛错,不返回空 bytes。 + + 返回空 bytes 的话上游会把它当成一段视频送进抽帧,报"视频无可解码帧", + 真正的原因(超时)就被埋掉了。 + """ + p = _video_provider(_i2v_handler({}, statuses=("in_progress",)), max_min=1) # 预算 2 次 + with pytest.raises(RuntimeError, match="未取得视频 URL"): + p.i2v(_jpeg_first_frame(), "x") + + +def test_image_list_models_use_a_different_first_frame_field(): + """字段按模型选。塞错字段不会立刻报错 —— 任务 status=queued 正常返回, + 直到生成阶段才 failed "model is not supported",而费用可能已经产生 + (2026-07-29 实测)。 + """ + from windup_framework.providers.sufy import _IMAGE_LIST_MODELS + + seen: dict = {} + p = _video_provider(_i2v_handler(seen), model=_IMAGE_LIST_MODELS[0], mode="pro") + p.i2v(_jpeg_first_frame(), "x") + assert "image_list" in seen["body"] and "input_reference" not in seen["body"] + assert not seen["body"]["image_list"][0]["image"].startswith("data:"), \ + "image_list 要裸 base64,不带 data URI 前缀" + + +def test_non_positive_poll_interval_is_rejected_at_construction(): + """轮询间隔 <= 0 在构造时就拒。 + + 此前会活到 i2v 里 `max_min * 60 // poll` 那一步除零 —— 报 ZeroDivisionError, + 读的人完全看不出是配错了参数(2026-08-11 补 i2v 主流程测试时逮到)。 + 0 的语义本身也不成立:那是忙等,会把网关打满。 + """ + from windup_framework.config.provider import AIProviderSettings + from windup_framework.providers.sufy import SufyVideoProvider + + cfg = AIProviderSettings(base_url="https://gw.example.com/v1", api_key="k") + for bad in (0, -1, -0.5): + with pytest.raises(ValueError, match="poll_interval"): + SufyVideoProvider(config=cfg, poll_interval=bad)