feat(comfy): H3 图生视频支持首尾帧——可选尾帧插槽、接线克隆 LoadImage 与改名迁移 (HEL-671)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员
2026-10-04 13:40:11 +08:00
co-authored by multica-agent
parent 7bd8c29faa
commit 2689f5354a
15 changed files with 261 additions and 9 deletions
@@ -13,12 +13,14 @@ from calliope.comfyui.registry import (
IMAGE_OUTPUT_CLASSES,
IMAGE_URL_CLASSES,
NUMBER_CLASSES,
OPTIONAL_FRAME_INPUTS,
TEXT_AREA_CLASSES,
VIDEO_CLASSES,
VIDEO_OUTPUT_CLASSES,
ComfyOutputKind,
class_to_input_kind,
class_to_output_kind,
optional_frame_sibling,
)
from calliope.comfyui.roles import (
USAGE_VALUES,
@@ -689,6 +691,8 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
)
)
media = _with_optional_frames(workflow, media)
results: list[dict[str, Any]] = []
positives = [t for t in texts if not t[3]]
negatives = [t for t in texts if t[3]]
@@ -732,6 +736,42 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
return results
def _with_optional_frames(
workflow: dict[str, Any], media: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Insert an optional slot after each frame slot whose node also accepts
an unwired optional frame (H3 FL2VA: 首帧 → 尾帧)."""
out = list(media)
for nid, node in sorted(
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
key=lambda kv: _node_sort_key(kv[0]),
):
for field in OPTIONAL_FRAME_INPUTS.get(str(node.get("class_type") or ""), {}):
sibling = optional_frame_sibling(node, field)
if sibling is None:
continue
loader_id = str(node["inputs"][sibling][0])
loader = workflow.get(loader_id)
if not isinstance(loader, dict) or loader.get("class_type") not in IMAGE_CLASSES:
continue
at = next(
(
i
for i, e in enumerate(out)
if e["nodeId"] == _key(loader_id, loader, "image")
),
None,
)
if at is None:
continue
role, label = _FRAME_SLOT_ROLES[field]
out.insert(
at + 1,
_entry(f"{nid}{FIELD_KEY_SEP}{field}", label, role, "image", None, False),
)
return out
def _is_negative(
node_id: str, lowered_title: str, consumers: dict[str, list[tuple[str, str]]]
) -> bool:
@@ -11,6 +11,7 @@ from calliope.comfyui.registry import (
VIDEO_CLASSES,
VIDEO_FILE_CLASSES,
class_to_patch_field,
optional_frame_sibling,
)
# Input keys of the form "<nodeId>@<widget>" target one named widget on a
@@ -142,6 +143,26 @@ def apply_media_group(
apply_image_group = apply_media_group
def apply_optional_frame(
workflow: dict[str, Any], node_id: str, field: str, sibling: str, path: str
) -> None:
"""Wire ``path`` into an unwired optional frame input (H3 ``last_frame``)
through a new LoadImage cloned from the ``sibling`` frame's loader."""
node = workflow[node_id]
inputs = dict(node.get("inputs") or {})
src = workflow.get(str(inputs[sibling][0]))
if isinstance(src, dict) and str(src.get("class_type") or "") in IMAGE_CLASSES:
loader = copy.deepcopy(src)
else:
loader = {"class_type": "LoadImage", "inputs": {}}
loader["inputs"] = {**(loader.get("inputs") or {}), "image": path}
loader["_meta"] = {"title": "尾帧" if field == "last_frame" else field}
nid = _next_node_id(workflow)
workflow[nid] = loader
inputs[field] = [nid, 0]
node["inputs"] = inputs
def patch_workflow(
base: dict[str, Any],
values_by_node_id: dict[str, Any],
@@ -167,6 +188,10 @@ def patch_workflow(
if clean:
apply_media_group(patched, key, field, clean)
continue
if field is not None and (sibling := optional_frame_sibling(node, field)):
if isinstance(value, str) and value.strip():
apply_optional_frame(patched, key, field, sibling, value)
continue
if field is None:
field = resolve_patch_field(node)
elif isinstance(inputs.get(field), list):
@@ -1,7 +1,7 @@
"""Unified class_type → kind + patch-field registry (MuseAgent port)."""
from __future__ import annotations
from typing import Literal
from typing import Any, Literal
ComfyInputKind = Literal["text", "textarea", "number", "image", "image_url", "audio", "video"]
ComfyOutputKind = Literal["image", "video", "other"]
@@ -33,6 +33,24 @@ IMAGE_OUTPUT_CLASSES = frozenset(
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket",
"SaveImageAdvanced"}
)
# Frame-conditioned video nodes and the optional frame input an export may
# leave unwired. MiniMax H3's FL2VA node takes `first_frame` plus an
# optional `last_frame`; the studio offers the slot and the patcher adds
# the LoadImage only when the user supplies that frame.
OPTIONAL_FRAME_INPUTS: dict[str, dict[str, str]] = {
"MiniMaxH3ImageToVideo": {"last_frame": "first_frame"},
}
def optional_frame_sibling(node: dict[str, Any], field: str) -> str | None:
"""The wired frame input an unwired optional ``field`` mirrors
(``last_frame`` → ``first_frame``); None when it does not apply."""
inputs = node.get("inputs") or {}
sibling = OPTIONAL_FRAME_INPUTS.get(str(node.get("class_type") or ""), {}).get(field)
if sibling is None or field in inputs:
return None
link = inputs.get(sibling)
return sibling if isinstance(link, list) and len(link) == 2 else None
def class_to_input_kind(class_type: str) -> ComfyInputKind:
+39
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import re
import sqlite3
from pathlib import Path
from typing import Any
@@ -410,6 +411,43 @@ def _backfill_workflow_output_map(conn: sqlite3.Connection) -> None:
)
# HEL-671: the user's H3 import went in under a first-frame-only name
# (“H3图生视频·首帧…”) although its graph is the FL2VA node that also takes
# a last frame. A row is renamed only when BOTH hold: its graph carries a
# wired MiniMaxH3ImageToVideo node, and its name still reads like a
# first-frame-only workflow — a row the user renamed, or any other graph,
# is left alone. Idempotent: the new name no longer matches.
_FIRST_FRAME_NAME_RE = re.compile(r"图生视频\s*[·\-—~]?\s*首帧")
_FL2VA_DESCRIPTION = "首帧必填,尾帧可选:只传首帧为图生视频,同时传尾帧为首尾帧生视频"
def _rename_workflows(conn: sqlite3.Connection) -> None:
for wid, name, wf_json in conn.execute(
"SELECT id, name, workflow_json FROM workflows"
).fetchall():
if not name or "尾帧" in name or not _FIRST_FRAME_NAME_RE.search(name):
continue
try:
workflow = json.loads(wf_json) if wf_json else None
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(workflow, dict):
continue
fl2va = any(
isinstance(node, dict)
and str(node.get("class_type") or "") == "MiniMaxH3ImageToVideo"
and isinstance((node.get("inputs") or {}).get("first_frame"), list)
for node in workflow.values()
)
if not fl2va:
continue
conn.execute(
"UPDATE workflows SET name = ?,"
" description = COALESCE(NULLIF(description, ''), ?) WHERE id = ?",
(_FIRST_FRAME_NAME_RE.sub("首尾帧生视频", name), _FL2VA_DESCRIPTION, wid),
)
async def migrate_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
@@ -472,6 +510,7 @@ async def migrate_db(db_path: Path) -> None:
# upgrades can settle them); confirmed rows are never touched.
_backfill_workflow_usage(conn)
_backfill_workflow_output_map(conn)
_rename_workflows(conn)
scene_cols = {r[1] for r in conn.execute("PRAGMA table_info(scenes)").fetchall()}
if "location_id" not in scene_cols:
conn.execute("ALTER TABLE scenes ADD COLUMN location_id INTEGER")
@@ -36,6 +36,7 @@ QWEN_EDIT = "qwen21_edit_sage_API.json" # qwen-image-2.1-图片编辑-sage加
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
REF_KEY = "131@ref_images"
LAST_KEY = "131@last_frame"
def _load(name: str) -> dict:
@@ -61,7 +62,8 @@ def _ref_links(wf: dict, node_id: str = "131") -> list[tuple[str, str]]:
# ---------------------------------------------------------------------------
def test_i2v_exposes_one_required_first_frame_and_no_last_frame():
def test_i2v_exposes_required_first_frame_and_optional_last_frame():
"""HEL-671: the 04 export is H3 FL2VA — `last_frame` is unwired but real."""
inputs = parse_dynamic_inputs(_load(I2V))
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
assert media == [
@@ -73,10 +75,36 @@ def test_i2v_exposes_one_required_first_frame_and_no_last_frame():
"defaultValue": None,
"required": True,
"auto": True,
}
},
{
"nodeId": LAST_KEY,
"label": "尾帧",
"role": "last_frame",
"kind": "image",
"defaultValue": None,
"required": False,
"auto": True,
},
]
# the title comment about FL2V is not a slot
assert not any(i.get("role") == "last_frame" for i in inputs)
def test_wired_last_frame_is_its_own_slot_not_a_second_virtual_one():
wf = _load(I2V)
wf["950"] = {"class_type": "LoadImage", "inputs": {"image": "last.png"}}
wf["131"]["inputs"]["last_frame"] = ["950", 0]
media = [i for i in parse_dynamic_inputs(wf) if i["kind"] == "image"]
assert [(i["nodeId"], i["role"], i["required"]) for i in media] == [
("901", "first_frame", True),
("950", "last_frame", True),
]
def test_first_frame_through_a_custom_loader_gets_no_last_frame_slot():
# Repo example: VHS_LoadImagePath → resize → first_frame; no slot pair to mirror.
wf = json.loads(
(EXAMPLES / "minimax_h3-Turbo_Image2Video_20260813_API.json").read_text(encoding="utf-8")
)
assert not any(i.get("role") == "last_frame" for i in parse_dynamic_inputs(wf))
def test_ref2v_exposes_one_group_of_one_to_nine_reference_images():
@@ -201,6 +229,29 @@ def test_single_slot_values_patch_first_frame_and_qwen_input():
assert qwen["4"]["inputs"]["resolution"] == 1536
def test_patch_last_frame_adds_its_own_load_image():
base = _load(I2V)
wf = patch_workflow(base, {"901": "/u/first.png", LAST_KEY: "/u/last.png"})
node = wf["131"]["inputs"]
assert node["first_frame"] == ["901", 0]
last_id = node["last_frame"][0]
assert last_id not in base and node["last_frame"] == [last_id, 0]
assert wf[last_id]["class_type"] == "LoadImage"
assert wf[last_id]["inputs"] == {"image": "/u/last.png"}
assert wf["901"]["inputs"]["image"] == "/u/first.png"
# base untouched; patching the result again rewires nothing
assert "last_frame" not in base["131"]["inputs"]
again = patch_workflow(wf, {LAST_KEY: "/u/other.png"})
assert again["131"]["inputs"]["last_frame"] == [last_id, 0]
def test_patch_without_last_frame_leaves_first_frame_only_graph():
for values in ({"901": "/u/first.png"}, {"901": "/u/first.png", LAST_KEY: ""}):
wf = patch_workflow(_load(I2V), values)
assert "last_frame" not in wf["131"]["inputs"]
assert sorted(wf) == sorted(_load(I2V))
def test_legacy_job_values_keyed_by_load_image_still_patch():
"""Jobs queued before HEL-633 carry the LoadImage id, not the group key."""
wf = patch_workflow(_load(REF), {"901": "/assets/uploads/old.png"})
@@ -265,6 +316,14 @@ def test_validate_rejects_bad_reference_files(uploads_dir):
def test_validate_requires_first_frame_and_input_image():
_, errs = validate_input_values(parse_dynamic_inputs(_load(I2V)), {"1": "p"})
assert errs == ["请上传首帧"]
_, errs = validate_input_values(
parse_dynamic_inputs(_load(I2V)), {"1": "p", LAST_KEY: "/u/l.png"}
)
assert errs == ["请上传首帧"]
clean, errs = validate_input_values(
parse_dynamic_inputs(_load(I2V)), {"1": "p", "901": "/u/f.png"}
)
assert errs == [] and LAST_KEY not in clean
_, errs = validate_input_values(parse_dynamic_inputs(_load(QWEN_EDIT)), {"4@prompt": "p"})
assert errs == ["请上传输入图"]
@@ -403,9 +462,67 @@ def test_i2v_chain_uploads_first_frame(client, named_comfy):
prompt = named_comfy.prompts[0]["prompt"]
assert prompt["901"]["inputs"]["image"] == f"calliope/{Path(first).name}"
assert prompt["131"]["inputs"]["first_frame"] == ["901", 0]
assert "last_frame" not in prompt["131"]["inputs"]
assert prompt["124"]["inputs"]["denoise"] == 0.8
def test_fl2v_chain_uploads_first_and_last_frame_into_their_own_inputs(client, named_comfy):
with _real_run(client, named_comfy):
wf_id = _import_workflow(client, "H3 首尾帧生视频", "video", _load(I2V))
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
assert listed["usage"] == "image2video"
slots = {i["nodeId"]: i for i in listed["input_schema"] if i["kind"] == "image"}
assert slots["901"]["role"] == "first_frame" and slots[LAST_KEY]["role"] == "last_frame"
last = _upload(client, "尾帧.png")
r = _generate(client, wf_id, {"1": "从白天过渡到夜晚", LAST_KEY: last})
assert r.status_code == 400 and "请上传首帧" in r.json()["detail"]
first = _upload(client, "首帧.png")
r = _generate(client, wf_id, {"1": "从白天过渡到夜晚", "901": first, LAST_KEY: last})
assert r.status_code == 200, r.text
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
prompt = named_comfy.prompts[0]["prompt"]
node = prompt["131"]["inputs"]
assert prompt[node["first_frame"][0]]["inputs"]["image"] == f"calliope/{Path(first).name}"
assert prompt[node["last_frame"][0]]["inputs"]["image"] == f"calliope/{Path(last).name}"
assert node["first_frame"][0] != node["last_frame"][0]
assert sorted(named_comfy.upload_names) == sorted([Path(first).name, Path(last).name])
def test_migration_renames_the_imported_first_frame_workflow(tmp_path):
import asyncio
import sqlite3
from calliope.db import migrate_db
db = tmp_path / "c.db"
asyncio.run(migrate_db(db))
conn = sqlite3.connect(db)
i2v, ref = json.dumps(_load(I2V)), json.dumps(_load(REF))
conn.executemany(
"INSERT INTO workflows (name, kind, workflow_json, description) VALUES (?, 'video', ?, ?)",
[
("H3 图生视频·首帧(04 用户提供)", i2v, None),
("H3图生视频·首帧", i2v, "我自己写的说明"),
("图生视频·首帧模板", ref, None), # not the FL2VA graph — left alone
("我的视频工作流", i2v, None), # user already renamed — left alone
],
)
conn.commit()
conn.close()
asyncio.run(migrate_db(db))
asyncio.run(migrate_db(db)) # idempotent
conn = sqlite3.connect(db)
rows = conn.execute("SELECT name, description FROM workflows ORDER BY id").fetchall()
conn.close()
assert rows[0] == (
"H3 首尾帧生视频(04 用户提供)",
"首帧必填,尾帧可选:只传首帧为图生视频,同时传尾帧为首尾帧生视频",
)
assert rows[1] == ("H3首尾帧生视频", "我自己写的说明") # suffix kept, description kept
assert rows[2] == ("图生视频·首帧模板", None)
assert rows[3] == ("我的视频工作流", None)
def test_broken_qwen_import_warns_and_generation_is_refused(client, named_comfy):
with _real_run(client, named_comfy):
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": _load(QWEN_EDIT)})
@@ -82,13 +82,16 @@ def test_a2_qwen_edit_variant_with_new_ids_titles_and_model_stays_image2image():
assert len(mask) == 1 # mask never counts as an input image
def test_a3_i2v_variant_renamed_still_image2video_first_frame_only():
def test_a3_i2v_variant_renamed_still_image2video_with_optional_last_frame():
wf = _rename_everything(_load(FIXTURES / "h3_i2v_04_API.json"))
c = classify_usage(wf)
assert c["usage"] == "image2video"
media = _media(parse_dynamic_inputs(wf))
assert [i.get("role") for i in media] == ["first_frame"]
# A3: no first/last pair → the pair validation must not fire
# HEL-671: the FL2VA node's unwired last_frame is offered, but optional
assert [(i.get("role"), i["required"]) for i in media] == [
("first_frame", True),
("last_frame", False),
]
assert blocking_problem(wf) is None
@@ -154,6 +154,7 @@
{@const reject = rejects[s.inp.nodeId]?.[0]}
<MediaSlot
label={s.label}
hint={s.lastFrame && !s.inp.required ? t('studio.slotOptional') : undefined}
required={s.inp.required}
value={String(values[s.inp.nodeId] ?? '')}
kind={s.mediaKind}
@@ -282,7 +282,9 @@ export function validateForm(
if (first && last) {
const a = String(values[first.inp.nodeId] ?? '');
const b = String(values[last.inp.nodeId] ?? '');
if ((!a || !b) && (a || b)) {
// An optional last frame (H3 FL2VA) may stay empty; it never goes without a first frame.
const unpaired = last.inp.required ? (!a || !b) && (a || b) : !a && !!b;
if (unpaired) {
errs.firstLast = t('studio.errFirstLast');
if (!a) errs[first.inp.nodeId] = ' ';
if (!b) errs[last.inp.nodeId] = ' ';
+1
View File
@@ -1558,6 +1558,7 @@ export const de: Dict = {
'studio.slotMask': 'Maske',
'studio.slotFirst': 'Erster Frame',
'studio.slotLast': 'Letzter Frame',
'studio.slotOptional': 'Optional',
'studio.slotRefVideo': 'Referenzvideo',
'studio.slotAudio': 'Referenzaudio',
'studio.slotPick': 'Klicken oder Datei ziehen',
+1
View File
@@ -1557,6 +1557,7 @@ export const en = {
'studio.slotMask': 'Mask',
'studio.slotFirst': 'First frame',
'studio.slotLast': 'Last frame',
'studio.slotOptional': 'Optional',
'studio.slotRefVideo': 'Reference video',
'studio.slotAudio': 'Reference audio',
'studio.slotPick': 'Click or drop a file',
+1
View File
@@ -1558,6 +1558,7 @@ export const es: Dict = {
'studio.slotMask': 'Máscara',
'studio.slotFirst': 'Primer fotograma',
'studio.slotLast': 'Último fotograma',
'studio.slotOptional': 'Opcional',
'studio.slotRefVideo': 'Vídeo de referencia',
'studio.slotAudio': 'Audio de referencia',
'studio.slotPick': 'Haz clic o arrastra',
+1
View File
@@ -1558,6 +1558,7 @@ export const fr: Dict = {
'studio.slotMask': 'Masque',
'studio.slotFirst': 'Premier cadre',
'studio.slotLast': 'Dernier cadre',
'studio.slotOptional': 'Facultatif',
'studio.slotRefVideo': 'Vidéo de référence',
'studio.slotAudio': 'Audio de référence',
'studio.slotPick': 'Cliquez ou déposez',
+1
View File
@@ -1556,6 +1556,7 @@ export const ja: Dict = {
'studio.slotMask': 'マスク',
'studio.slotFirst': '始端フレーム',
'studio.slotLast': '終端フレーム',
'studio.slotOptional': '任意',
'studio.slotRefVideo': '参照動画',
'studio.slotAudio': '参照音声',
'studio.slotPick': 'クリックまたはドロップ',
+1
View File
@@ -1556,6 +1556,7 @@ export const ko: Dict = {
'studio.slotMask': '마스크',
'studio.slotFirst': '첫 프레임',
'studio.slotLast': '마지막 프레임',
'studio.slotOptional': '선택',
'studio.slotRefVideo': '참조 비디오',
'studio.slotAudio': '참조 오디오',
'studio.slotPick': '클릭하거나 끌어다 놓기',
+1
View File
@@ -1555,6 +1555,7 @@ export const zh: Dict = {
'studio.slotMask': '遮罩',
'studio.slotFirst': '首帧',
'studio.slotLast': '尾帧',
'studio.slotOptional': '可选',
'studio.slotRefVideo': '参考视频',
'studio.slotAudio': '参考音频',
'studio.slotPick': '点击或拖入',