- 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>
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""媒体组泛化(HEL-643 批次 3):参考视频/参考音频按 JSON 实际连线进入
|
|
input_schema 与提交流程;无连线则无入口;05_ref2va 只保留参考图。
|
|
|
|
视频/音频参考的真实 H3 JSON 暂无可执行样本(.15 无 ComfyUI),本套用
|
|
自造 fixture + fake ComfyUI 验证解析/表单/回填/上传全链路(模拟链路)。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from calliope.comfyui.parser import parse_dynamic_inputs
|
|
from calliope.comfyui.patcher import patch_workflow
|
|
from calliope.comfyui.validate import validate_input_values
|
|
from calliope.config import settings
|
|
from tests.test_studio_generation import PNG_1PX, _real_run
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
|
|
|
|
|
@pytest.fixture
|
|
def uploads_dir(client):
|
|
d = settings.assets_dir / "uploads"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def _load_05() -> dict:
|
|
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
def _ref2va_workflow(*, images=1, videos=1, audios=1) -> dict:
|
|
"""H3 Ref2VA 形状的自造 JSON:图/视频/音频各一组 Autogrow 连线。"""
|
|
wf: dict = {
|
|
"1": {
|
|
"class_type": "PrimitiveStringMultiline",
|
|
"inputs": {"value": "提示词"},
|
|
},
|
|
"92": {
|
|
"class_type": "SaveVideo",
|
|
"inputs": {"video": ["131", 0]},
|
|
},
|
|
"131": {
|
|
"class_type": "MiniMaxH3ReferenceToVideo",
|
|
"inputs": {"prompt": ["1", 0]},
|
|
},
|
|
"901": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
|
|
"902": {"class_type": "LoadVideo", "inputs": {"file": "ref.mp4", "force_rate": 0}},
|
|
"903": {"class_type": "LoadAudio", "inputs": {"audio": "ref.wav"}},
|
|
}
|
|
ins = wf["131"]["inputs"]
|
|
for i in range(images):
|
|
ins[f"ref_images.ref_image_{i}"] = ["901", 0]
|
|
for i in range(videos):
|
|
ins[f"ref_videos.ref_video_{i}"] = ["902", 0]
|
|
for i in range(audios):
|
|
ins[f"ref_audios.ref_audio_{i}"] = ["903", 0]
|
|
return wf
|
|
|
|
|
|
def _entry(schema: list[dict], key_suffix: str) -> dict | None:
|
|
for e in schema:
|
|
if e["nodeId"].endswith("@" + key_suffix):
|
|
return e
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema: 入口只由实际连线决定
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_ref2va_schema_exposes_three_media_groups():
|
|
schema = parse_dynamic_inputs(_ref2va_workflow())
|
|
img = _entry(schema, "ref_images")
|
|
vid = _entry(schema, "ref_videos")
|
|
aud = _entry(schema, "ref_audios")
|
|
assert img and img["kind"] == "image_list" and img["role"] == "reference_image"
|
|
assert img["max"] == 9 and img["label"] == "参考图"
|
|
assert vid and vid["kind"] == "video_list" and vid["role"] == "reference_video"
|
|
assert vid["max"] == 3 and vid["label"] == "参考视频"
|
|
assert aud and aud["kind"] == "audio_list" and aud["role"] == "reference_audio"
|
|
assert aud["max"] == 3 and aud["label"] == "参考音频"
|
|
# 官方限额提示按节点类名驱动(通用检测不携带 hint)
|
|
assert img["media_hint"] == vid["media_hint"] == aud["media_hint"] == "minimax_h3_ref2va"
|
|
# 被组吸收的 loader 不再单独出现
|
|
assert not any(e["nodeId"] == "902" for e in schema)
|
|
assert not any(e["nodeId"] == "903" for e in schema)
|
|
|
|
|
|
def test_unwired_media_kinds_produce_no_entry():
|
|
schema = parse_dynamic_inputs(_ref2va_workflow(videos=0, audios=0))
|
|
assert _entry(schema, "ref_images") is not None
|
|
assert _entry(schema, "ref_videos") is None
|
|
assert _entry(schema, "ref_audios") is None
|
|
|
|
|
|
def test_generic_class_gets_group_without_hint():
|
|
wf = _ref2va_workflow(videos=0, audios=0)
|
|
wf["20"] = {"class_type": "GenericRefNode", "inputs": {}}
|
|
wf["701"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "a.mp4"}}
|
|
wf["702"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "b.mp4"}}
|
|
wf["20"]["inputs"]["media.clip_0"] = ["701", 0]
|
|
wf["20"]["inputs"]["media.clip_1"] = ["702", 0]
|
|
schema = parse_dynamic_inputs(wf)
|
|
g = _entry(schema, "media")
|
|
assert g and g["kind"] == "video_list" and g["max"] == 9
|
|
assert "media_hint" not in g
|
|
|
|
|
|
def test_05_ref2va_fixture_stays_image_only():
|
|
schema = parse_dynamic_inputs(_load_05())
|
|
lists = [e for e in schema if e["kind"].endswith("_list")]
|
|
assert len(lists) == 1
|
|
assert lists[0]["kind"] == "image_list" and lists[0]["max"] == 9
|
|
assert lists[0]["nodeId"] == "131@ref_images"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _media_file(path: str, data: bytes) -> str:
|
|
p = Path(path)
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_bytes(data)
|
|
return str(p)
|
|
|
|
|
|
def test_validate_video_and_audio_lists(uploads_dir):
|
|
schema = parse_dynamic_inputs(_ref2va_workflow())
|
|
img_key = _entry(schema, "ref_images")["nodeId"]
|
|
vid_key = _entry(schema, "ref_videos")["nodeId"]
|
|
aud_key = _entry(schema, "ref_audios")["nodeId"]
|
|
png = _media_file(str(uploads_dir / "ok.png"), PNG_1PX)
|
|
mp4 = _media_file(str(uploads_dir / "ok.mp4"), b"00")
|
|
mp3 = _media_file(str(uploads_dir / "ok.mp3"), b"00")
|
|
|
|
clean, errs = validate_input_values(
|
|
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp3]}
|
|
)
|
|
assert errs == [] and clean[vid_key] == [mp4] and clean[aud_key] == [mp3]
|
|
|
|
# 错误格式(图片文件填视频槽 / 视频文件填音频槽)
|
|
_, errs = validate_input_values(
|
|
schema, {"1": "p", img_key: [png], vid_key: [png], aud_key: [mp3]}
|
|
)
|
|
assert errs == ["参考视频 1:格式不支持,请使用 MP4 / WebM / MOV"]
|
|
_, errs = validate_input_values(
|
|
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp4]}
|
|
)
|
|
assert errs == ["参考音频 1:格式不支持,请使用 MP3 / WAV / M4A"]
|
|
|
|
# 上限(H3 官方 3 段)与最少 1 段
|
|
four = [_media_file(str(uploads_dir / f"v{i}.mp4"), b"00") for i in range(4)]
|
|
_, errs = validate_input_values(
|
|
schema, {"1": "p", img_key: [png], vid_key: four, aud_key: [mp3]}
|
|
)
|
|
assert errs == ["参考视频最多 3 段(当前 4 段),请删除多余的视频"]
|
|
_, errs = validate_input_values(
|
|
schema, {"1": "p", img_key: [png], aud_key: [mp3]}
|
|
)
|
|
assert errs == ["需要至少 1 段参考视频"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Patcher + full chain against fake ComfyUI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_patcher_clones_and_backfills_each_media_kind(uploads_dir):
|
|
wf = _ref2va_workflow()
|
|
schema = parse_dynamic_inputs(wf)
|
|
pngs = [_media_file(str(uploads_dir / "r1.png"), PNG_1PX),
|
|
_media_file(str(uploads_dir / "r2.png"), PNG_1PX)]
|
|
mp4 = _media_file(str(uploads_dir / "v1.mp4"), b"00")
|
|
mp3 = _media_file(str(uploads_dir / "a1.mp3"), b"00")
|
|
patched = patch_workflow(
|
|
wf,
|
|
{
|
|
_entry(schema, "ref_images")["nodeId"]: pngs,
|
|
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
|
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
|
},
|
|
)
|
|
ins = patched["131"]["inputs"]
|
|
image_links = [v for k, v in ins.items() if k.startswith("ref_images.")]
|
|
video_links = [v for k, v in ins.items() if k.startswith("ref_videos.")]
|
|
audio_links = [v for k, v in ins.items() if k.startswith("ref_audios.")]
|
|
assert len(image_links) == 2 and len(video_links) == 1 and len(audio_links) == 1
|
|
loaders = {nid: n for nid, n in patched.items()
|
|
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")}
|
|
imgs = [n for n in loaders.values() if n["class_type"] == "LoadImage"]
|
|
vids = [n for n in loaders.values() if n["class_type"] == "LoadVideo"]
|
|
auds = [n for n in loaders.values() if n["class_type"] == "LoadAudio"]
|
|
assert len(imgs) == 2 and all(n["inputs"]["image"] in pngs for n in imgs)
|
|
assert vids and vids[0]["inputs"]["file"] == mp4
|
|
assert auds and auds[0]["inputs"]["audio"] == mp3
|
|
# 克隆出的节点带中文槽位标题
|
|
assert {n["_meta"]["title"] for n in imgs} == {"参考图 1", "参考图 2"}
|
|
assert vids[0]["_meta"]["title"] == "参考视频 1"
|
|
assert auds[0]["_meta"]["title"] == "参考音频 1"
|
|
|
|
|
|
def test_full_chain_uploads_and_backfills_media_groups(client, fake_comfy, uploads_dir):
|
|
"""提交 → 上传 → 回填:fake ComfyUI 收到的 prompt 里每类 loader 都
|
|
指向已上传文件(模拟链路:fake 端点,非真机 H3)。"""
|
|
wf = _ref2va_workflow()
|
|
png = _media_file(str(uploads_dir / "ref.png"), PNG_1PX)
|
|
mp4 = _media_file(str(uploads_dir / "ref.mp4"), b"00")
|
|
mp3 = _media_file(str(uploads_dir / "ref.mp3"), b"00")
|
|
r = client.post(
|
|
"/api/workflows",
|
|
json={"name": "媒体组链路", "workflow_json": wf, "usage": "ref2video"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
wf_id = r.json()["id"]
|
|
schema = r.json()["input_schema"]
|
|
|
|
with _real_run(client, fake_comfy):
|
|
fake_comfy.out_name = "media_out.mp4"
|
|
r = client.post(
|
|
f"/api/workflows/{wf_id}/test",
|
|
json={
|
|
"input_values": {
|
|
_entry(schema, "ref_images")["nodeId"]: [png],
|
|
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
|
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
|
}
|
|
},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
job_id = r.json()["job"]["id"]
|
|
|
|
deadline = time.time() + 25
|
|
job = {}
|
|
while time.time() < deadline:
|
|
job = client.get(f"/api/jobs/{job_id}").json()
|
|
if job["status"] not in ("pending", "running"):
|
|
break
|
|
time.sleep(0.4)
|
|
assert job["status"] == "done", job.get("error")
|
|
|
|
assert len(fake_comfy.uploads) >= 3 # 图、视频、音频各一次
|
|
queued = fake_comfy.prompts[-1]["prompt"]
|
|
loaders = [n for n in queued.values()
|
|
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")]
|
|
by_field = {
|
|
(n["class_type"], k): v
|
|
for n in loaders
|
|
for k, v in n["inputs"].items()
|
|
if isinstance(v, str) and "uploaded-input" in v
|
|
}
|
|
assert ("LoadImage", "image") in by_field
|
|
assert ("LoadVideo", "file") in by_field
|
|
assert ("LoadAudio", "audio") in by_field
|
|
# 组连线数与提交数一致
|
|
ins = queued["131"]["inputs"]
|
|
assert len([k for k in ins if k.startswith("ref_images.")]) == 1
|
|
assert len([k for k in ins if k.startswith("ref_videos.")]) == 1
|
|
assert len([k for k in ins if k.startswith("ref_audios.")]) == 1
|