Files
aigc-home/calliope-backend/tests/test_studio_generation.py
T
总工andmultica-agent 2ed9d25c24 feat(workflows): import preflight, real test runs and media groups (HEL-643)
- L2 连接核查: /object_info preflight (node existence + combo values) with
  5-min cache + manual refresh; analyze accepts endpoint_id, saved workflows
  get POST /{id}/check; unreachable degrades to 无法核查, never blocks saving
- 测试工作流: jobs.origin='workflow_test' rides the real queue → ComfyUI →
  outputs pipeline; only a downloadable, correctly-typed output counts as a
  pass; dry-run refused; repeat clicks reuse the in-flight job; artifacts
  isolated under assets/_workflow_test/{id}; retention keeps newest 5;
  clear-tests + delete-workflow cascade cleanup; tasks page shows a 测试 badge
- Media groups: Autogrow detection generalized to video/audio lists
  (ref_videos/ref_audios etc.), patcher clones/rewires per media family,
  validation per list kind; entries appear only for actually-wired slots;
  MiniMax H3 Ref2VA official-limit hints keyed off the node class
- 25 new backend tests (preflight / workflow-test / media-groups) against the
  fake ComfyUI; fake server gains /object_info + drop_outputs knobs

Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 19:07:20 +08:00

399 lines
15 KiB
Python

