Files
aigc-home/calliope-backend/tests/test_pipeline.py
T
shigongyuan-agentandmultica-agent 063a3c7063 fix: untagged ComfyUI workflows expose inputs; studio generate always gives feedback (HEL-623)
Root cause: parse_dynamic_inputs only recognised (Input:role) title tags.
A workflow exported straight from ComfyUI has default titles, so its
input_schema was empty and the studio form rendered no prompt box; the
generate click then enqueued (or was blocked) with no toast at all.

- parser: untagged fallback detects prompt/negative (via wiring to
  sampler positive/negative), seed/steps/cfg, latent width/height/batch,
  numeric primitives, LoadImage/LoadImageMask/video/audio slots; only the
  prompt is required; entries carry auto=true
- patcher: "<nodeId>@<widget>" keys target one widget on multi-widget
  nodes, never overwrite linked inputs; LoadImageMask uploads like images
- workflows API re-derives an empty stored schema (old imports fixed
  without re-import); smart_fill sort accepts subgraph/field ids
- studio: blocked submit toasts the reason and scrolls to the field;
  success toasts "已提交"; submit errors show the API detail; job.failed
  for this window's jobs toasts the reason; no-param workflows show a
  note + disabled prompt box; auto-detected params show a note
- connections import: chip shows node count, toast reports real params
- Chinese messages for ComfyUI rejection/upload/timeout/execution errors
  and playground workflow-not-found/disabled
- tests: native Qwen t2i / Qwen edit+mask / untagged H3 fixtures, patch
  keys, backfill, full fake-ComfyUI chain, Chinese rejection

Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 18:25:41 +08:00

400 lines
14 KiB
Python