"""独立生图/生视频 full-chain tests (HEL-612): studio window jobs run
through the real playground → queue → ComfyUI HTTP → downloaded-output
path against a fake ComfyUI server (real HTTP round trip), plus the
source label, upload slot flow, retry and delete behaviours."""
from __future__ import annotations
import io
import json
import threading
import time
import uuid
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
from calliope.config import settings
PNG_1PX = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d4944415478da63f8cfc0f01f0005000105ab5e30760000000049454e44ae426082"
)
class _FakeComfyHandler(BaseHTTPRequestHandler):
"""Minimal ComfyUI: /system_stats, /prompt, /history/{id}, /view, /upload/image.
History outputs are keyed by the prompt's real output node id (what
ComfyUI does); a known output class that is absent falls back to
``server.forced_output_node`` (default "9"). Scripted tests can pin
``server.forced_output_node`` or drop entries via ``server.drop_outputs``.
"""
server_version = "FakeComfy/1.0"
KNOWN_OUTPUT_CLASSES = {
"SaveImage", "PreviewImage", "SaveImageAdvanced", "SaveImageWebsocket",
"SaveVideo", "VideoOutput", "VHS_VideoCombine", "AnimateDiffCombine",
}
def do_GET(self):
self.server.requests.append(self.path)
if self.path == "/system_stats":
self._json(
200,
{
"system": {"comfyui_version": "0.3.72-fake", "os": "linux"},
"devices": [{"name": "FakeGPU", "type": "cuda"}],
},
)
return
if self.path == "/object_info":
self._json(200, dict(getattr(self.server, "object_info", None) or {}))
return
if self.path.startswith("/history/"):
pid = self.path[len("/history/") :]
entry = self.server.history.get(pid)
if entry is None:
self._json(200, {})
return
self._json(200, {pid: entry})
return
if self.path.startswith("/view"):
self._raw(200, PNG_1PX, "image/png")
return
self._json(404, {"error": "not found"})
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""
self.server.requests.append(self.path)
if self.path == "/prompt":
if self.server.fail_prompt:
self._json(500, {"error": "boom", "node_errors": []})
return
prompt_id = uuid.uuid4().hex
outputs_key = self.server.outputs_key
out_entry = {
outputs_key: [
{"filename": self.server.out_name, "subfolder": "", "type": "output"}
]
}
outputs = (
{} if getattr(self.server, "drop_outputs", False) else
{self._output_node(body): out_entry}
)
self.server.history[prompt_id] = {
"status": {"status_str": "success", "completed": True},
"outputs": outputs,
"prompt": [],
}
self.server.prompts.append(json.loads(body.decode("utf-8") or "{}"))
self._json(200, {"prompt_id": prompt_id})
return
if self.path == "/upload/image":
self.server.uploads.append(body)
self._json(200, {"name": "uploaded-input.png", "subfolder": "calliope"})
return
self._json(404, {"error": "not found"})
def _output_node(self, body: bytes) -> str:
forced = getattr(self.server, "forced_output_node", None)
if forced:
return forced
try:
prompt = json.loads(body.decode("utf-8") or "{}").get("prompt") or {}
except json.JSONDecodeError:
prompt = {}
for nid, node in prompt.items():
if isinstance(node, dict) and node.get("class_type") in self.KNOWN_OUTPUT_CLASSES:
return str(nid)
return "9"
def _json(self, status: int, payload: dict) -> None:
data = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _raw(self, status: int, data: bytes, ctype: str) -> None:
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, format, *args):
pass
@pytest.fixture
def fake_comfy():
server = ThreadingHTTPServer(("127.0.0.1", 0), _FakeComfyHandler)
server.requests = []
server.prompts = []
server.uploads = []
server.history = {}
server.fail_prompt = False
server.out_name = "out-1.png"
server.outputs_key = "images"
server.forced_output_node = None
server.object_info = {}
server.drop_outputs = False
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
def _url(server) -> str:
return f"http://127.0.0.1:{server.server_address[1]}"
@contextmanager
def _real_run(client, server, endpoint_name="测试桩连接"):
"""One registered connection pointing at the fake server; dry_run off."""
prev_dry = settings.dry_run
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
prev_active = settings.comfyui_active_id
settings.dry_run = False
r = client.post(
"/api/comfyui/endpoints",
json={"name": endpoint_name, "base_url": _url(server), "auth_type": "none"},
)
assert r.status_code == 200, r.text
endpoints = r.json()["endpoints"]
# keep only the new endpoint for deterministic snapshot resolution
settings.comfyui_endpoints = [dict(ep) for ep in endpoints if ep["base_url"] == _url(server)]
settings.comfyui_active_id = settings.comfyui_endpoints[0]["id"]
settings.apply_active_comfyui()
try:
yield settings.comfyui_endpoints[0]
finally:
settings.dry_run = prev_dry
settings.comfyui_endpoints = prev_eps
settings.comfyui_active_id = prev_active
settings.apply_active_comfyui()
IMAGE_WORKFLOW = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "默认提示词"},
"_meta": {"title": "(Input:prompt) 正向提示词"},
},
"2": {
"class_type": "PrimitiveInt",
"inputs": {"value": 42},
"_meta": {"title": "(Input:seed) 种子"},
},
"9": {
"class_type": "SaveImage",
"inputs": {"images": "x"},
"_meta": {"title": "(Output:image) 图片输出"},
},
}
def _video_workflow(first_last: bool) -> dict:
wf = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "镜头缓慢推近"},
"_meta": {"title": "(Input:prompt) 正向提示词"},
},
"2": {
"class_type": "PrimitiveFloat",
"inputs": {"value": 6},
"_meta": {"title": "(Input:duration) 时长"},
},
}
if first_last:
wf["3"] = {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "(Input:image) 首帧"},
}
wf["4"] = {
"class_type": "LoadImage",
"inputs": {"image": ""},
"_meta": {"title": "(Input:image) 尾帧"},
}
wf["9"] = {
"class_type": "VHS_VideoCombine",
"inputs": {},
"_meta": {"title": "(Output:video) 视频输出"},
}
return wf
def _import_workflow(client, name: str, kind: str, workflow: dict) -> int:
r = client.post(
"/api/workflows",
json={"name": name, "kind": kind, "workflow_json": workflow},
)
assert r.status_code == 200, r.text
return r.json()["id"]
def _wait_done(client, job_id: int, timeout: float = 25.0) -> dict:
deadline = time.time() + timeout
while time.time() < deadline:
jobs = client.get("/api/playground/jobs").json()
for job in jobs:
if job["id"] == job_id:
if job["status"] in ("done", "failed"):
return job
time.sleep(0.4)
pytest.fail(f"job {job_id} did not finish in {timeout}s")
def test_studio_image_full_chain(client, fake_comfy):
"""独立生图: upload → generate(source=studio-image) → worker → output file."""
with _real_run(client, fake_comfy):
wf_id = _import_workflow(client, "假·文生图", "image", IMAGE_WORKFLOW)
r = client.post(
"/api/playground/generate",
json={
"workflow_id": wf_id,
"input_values": {"1": "霓虹灯下的雨夜街道", "2": 7},
"source": "studio-image",
"endpoint_id": settings.comfyui_active_id,
},
)
assert r.status_code == 200, r.text
job_id = r.json()["job"]["id"]
job = _wait_done(client, job_id)
assert job["status"] == "done", job.get("error")
assert job["kind"] == "image"
assert job["payload"]["source"] == "studio-image"
# endpoint snapshot: id/name/url only, never a token
snap = job["payload"]["endpoint"]
assert snap["id"] == settings.comfyui_active_id
assert snap["base_url"] == _url(fake_comfy)
assert "token" not in json.dumps(snap)
# output downloaded into assets and served via /api/file
assert len(job["output_paths"]) == 1
out = Path(job["output_paths"][0])
assert out.exists() and out.read_bytes() == PNG_1PX
r = client.get("/api/file", params={"path": str(out)})
assert r.status_code == 200
# the workflow actually reached the fake ComfyUI /prompt
assert any(p.startswith("/prompt") for p in map(str, fake_comfy.requests))
queued = fake_comfy.prompts[-1]["prompt"]
assert queued["1"]["inputs"]["text"] == "霓虹灯下的雨夜街道"
assert queued["2"]["inputs"]["value"] == 7
def test_studio_video_full_chain_with_frame_uploads(client, fake_comfy):
"""独立生视频: first/last frame slots upload to ComfyUI before /prompt."""
fake_comfy.outputs_key = "videos"
fake_comfy.out_name = "out-clip.mp4"
with _real_run(client, fake_comfy):
wf_id = _import_workflow(client, "假·首尾帧生视频", "video", _video_workflow(True))
# two upload-slot files via the playground upload API
paths = []
for _ in range(2):
r = client.post(
"/api/playground/uploads",
files={"file": ("frame.png", io.BytesIO(PNG_1PX), "image/png")},
)
assert r.status_code == 200, r.text
paths.append(r.json()["path"])
r = client.post(
"/api/playground/generate",
json={
"workflow_id": wf_id,
"input_values": {"1": "海上日出延时", "2": 6, "3": paths[0], "4": paths[1]},
"source": "studio-video",
"endpoint_id": settings.comfyui_active_id,
},
)
assert r.status_code == 200, r.text
job = _wait_done(client, r.json()["job"]["id"])
assert job["status"] == "done", job.get("error")
assert job["payload"]["source"] == "studio-video"
assert job["output_paths"][0].endswith(".mp4")
# both frames were uploaded to ComfyUI's /upload/image (media slots)
assert len(fake_comfy.uploads) == 2
queued = fake_comfy.prompts[-1]["prompt"]
assert queued["3"]["inputs"]["image"] == "calliope/uploaded-input.png"
assert queued["4"]["inputs"]["image"] == "calliope/uploaded-input.png"
def test_studio_retry_after_failure(client, fake_comfy):
"""失败原因进历史 → retry re-queues → success."""
fake_comfy.fail_prompt = True
with _real_run(client, fake_comfy):
wf_id = _import_workflow(client, "假·文生图(重试)", "image", IMAGE_WORKFLOW)
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "x"}, "source": "studio-image"},
)
job_id = r.json()["job"]["id"]
job = _wait_done(client, job_id)
assert job["status"] == "failed"
assert job["error"]
fake_comfy.fail_prompt = False
r = client.post(f"/api/jobs/{job_id}/retry")
assert r.status_code == 200, r.text
job = _wait_done(client, job_id)
assert job["status"] == "done"
assert job["retry_count"] >= 1
def test_studio_delete_removes_outputs(client, fake_comfy):
with _real_run(client, fake_comfy):
wf_id = _import_workflow(client, "假·文生图(删除)", "image", IMAGE_WORKFLOW)
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "y"}, "source": "studio-image"},
)
job_id = r.json()["job"]["id"]
job = _wait_done(client, job_id)
assert job["status"] == "done"
out = Path(job["output_paths"][0])
assert out.exists()
r = client.delete(f"/api/playground/jobs/{job_id}")
assert r.status_code == 200, r.text
assert not out.exists()
jobs = client.get("/api/playground/jobs").json()
assert all(j["id"] != job_id for j in jobs)
def test_studio_jobs_listed_on_tasks_api(client, fake_comfy):
"""/api/jobs (生成任务页) carries both studio kinds with payload.source."""
with _real_run(client, fake_comfy):
img_wf = _import_workflow(client, "假·文生图A", "image", IMAGE_WORKFLOW)
fake_comfy.outputs_key = "videos"
fake_comfy.out_name = "v.mp4"
vid_wf = _import_workflow(client, "假·生视频A", "video", _video_workflow(False))
for wf_id, source in ((img_wf, "studio-image"), (vid_wf, "studio-video")):
r = client.post(
"/api/playground/generate",
json={"workflow_id": wf_id, "input_values": {"1": "z"}, "source": source},
)
assert r.status_code == 200, r.text
_wait_done(client, r.json()["job"]["id"])
jobs = client.get("/api/jobs").json()
sources = {j["payload"].get("source"): j["kind"] for j in jobs}
assert sources.get("studio-image") == "image"
assert sources.get("studio-video") == "video"