from __future__ import annotations
import json
from pathlib import Path
from calliope.comfyui.parser import parse_dynamic_inputs, parse_dynamic_outputs
from calliope.comfyui.patcher import patch_workflow
from calliope.config import settings
from calliope.db import get_db
SAMPLE_WORKFLOW = {
"102": {
"inputs": {"value": 1280},
"class_type": "PrimitiveInt",
"_meta": {"title": "Width (Input:width)"},
},
"209": {
"inputs": {"value": "My prompt"},
"class_type": "PrimitiveStringMultiline",
"_meta": {"title": "Text Prompt (Input:prompt)"},
},
"10": {
"inputs": {"image": "x.png"},
"class_type": "LoadImage",
"_meta": {"title": "Reference Image (Input:character)"},
},
"99": {
"inputs": {"filename_prefix": "out"},
"class_type": "SaveImage",
"_meta": {"title": "Final Image (Output:image)"},
},
}
def test_parse_dynamic_inputs_outputs():
inputs = parse_dynamic_inputs(SAMPLE_WORKFLOW)
outputs = parse_dynamic_outputs(SAMPLE_WORKFLOW)
assert len(inputs) == 3
assert {i["kind"] for i in inputs} >= {"number", "textarea", "image"}
by_role = {i["role"]: i for i in inputs}
assert by_role["width"]["nodeId"] == "102"
assert by_role["prompt"]["nodeId"] == "209"
assert by_role["character"]["nodeId"] == "10"
assert len(outputs) == 1
assert outputs[0]["kind"] == "image"
assert outputs[0]["role"] == "image"
def test_patch_workflow_by_node_id():
patched = patch_workflow(SAMPLE_WORKFLOW, {"209": "New prompt", "102": 720, "10": "ref.png"})
assert patched["209"]["inputs"]["value"] == "New prompt"
assert patched["102"]["inputs"]["value"] == 720
assert patched["10"]["inputs"]["image"] == "ref.png"
def test_patch_workflow_vhs_audio_colon_widget():
"""VHS_LoadAudio's widget is literally `audio:` — the patcher must write it."""
wf = {
"5": {
"inputs": {"audio:": "old.wav"},
"class_type": "VHS_LoadAudio",
"_meta": {"title": "Ref Audio (Input:audio)"},
}
}
patched = patch_workflow(wf, {"5": "C:/assets/new.wav"})
assert patched["5"]["inputs"]["audio:"] == "C:/assets/new.wav"
# no bogus `audio` key gets added alongside
assert "audio" not in patched["5"]["inputs"]
def test_patch_workflow_no_fuzzy_fallback():
"""Unknown field on a node with unrelated keys must not get rewritten."""
wf = {
"7": {
"inputs": {"aspect_ratio": "16:9", "megapixels": 1.0},
"class_type": "ResolutionSelector",
"_meta": {"title": "Resolution (Input)"},
}
}
patched = patch_workflow(wf, {"7": "9:16"})
# computed field 'value' doesn't exist and has no sibling pair — stays put
assert patched["7"]["inputs"].get("value") == "9:16"
assert patched["7"]["inputs"]["aspect_ratio"] == "16:9"
def test_queue_prompt_surfaces_comfy_error_body(monkeypatch):
"""A 400 from Comfy /prompt must name the node, not just the status code."""
import httpx
from calliope.comfyui.client import ComfyUIClient
body = {
"error": {"type": "prompt_outputs_failed_validation"},
"node_errors": {
"12": {"errors": [{"message": "Invalid audio file: ref.m4a"}]},
},
}
def fake_send(request, **kwargs):
return httpx.Response(400, json=body, request=request)
transport = httpx.MockTransport(fake_send)
client = ComfyUIClient(base_url="http://comfy.test")
client._http = httpx.AsyncClient(transport=transport)
import asyncio
async def run():
try:
await client.queue_prompt({"1": {"class_type": "X", "inputs": {}}})
except RuntimeError as exc:
return str(exc)
finally:
await client.close()
msg = asyncio.run(run())
assert "prompt_outputs_failed_validation" in msg
assert "节点 12" in msg
assert "HTTP 400" in msg
assert "Invalid audio file" in msg
def test_upload_audio_flat_no_subfolder(tmp_path, monkeypatch):
"""Audio uploads go to the flat input dir and return a bare filename."""
import asyncio
import httpx
from calliope.comfyui.client import ComfyUIClient
sent: dict = {}
def fake_send(request, **kwargs):
sent["url"] = str(request.url)
sent["content_type"] = request.headers.get("content-type", "")
return httpx.Response(200, json={"name": "ref.wav", "subfolder": ""}, request=request)
transport = httpx.MockTransport(fake_send)
client = ComfyUIClient(base_url="http://comfy.test")
client._http = httpx.AsyncClient(transport=transport)
audio = tmp_path / "ref.wav"
audio.write_bytes(b"wav-placeholder")
async def run():
try:
return await client.upload_audio(audio)
finally:
await client.close()
name = asyncio.run(run())
assert name == "ref.wav" # bare filename, no calliope/ prefix
assert sent["url"].endswith("/upload/image")
def test_prepare_media_inputs_vhs_audio(tmp_path):
"""VHS_LoadAudio with an `audio:` widget gets its file uploaded in place."""
import asyncio
from calliope.comfyui.client import ComfyUIClient
audio = tmp_path / "voice.wav"
audio.write_bytes(b"wav-placeholder")
client = ComfyUIClient.__new__(ComfyUIClient) # skip __init__ HTTP client
async def fake_upload(path, subfolder=""):
assert Path(path) == audio
return "voice.wav"
client.upload_audio = fake_upload # type: ignore[method-assign]
wf = {
"5": {
"inputs": {"audio:": str(audio)},
"class_type": "VHS_LoadAudio",
"_meta": {"title": "Ref Audio (Input:audio)"},
}
}
out = asyncio.run(client.prepare_media_inputs(wf))
assert out["5"]["inputs"]["audio:"] == "voice.wav"
def test_workflow_analyze_and_create(client):
r = client.post("/api/workflows/analyze", json={"workflow_json": SAMPLE_WORKFLOW})
assert r.status_code == 200
assert len(r.json()["inputs"]) == 3
r = client.post(
"/api/workflows",
json={"name": "Test WF", "kind": "image", "workflow_json": SAMPLE_WORKFLOW},
)
assert r.status_code == 200
data = r.json()
assert data["name"] == "Test WF"
assert len(data["input_schema"]) == 3
def test_story_replace_on_regenerate(client, monkeypatch):
async def fake_structured(messages, temperature=0.7):
return {
"title": "T",
"logline": "L",
"beats": [
{"order_index": i, "title": f"B{i}", "description": "d"} for i in range(1, 5)
],
"characters": [
{
"name": "Hero",
"role": "protagonist",
"age": "20",
"appearance": "tall",
"personality": "brave",
}
],
"locations": [{"name": "Forest", "description": "dark woods"}],
}
monkeypatch.setattr("calliope.routers.story.generate_structured", fake_structured)
r = client.post("/api/projects", json={"title": "P", "idea": "idea"})
pid = r.json()["id"]
r = client.post(f"/api/projects/{pid}/generate-story")
assert r.status_code == 200
r = client.get(f"/api/projects/{pid}/story")
assert len(r.json()["beats"]) == 4
assert len(r.json()["characters"]) == 1
# regenerate should replace, not append
r = client.post(f"/api/projects/{pid}/generate-story")
assert r.status_code == 200
r = client.get(f"/api/projects/{pid}/story")
assert len(r.json()["beats"]) == 4
assert len(r.json()["characters"]) == 1
def test_story_replace_clears_scene_locations(client, monkeypatch):
"""replace=true deletes all locations; scenes.location_id (no FK) must be
nulled, not left dangling at dead location rows."""
async def fake_structured(messages, temperature=0.7):
return {
"title": "T",
"logline": "L",
"beats": [
{"order_index": i, "title": f"B{i}", "description": "d"} for i in range(1, 5)
],
"characters": [],
"locations": [{"name": "Forest", "description": "dark woods"}],
}
monkeypatch.setattr("calliope.routers.story.generate_structured", fake_structured)
r = client.post("/api/projects", json={"title": "P2", "idea": "idea"})
pid = r.json()["id"]
assert client.post(f"/api/projects/{pid}/generate-story").status_code == 200
loc = client.get(f"/api/projects/{pid}/story").json()["locations"][0]["id"]
scene = client.post(
f"/api/projects/{pid}/scenes",
json={"order_index": 1, "heading": "S1", "location_id": loc},
).json()
assert scene["location_id"] == loc
# Regenerate with replace: locations wiped → scene must not dangle.
assert client.post(f"/api/projects/{pid}/generate-story").status_code == 200
conn = get_db(settings.db_path)
try:
row = conn.execute(
"SELECT location_id FROM scenes WHERE id = ?", (scene["id"],)
).fetchone()
assert row["location_id"] is None
# And the new story has its own fresh location row.
n = conn.execute(
"SELECT COUNT(*) AS n FROM locations WHERE project_id = ?", (pid,)
).fetchone()["n"]
assert n == 1
finally:
conn.close()
def test_story_seeds_items(client, monkeypatch):
async def fake_structured(messages, temperature=0.7):
return {
"title": "T",
"logline": "L",
"beats": [
{"order_index": i, "title": f"B{i}", "description": "d"} for i in range(1, 5)
],
"characters": [],
"locations": [],
"items": [
{"name": "Magic Box", "description": "a glowing rune-carved chest"},
{"name": "Sword", "description": "a chipped steel blade"},
],
}
monkeypatch.setattr("calliope.routers.story.generate_structured", fake_structured)
r = client.post("/api/projects", json={"title": "P3", "idea": "idea"})
pid = r.json()["id"]
assert client.post(f"/api/projects/{pid}/generate-story").status_code == 200
data = client.get(f"/api/projects/{pid}/story").json()
assert len(data["items"]) == 2
assert {i["name"] for i in data["items"]} == {"Magic Box", "Sword"}
# seeded consistency_prompt mirrors the published item template
assert "ITEM REFERENCE" in data["items"][0]["consistency_prompt"]
# replace wipes and re-seeds items (no append)
assert client.post(f"/api/projects/{pid}/generate-story").status_code == 200
data = client.get(f"/api/projects/{pid}/story").json()
assert len(data["items"]) == 2
def test_item_crud(client):
r = client.post("/api/projects", json={"title": "P4"})
pid = r.json()["id"]
created = client.post(
f"/api/projects/{pid}/items", json={"name": "Lantern", "description": "brass oil lamp"}
).json()
assert created["name"] == "Lantern"
updated = client.patch(
f"/api/projects/{pid}/items/{created['id']}", json={"description": "battered brass lamp"}
).json()
assert updated["description"] == "battered brass lamp"
assert client.delete(f"/api/projects/{pid}/items/{created['id']}").json()["ok"] is True
assert client.get(f"/api/projects/{pid}/assets").json()["items"] == []
def test_item_prompt_templates():
from calliope.agent.prompts import item_image_prompt, item_reference_prompt
item = {"name": "Key", "description": "an ornate iron key"}
template = item_reference_prompt(item)
assert template.startswith("ITEM REFERENCE — Key")
assert "ornate iron key" in template
assert item_image_prompt(item) == template
saved = item_image_prompt({**item, "consistency_prompt": "custom prompt"})
assert saved == "custom prompt"
def test_enqueue_dry_run_job(client):
r = client.post("/api/projects", json={"title": "Q"})
pid = r.json()["id"]
r = client.post(
"/api/workflows",
json={"name": "Img", "kind": "image", "workflow_json": SAMPLE_WORKFLOW},
)
assert r.status_code == 200
# create character manually via story path monkeypatch-less insert through generate-assets empty
# use character create endpoint
r = client.post(
f"/api/projects/{pid}/characters",
json={"name": "A", "appearance": "blue hair"},
)
assert r.status_code == 200
r = client.post(f"/api/projects/{pid}/generate-assets", json={"missing_only": True})
assert r.status_code == 200
jobs = r.json()["jobs"]
assert len(jobs) >= 1
# wait briefly for dry-run worker
import time
done = False
for _ in range(20):
time.sleep(0.25)
listed = client.get(f"/api/jobs?project_id={pid}").json()
if listed and listed[0]["status"] in {"done", "failed"}:
done = listed[0]["status"] == "done"
break
assert done
def test_settings_dry_run(client):
r = client.get("/api/settings")
assert r.status_code == 200
assert "dry_run" in r.json()
r = client.post("/api/settings", json={"dry_run": True})
assert r.status_code == 200
assert r.json()["dry_run"] is True
def test_duration_beat_budget():
from calliope.agent.prompts import (
estimate_target_seconds,
recommend_beat_count,
story_generation_user_prompt,
)
assert estimate_target_seconds("2 minutes") == 120
assert recommend_beat_count("2 minutes") == 10
assert recommend_beat_count("medium (~2 min)") == 10
assert recommend_beat_count("10 minutes") == 50
assert recommend_beat_count("30 seconds") == 4
prompt = story_generation_user_prompt("T", "idea", "Horror", "Dark", "10 minutes")
assert "required_beat_count: 50" in prompt
assert "EXACTLY 50" in prompt