Compare commits

...
275 changed files with 1157777 additions and 52 deletions
+5
View File
@@ -46,3 +46,8 @@ prototype/
# --- Reference clones (vendored for porting only) ---
reference/
# HEL-892 offline audit is reproducible; local DBs stay private.
.local-evidence/
!docs/hel-892/
!docs/hel-892/**
+6 -1
View File
@@ -37,7 +37,12 @@
"image",
"img",
"vid",
"video"
"video",
"audio",
"sound",
"file",
"srt",
"subtitle"
],
"agent_event_types": [
"assistant/message",
@@ -0,0 +1,360 @@
(
'Source-only UI conversion'
' inventory. This does not'
' implement export rules.'
)
from __future__ import annotations
import argparse
import hashlib
import json
from collections import Counter
from pathlib import Path
def graph_rows(graph: dict, pointer: str = "", scope: str = ""):
(
'Use the same scoped IDs a'
's library documents, incl'
'uding unused definitions.'
)
yield graph, pointer, scope
for i, sub in enumerate(graph.get("definitions", {}).get("subgraphs", [])):
yield from graph_rows(sub, f"{pointer}/definitions/subgraphs/{i}", f"{scope}{sub['id']}/")
def cycles(nodes: list[dict], links: list) -> list[list[str]]:
(
'Strongly connected compon'
'ents of literal edges, no'
't plugin execution loops.'
)
adjacency = {str(n["id"]): set() for n in nodes}
for link in links:
if isinstance(link, list) and len(link) >= 6:
a, b = str(link[1]), str(link[3])
elif isinstance(link, dict) and {"origin_id", "target_id"} <= link.keys():
a, b = str(link["origin_id"]), str(link["target_id"])
else:
continue
if a in adjacency and b in adjacency:
adjacency[a].add(b)
order, low, stack, active, found = {}, {}, [], set(), []
def visit(n):
order[n] = low[n] = len(order)
stack.append(n)
active.add(n)
for target in sorted(adjacency[n]):
if target not in order:
visit(target)
low[n] = min(low[n], low[target])
elif target in active:
low[n] = min(low[n], order[target])
if low[n] == order[n]:
component = []
while True:
target = stack.pop()
active.remove(target)
component.append(target)
if target == n:
break
if len(component) > 1 or n in adjacency[n]:
found.append(sorted(component))
for n in adjacency:
if n not in order:
visit(n)
return found
# Source-review rules: these describe what must be checked, not what an export
# will do. Candidates are kept even when not referenced in the main graph.
RULES = {
"subgraph": (
'按定义ID及实例输入输出逐槽核对展开、实例ID重写'
'和未使用定义剔除;源UI不能证明展开规则。'
),
"reroute": "沿所列输入输出核对转接节点的剔除和端点重连,不能按相邻布局猜线。",
"virtual_candidate": (
"Set/Get或Primitive等候选虚拟节点需对应前端/"
"插件规则及真实导出;type字符串不能单独证明应剔除。"
),
"note": (
'注释原值保留用于说明;实际导出是否剔除需核对,不把'
'注释当服务端模型节点。'
),
"nonzero_mode": (
'源mode非0(2/4保留为静音/旁路候选);逐输'
'入核对跳过/穿透与实际端点,不能只删除节点。'
),
"loop_candidate": (
'循环命名节点仅是候选;核对循环边界、迭代输入和前端'
'展开规则,不能声称静态DAG即可执行。'
),
"literal_cycle": (
'字面连线存在强连通分量;需真实导出与插件循环语义确'
'认,不能直接交队列。'
),
"position_widgets": (
'位置数组逐节点待具名;object_info的in'
'put_order本身不足以证明第三方控件序列化顺'
'序。'
),
"named_widgets": (
'具名控件仍须核对前端状态、动态槽和服务端字段声明;'
'不保证可直接原样拷贝。'
),
"slots": (
'逐条核对起止节点、非零输出槽、输入槽索引、端点声明'
'及源type;通配/联合/类型不一致仍保留待验证。'
),
"node_definitions": (
'逐节点核对服务端字段/槽/必填与插件版本;源UI的'
'type不是可信object_info,实际未采集'
'。'
),
"nonconcrete_slots": (
'源槽声明为通配/联合/枚举等非单一类型;严格相等校'
'验不能自动放行,需真实定义与导出。'
),
"nonzero_output_links": "源连线使用非零输出槽;保留槽号,不能全部改成0。",
"endpoint_findings": (
'仅检查源声明:本地端点缺失可能为子图边界,索引/端'
'点不一致需原前端导出核对,禁止静默纠正。'
),
}
def inventory(repo: Path) -> dict:
docs = repo / "docs/hel-892"
manifest = json.loads((docs / "offline/manifest.json").read_text(encoding="utf-8"))
cases = json.loads((docs / "golden-cases.json").read_text(encoding="utf-8"))["cases"]
files = []
for entry in manifest:
raw = (repo / entry["path"]).read_bytes()
source = json.loads(raw.decode("utf-8-sig"))
assert hashlib.sha1(raw).hexdigest() == entry["source_sha"]
if "nodes" not in source:
continue
findings = {key: [] for key in RULES}
graphs = []
for graph, pointer, scope in graph_rows(source):
nodes = graph["nodes"]
links = graph.get("links", [])
definitions = graph.get("definitions", {}).get("subgraphs", [])
sub_ids = {s["id"] for s in definitions}
# Also allow a subgraph definition referenced from a nested graph.
all_sub_ids = {
s["id"]
for g, _, _ in graph_rows(source)
for s in g.get("definitions", {}).get("subgraphs", [])
}
graph_ref = {
"scope": scope,
"pointer": pointer or "/",
"node_count": len(nodes),
"links": links,
"inputs": graph.get("inputs", []),
"outputs": graph.get("outputs", []),
"extra_keys": sorted(set(graph) - {"nodes", "links"}),
}
graphs.append(graph_ref)
for i, sub in enumerate(definitions):
findings["subgraph"].append(
{
"kind": "definition",
"id": sub["id"],
"name": sub.get("name"),
"pointer": f"{pointer}/definitions/subgraphs/{i}",
"local_instances": [
scope + str(n["id"]) for n in nodes if n.get("type") == sub["id"]
],
}
)
for i, node in enumerate(nodes):
ct = node.get("type", "")
ref = {
"node_id": scope + str(node["id"]),
"type": ct,
"pointer": f"{pointer}/nodes/{i}",
"mode": node.get("mode", 0),
}
if ct in all_sub_ids or ct in sub_ids:
findings["subgraph"].append({**ref, "kind": "instance"})
if "reroute" in ct.lower():
findings["reroute"].append(ref)
if ct in {"SetNode", "GetNode", "PrimitiveNode"} or ct.startswith("Primitive"):
findings["virtual_candidate"].append(
{**ref, "widgets_values": node.get("widgets_values")}
)
if ct in {"Note", "MarkdownNote"}:
findings["note"].append(ref)
if node.get("mode", 0) != 0:
findings["nonzero_mode"].append(ref)
if any(word in ct.lower() for word in ("loop", "foreach", "forloop", "while")):
findings["loop_candidate"].append(ref)
widgets = node.get("widgets_values")
if ct not in {"Note", "MarkdownNote"} and isinstance(widgets, list) and widgets:
findings["position_widgets"].append({**ref, "values": widgets})
if isinstance(widgets, dict) and widgets:
findings["named_widgets"].append({**ref, "fields": sorted(widgets)})
findings["slots"].append(
{**ref, "inputs": node.get("inputs", []), "outputs": node.get("outputs", [])}
)
for direction in ("inputs", "outputs"):
for slot, socket in enumerate(node.get(direction, [])):
value = socket.get("type")
if not isinstance(value, str) or value in {"*", "ANY"} or "," in value:
findings["nonconcrete_slots"].append(
{
**ref,
"direction": direction,
"slot": slot,
"name": socket.get("name"),
"source_type": value,
}
)
if ct not in {"Note", "MarkdownNote"}:
findings["node_definitions"].append(ref)
by_id = {str(n["id"]): n for n in nodes}
for i, link in enumerate(links):
ref = {"scope": scope, "pointer": f"{pointer}/links/{i}", "link": link}
if isinstance(link, dict):
values = [
link.get(k)
for k in (
"id",
"origin_id",
"origin_slot",
"target_id",
"target_slot",
"type",
)
]
elif isinstance(link, list) and len(link) >= 6:
values = link
else:
findings["endpoint_findings"].append({**ref, "reason": "nonstandard_link"})
continue
lid, origin, output, target, input_slot = values[:5]
if type(output) is int and output != 0:
findings["nonzero_output_links"].append(ref)
for node_id, direction, slot in (
(origin, "outputs", output),
(target, "inputs", input_slot),
):
node = by_id.get(str(node_id))
if node is None:
reason = (
'endpoint_not_local_may_be'
'_subgraph_boundary'
)
elif type(slot) is not int or not 0 <= slot < len(node.get(direction, [])):
reason = (
'slot_index_outside_source'
'_declaration'
)
elif direction == "inputs" and node[direction][slot].get("link") != lid:
reason = "input_link_id_not_reciprocal"
elif direction == "outputs" and lid not in (
node[direction][slot].get("links") or []
):
reason = "output_link_id_not_reciprocal"
else:
continue
findings["endpoint_findings"].append(
{
**ref,
"node_id": scope + str(node_id),
"direction": direction,
"slot": slot,
"reason": reason,
}
)
for component in cycles(nodes, links):
findings["literal_cycle"].append(
{"scope": scope, "node_ids": [scope + n for n in component]}
)
matching = [
c
for c in cases
if c["source_path"] == entry["path"] and c["source_sha"] == entry["source_sha"]
]
files.append(
{
"path": entry["path"],
"source_sha": entry["source_sha"],
"facts_method": (
'mechanical_source_structu'
're_extraction'
),
"source_use_review": "docs/hel-892/逐文件第三批审校.md",
"graphs": graphs,
"findings": findings,
"rule_review": [
{
"category": key,
"evidence_count": len(value),
"rule": RULES[key],
"status": "requires_materials_not_implemented",
}
for key, value in findings.items()
if value
],
"golden_case_ids": [c["id"] for c in matching],
"golden_coverage": "exact_source_candidate_only" if matching else "no_exact_sample",
"materials": {
"original_frontend_api_export": {
"source_path": entry["path"],
"source_sha": entry["source_sha"],
"case_export": matching[0]["export_file"] if matching else None,
"needed_for": [
k for k in findings if findings[k] and k != "node_definitions"
],
},
"trusted_definitions": (
'findings.node_definitions'
'逐节点字段和槽;不是模型包或机器访问请求'
),
},
"converted_reliably": False,
"executable": False,
"execution_verified": False,
"remaining": "源结构清单不能当作可靠转换;即使差分equal也不代表可执行",
}
)
actual_ui = {
p.relative_to(repo).as_posix()
for p in (repo / "example_ComfyUI_workflows").rglob("*")
if p.is_file() and "nodes" in json.loads(p.read_text(encoding="utf-8-sig"))
}
assert len(files) == len(actual_ui) == 146
assert {f["path"] for f in files} == actual_ui
return {
"schema_version": 1,
"files": files,
"total_ui_files": len(files),
"remaining_reliable_conversions": 146,
"not_execution_evidence": True,
"files_by_finding": dict(Counter(k for f in files for k, v in f["findings"].items() if v)),
"exact_golden_candidates": sum(bool(f["golden_case_ids"]) for f in files),
"rule_review_method": (
'人工规则核对与机器证据拼接;所有规则仍待真实导出/'
'定义,不是146次实际转换验证'
),
"negative_finding_limit": "未命中命名候选或静态环不证明没有插件隐藏循环/虚拟语义",
}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
report = inventory(args.repo)
args.output.write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps({k: v for k, v in report.items() if k != "files"}, ensure_ascii=False))
@@ -0,0 +1,313 @@
"Compare exported source-only documentation with repository bytes, without services."
from __future__ import annotations
import argparse
import copy
import hashlib
import json
from collections import Counter
from pathlib import Path
def audit(repo: Path, directory: Path) -> dict:
manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8"))
sources = {
p.relative_to(repo).as_posix()
for p in (repo / "example_ComfyUI_workflows").rglob("*")
if p.is_file()
}
assert {e["path"] for e in manifest} == sources
assert len(manifest) == len(sources)
files = []
for entry in manifest:
raw = (repo / entry["path"]).read_bytes()
source = json.loads(raw.decode("utf-8-sig"))
doc = json.loads((directory / entry["document"]).read_text(encoding="utf-8"))
assert hashlib.sha1(raw).hexdigest() == entry["source_sha"]
assert doc["source_review"]["source_sha"] == entry["source_sha"]
nodes = {}
source_links = []
def visit(graph, scope=""):
ui = "nodes" in graph
rows = graph["nodes"] if ui else [dict(n, id=k) for k, n in graph.items()]
for node in rows:
nid = scope + str(node["id"])
nodes[nid] = (
node["widgets_values"]
if ui and "widgets_values" in node
else ([] if ui else node["inputs"])
)
if not ui:
source_links.extend(
{
"scope": scope,
"node_id": nid,
"field": key,
"upstream_node_id": str(value[0]),
"output_slot": value[1],
}
for key, value in node["inputs"].items()
if isinstance(value, list) and len(value) == 2
)
source_links.extend({"scope": scope, "link": link} for link in graph.get("links", []))
for sub in graph.get("definitions", {}).get("subgraphs", []):
visit(sub, scope + sub["id"] + "/")
visit(source)
assert doc["source_links"] == source_links
assert {n["node_id"]: n["source_values"] for n in doc["source_nodes"]} == nodes
for param in doc["params"]:
assert param["default"] == nodes[param["node_id"]][param["field"]]
assert param["unit"] is None
assert param["min"] is None and param["max"] is None
for note in doc["source_notes"]:
assert note["content"] == nodes[note["node_id"]]
checked_fields = doc["source_review"].get("reviewed_named_fields", [])
assert len({(f["node_id"], f["field"]) for f in checked_fields}) == len(checked_fields)
for field in checked_fields:
assert field["source_value"] == nodes[field["node_id"]][field["field"]]
fourth = doc["source_review"].get("fourth_review")
if fourth:
assert fourth["operation_check"] in doc["steps"]
assert {(f["node_id"], f["field"]) for f in checked_fields} == {
(p["node_id"], p["field"]) for p in doc["params"]
}
semantic = doc["source_review"].get("semantic_review")
if semantic:
assert semantic["batch"] == "HEL-892-third"
assert semantic["operation_check"] in doc["steps"]
assert semantic["conclusions"] and semantic["unknowns"]
assert {(f["node_id"], f["field"]) for f in checked_fields} == {
(p["node_id"], p["field"]) for p in doc["params"]
}
for kind in ("nodes", "notes"):
for ref in semantic["evidence_refs"][kind]:
key, index = ref["pointer"].strip("/").split("/")
assert doc[key][int(index)]["node_id"] == ref["node_id"]
assert semantic["mechanical_inventory"] == {
"named_value_count": len(doc["params"]),
"note_count": len(doc["source_notes"]),
"link_count": len(doc["source_links"]),
"position_widget_node_count": len(
doc["source_review"]["evidence"]["unnamed_widget_node_ids"]
),
}
if not checked_fields:
assert semantic["named_parameter_finding"] == ("未发现可由源文件确认的具名参数")
assert doc["status"] == {"imported": True, "doc_done": False, "exec_verified": False}
assert doc["source_review"]["complete"] is False
files.append(
{
"path": entry["path"],
"source_sha": entry["source_sha"],
"document": entry["document"],
"state": entry["state"],
"manual_scope": doc["source_review"]["scope"],
"purpose": doc["purpose"],
"source_specific_instruction": doc["steps"][0],
"source_evidence": doc["source_review"]["evidence"],
"unconfirmed": doc["source_review"]["unconfirmed"],
"reviewed_named_fields": checked_fields,
"semantic_review": semantic,
}
)
return {
"files": files,
"total": len(files),
"source_structure_purpose_partially_reviewed": len(files),
"full_documents_completed": 0,
"files_with_selected_named_fields_reviewed": sum(
bool(f["reviewed_named_fields"]) for f in files
),
"selected_named_fields_reviewed": sum(len(f["reviewed_named_fields"]) for f in files),
"real_executions": 0,
"states": dict(Counter(f["state"] for f in files)),
"named_values_preserved": sum(f["source_evidence"]["named_value_count"] for f in files),
"unnamed_widget_nodes": sum(
len(f["source_evidence"]["unnamed_widget_node_ids"]) for f in files
),
"semantic_batch_files": sum(bool(f["semantic_review"]) for f in files),
"semantic_batch_without_named_fields": sum(
bool(f["semantic_review"]) and not f["reviewed_named_fields"] for f in files
),
"semantic_batch_named_fields": sum(
len(f["reviewed_named_fields"]) for f in files if f["semantic_review"]
),
"named_values_without_manual_field_review": sum(
f["source_evidence"]["named_value_count"] - len(f["reviewed_named_fields"])
for f in files
),
"named_values_without_verified_units_ranges_effects": sum(
f["source_evidence"]["named_value_count"] for f in files
),
"scope_limit": "人工结论与机械原值清单分开;源语义局部审校非完整说明定稿",
}
def audit_partition(repo: Path, report: dict, partition_path: Path) -> dict:
partition = json.loads(partition_path.read_text(encoding="utf-8"))
prior, added = partition["prior_16"], partition["new_140"]
prior_paths = {e["path"] for e in prior}
added_paths = {e["path"] for e in added}
assert len(prior) == len(prior_paths) == 16
assert len(added) == len(added_paths) == 140
assert prior_paths.isdisjoint(added_paths)
assert prior_paths | added_paths == {f["path"] for f in report["files"]}
reviews = json.loads(
(repo / ("calliope-backend/src/calliope/comfyui/source_reviews.json")).read_text(
encoding="utf-8"
)
)
for entry in prior:
# The third-batch snapshot remains immutable historical evidence.
# Reconstruct its exact payload, excluding only the explicit batch-four delta.
review = copy.deepcopy(reviews[entry["path"]])
fourth = review.pop("fourth_review", None)
if fourth:
fourth_fields = {tuple(k) for k in fourth["fields"]}
review["reviewed_named_fields"] = [
f
for f in review["reviewed_named_fields"]
if (f["node_id"], f["field"]) not in fourth_fields
]
assert review["reviewed_named_fields"] == entry["reviewed_named_fields"]
assert (
hashlib.sha256(
json.dumps(review, ensure_ascii=False, sort_keys=True).encode()
).hexdigest()
== entry["review_sha256"]
)
by_path = {f["path"]: f for f in report["files"]}
for entry in prior + added:
assert entry["source_sha"] == by_path[entry["path"]]["source_sha"]
assert all(by_path[p]["semantic_review"] for p in added_paths)
assert all(not by_path[p]["semantic_review"] for p in prior_paths)
return {
"prior_files": 16,
"prior_named_fields": 42,
"new_files": 140,
"new_named_fields": report["semantic_batch_named_fields"],
"new_without_named_fields": report["semantic_batch_without_named_fields"],
"disjoint_complete_sha_verified": True,
"prior_review_payloads_unchanged": True,
}
def audit_fourth(repo: Path, report: dict, partition_path: Path) -> dict:
partition = json.loads(partition_path.read_text(encoding="utf-8"))
mechanical = {tuple(k) for k in partition["mechanical_1227"]}
prior = {tuple(k) for k in partition["prior_542"]}
added = {tuple(k) for k in partition["added_685"]}
assert len(mechanical) == len(partition["mechanical_1227"]) == 1227
assert len(prior) == len(partition["prior_542"]) == 542
assert len(added) == len(partition["added_685"]) == 685
assert prior.isdisjoint(added) and prior | added == mechanical
assert len({k[0] for k in added}) == 16
reviews = json.loads(
(repo / ("calliope-backend/src/calliope/comfyui/source_reviews.json")).read_text(
encoding="utf-8"
)
)
current_ids, checked_added, categories = set(), set(), Counter()
for path, original in reviews.items():
review = copy.deepcopy(original)
fourth = review.pop("fourth_review", None)
fields = review["reviewed_named_fields"]
current_ids.update((path, f["node_id"], f["field"]) for f in fields)
if fourth:
raw = (repo / path).read_bytes()
source = json.loads(raw.decode("utf-8-sig"))
assert hashlib.sha1(raw).hexdigest() == review["source_sha"]
new_fields = [f for f in fields if (path, f["node_id"], f["field"]) in added]
assert {(f["node_id"], f["field"]) for f in new_fields} == {
tuple(k) for k in fourth["fields"]
}
for f in new_fields:
assert f["source_path"] == path and f["source_sha"] == review["source_sha"]
value = source
for part in f["source_pointer"].strip("/").split("/"):
part = part.replace("~1", "/").replace("~0", "~")
value = value[int(part)] if isinstance(value, list) else value[part]
assert json.dumps(value, sort_keys=True) == json.dumps(
f["source_value"], sort_keys=True
)
nid = f["node_id"]
if "nodes" in source:
node = next(n for n in source["nodes"] if str(n["id"]) == nid)
assert f["class_type"] == node["type"]
for direction, endpoint in (("incoming", 3), ("outgoing", 1)):
actual = [
{"pointer": f"/links/{i}", "link": link}
for i, link in enumerate(source.get("links", []))
if str(link[endpoint]) == nid
]
assert f["connections"][direction] == actual
else:
assert f["class_type"] == source[nid]["class_type"]
assert f["connections"]["incoming"] == [
{"field": k, "link": v} for k, v in source[nid]["inputs"].items()
if isinstance(v, list)
]
assert f["connections"]["outgoing"] == [
{"node_id": target, "field": k, "link": v}
for target, n in source.items() for k, v in n["inputs"].items()
if isinstance(v, list) and len(v) == 2 and v[0] == nid
]
assert f["category"] in {
"runtime_parameter",
"frontend_state",
"current_value_only",
"semantic_unknown",
}
assert f["label"] and f["operation_check"] and f["connections"]
categories[f["category"]] += 1
checked_added.add((path, f["node_id"], f["field"]))
assert fourth["classification_counts"] == dict(
Counter(f["category"] for f in new_fields)
)
review["reviewed_named_fields"] = [
f for f in fields if (path, f["node_id"], f["field"]) not in added
]
assert (
hashlib.sha256(
json.dumps(review, ensure_ascii=False, sort_keys=True).encode()
).hexdigest()
== partition["prior_review_sha256"][path]
)
assert current_ids == mechanical and checked_added == added
assert report["selected_named_fields_reviewed"] == 1227
return {
"mechanical_values_checked": 1227,
"prior_reviewed": 542,
"added_reviewed": 685,
"new_files": 16,
"classification_counts_new": dict(categories),
"duplicates": 0,
"prior_156_payloads_preserved": True,
"added_fields_with_runtime_source_use": categories["runtime_parameter"],
"added_fields_without_confirmed_runtime_meaning": 685 - categories["runtime_parameter"],
"verified_units_ranges_effects": 0,
"full_documents_completed": 0,
"real_executions": 0,
}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", type=Path, required=True)
parser.add_argument("--documents", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--partition", type=Path)
parser.add_argument("--fourth-partition", type=Path)
args = parser.parse_args()
report = audit(args.repo, args.documents)
if args.partition:
report["partition"] = audit_partition(args.repo, report, args.partition)
if args.fourth_partition:
report["fourth_partition"] = audit_fourth(args.repo, report, args.fourth_partition)
args.output.write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(json.dumps({k: v for k, v in report.items() if k != "files"}, ensure_ascii=False))
@@ -0,0 +1,219 @@
"""Offline, fail-closed comparison against source-bound original frontend exports.
This command never imports a workflow, enables one, calls a service, or executes it.
An equal result only establishes equality to the supplied export, not its provenance.
"""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
from calliope.comfyui.library import load_snapshot
from calliope.comfyui.ui2api import convert
def differences(expected, actual, pointer=""):
"""Keep types, absent keys, list order and nonzero output slots significant."""
if type(expected) is not type(actual):
return [{"path": pointer, "expected": expected, "actual": actual, "kind": "type"}]
if isinstance(expected, dict):
rows = []
for key in sorted(expected.keys() | actual.keys()):
child = pointer + "/" + key.replace("~", "~0").replace("/", "~1")
if key not in expected or key not in actual:
rows.append(
{
"path": child,
"kind": "missing_key",
"expected_present": key in expected,
"actual_present": key in actual,
"expected": expected.get(key),
"actual": actual.get(key),
}
)
else:
rows.extend(differences(expected[key], actual[key], child))
return rows
if isinstance(expected, list):
rows = []
for index in range(max(len(expected), len(actual))):
child = pointer + "/" + str(index)
if index >= len(expected) or index >= len(actual):
rows.append(
{
"path": child,
"kind": "missing_item",
"expected_present": index < len(expected),
"actual_present": index < len(actual),
"expected": expected[index] if index < len(expected) else None,
"actual": actual[index] if index < len(actual) else None,
}
)
else:
rows.extend(differences(expected[index], actual[index], child))
return rows
return (
[]
if expected == actual
else [{"path": pointer, "kind": "value", "expected": expected, "actual": actual}]
)
def preserved_fields(expected, actual, pointer=""):
if type(expected) is not type(actual):
return []
if isinstance(expected, dict):
return [
row
for key in sorted(expected.keys() & actual.keys())
for row in preserved_fields(
expected[key],
actual[key],
pointer + "/" + key.replace("~", "~0").replace("/", "~1"),
)
]
if isinstance(expected, list):
return [
row
for index, (left, right) in enumerate(zip(expected, actual))
for row in preserved_fields(left, right, pointer + "/" + str(index))
]
return [{"path": pointer, "value": expected}] if expected == actual else []
def compare_case(repo, exports, case, snapshot=None):
row = {**case, "executable": False, "execution_verified": False}
try:
raw = (repo / case["source_path"]).read_bytes()
if hashlib.sha1(raw).hexdigest() != case["source_sha"]:
return {**row, "status": "source_sha_mismatch"}
source = json.loads(raw.decode("utf-8-sig"))
missing = [
name
for name in (case["export_file"], case["binding_file"])
if not (exports / name).is_file()
]
if missing:
return {
**row,
"status": "missing_materials",
"missing": missing,
"cannot_check": case["comparison"],
"node_definitions_available": bool(snapshot),
"missing_definitions": [] if snapshot else [
"未提供可信节点定义;UI位置控件和插件行为不能仅凭源文件确认"
],
}
export_raw = (exports / case["export_file"]).read_bytes()
binding = json.loads((exports / case["binding_file"]).read_text(encoding="utf-8"))
# Binding is an operator statement, not automatic authentication of provenance.
required = {
"source_path": case["source_path"],
"source_sha": case["source_sha"],
"export_sha256": hashlib.sha256(export_raw).hexdigest(),
"original_frontend_export": True,
}
if any(
type(binding.get(k)) is not type(v) or binding.get(k) != v for k, v in required.items()
) or not all(
isinstance(binding.get(k), str) and binding[k].strip()
for k in ("exported_at", "frontend_version", "node_versions", "origin")
):
return {**row, "status": "invalid_binding", "required": required}
expected = json.loads(export_raw.decode("utf-8-sig"))
# A UI file is not an original API export, even when convert could accept it.
if not isinstance(expected, dict) or "nodes" in expected:
return {**row, "status": "invalid_export", "issues": ["必须为原始API图"]}
checked = convert(expected)
if checked.prompt is None:
return {**row, "status": "invalid_export", "issues": checked.issues}
result = convert(source, (snapshot or {}).get("object_info"))
row.update(
export_sha256=required["export_sha256"],
binding=binding,
provenance="仅记录提供者声明;真实性仍需审核",
)
if result.prompt is None:
return {
**row,
"status": "conversion_blocked",
"issues": result.issues,
"cannot_check": "转换器未生成完整prompt,不能逐字段/连线对照",
}
def split(graph):
return (
{
nid: {k: v for k, v in node.items() if k != "_meta"}
for nid, node in graph.items()
},
{nid: node["_meta"] for nid, node in graph.items() if "_meta" in node},
)
expected_graph, expected_meta = split(expected)
actual_graph, actual_meta = split(result.prompt)
delta = differences(expected_graph, actual_graph)
metadata = differences(expected_meta, actual_meta)
return {
**row,
"status": "different" if delta or metadata else "equal",
"differences": delta,
"metadata_differences": metadata,
"preserved_fields": preserved_fields(expected_graph, actual_graph),
"preserved_node_ids": sorted(expected.keys() & result.prompt.keys()),
"comparison_scope": "全部字段、类型、值及连线槽;_meta单列也影响equal",
}
except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc:
return {**row, "status": "invalid_materials", "error": f"{type(exc).__name__}: {exc}"}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", type=Path, required=True)
parser.add_argument("--cases", type=Path, required=True)
parser.add_argument("--exports", type=Path, required=True)
parser.add_argument("--snapshot", type=Path)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
cases = json.loads(args.cases.read_text(encoding="utf-8"))["cases"]
try:
snapshot = load_snapshot(args.snapshot)
except (OSError, ValueError, AttributeError) as exc:
report = {
"status": "invalid_snapshot",
"error": str(exc),
"cases": [],
"execution_verified": False,
}
else:
rows = [compare_case(args.repo, args.exports, case, snapshot) for case in cases]
report = {
"status": "equal"
if rows and all(r["status"] == "equal" for r in rows)
else "not_ready",
"cases": rows,
"execution_verified": False,
"snapshot_source": (snapshot or {}).get("source_url"),
"snapshot_captured_at": (snapshot or {}).get("captured_at"),
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(
json.dumps(
{
"status": report["status"],
"cases": [{"id": r["id"], "status": r["status"]} for r in report["cases"]],
},
ensure_ascii=False,
)
)
return 0 if report["status"] == "equal" else 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -9,8 +9,13 @@ from typing import Any
import httpx
from calliope.comfyui.registry import (
AUDIO_CLASSES,
IMAGE_CLASSES,
VIDEO_CLASSES,
VIDEO_FILE_CLASSES,
)
from calliope.config import settings
from calliope.comfyui.registry import IMAGE_CLASSES, AUDIO_CLASSES, VIDEO_CLASSES, VIDEO_FILE_CLASSES
logger = logging.getLogger("calliope.comfyui")
@@ -336,6 +341,9 @@ class ComfyUIClient:
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mkv", ".mov", ".gif", ".m4v", ".avi", ".flv"})
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"})
_AUDIO_EXTS = frozenset({".wav", ".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac"})
_FILE_EXTS = frozenset({".srt", ".vtt", ".txt", ".json"})
@classmethod
def _infer_media_kind(cls, filename: str, history_key: str) -> str | None:
ext = Path(str(filename)).suffix.lower()
@@ -343,6 +351,12 @@ class ComfyUIClient:
return "video"
if ext in cls._IMAGE_EXTS:
return "image"
if ext in cls._AUDIO_EXTS:
return "audio"
if ext in cls._FILE_EXTS:
return "file"
if history_key in ("audio", "audios"):
return "audio"
if history_key in ("gifs", "videos"):
return "video"
return None
@@ -361,9 +375,14 @@ class ComfyUIClient:
for node_id, node_out in (history.get("outputs") or {}).items():
if node_kinds is not None and str(node_id) not in node_kinds:
continue
for key in ("images", "gifs", "videos"):
for item in node_out.get(key) or []:
filename = item.get("filename", "")
for key in ("images", "gifs", "videos", "audio", "audios", "files"):
items = node_out.get(key) or []
if not isinstance(items, list):
continue
for item in items:
if not isinstance(item, dict) or not item.get("filename"):
continue
filename = item["filename"]
outputs.append(
{
"filename": filename,
@@ -0,0 +1,504 @@
"""Source-tracked, disabled-by-default example library with reversible batches."""
from __future__ import annotations
import hashlib
import json
import sqlite3
import uuid
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from calliope.comfyui.lint import workflow_warnings
from calliope.comfyui.output_map import auto_output_map, needs_user_output_map
from calliope.comfyui.parser import classify_usage, parse_dynamic_inputs, parse_dynamic_outputs
from calliope.comfyui.preflight import check_workflow_against
from calliope.comfyui.source_reviews import review_for
from calliope.comfyui.ui2api import convert
LIBRARY_COLUMNS = {
"source_repo_path": "TEXT",
"source_sha": "TEXT",
"source_commit": "TEXT",
"batch_id": "TEXT",
"raw_source_json": "TEXT",
"doc_json": "TEXT",
"import_state": "TEXT",
"import_issues": "TEXT",
"imported_at": "TEXT",
"doc_completed_at": "TEXT",
"exec_verified_at": "TEXT",
"library_fingerprint": "TEXT",
}
def migrate_library(conn: sqlite3.Connection) -> None:
"""Rebuild only the legacy kind constraint; preserve columns, indexes and FKs."""
sql = conn.execute("SELECT sql FROM sqlite_master WHERE name='workflows'").fetchone()[0]
old = "CHECK(kind IN ('image', 'video'))"
if old in sql:
conn.commit()
foreign_keys = conn.execute("PRAGMA foreign_keys").fetchone()[0]
conn.execute("PRAGMA foreign_keys=OFF")
try:
conn.execute("BEGIN")
has_sequence = conn.execute(
"SELECT 1 FROM sqlite_master WHERE name='sqlite_sequence'"
).fetchone()
sequence = (
conn.execute("SELECT seq FROM sqlite_sequence WHERE name='workflows'").fetchone()
if has_sequence
else None
)
objects = conn.execute(
"SELECT sql FROM sqlite_master WHERE tbl_name='workflows' "
"AND type IN ('index','trigger') AND sql IS NOT NULL"
).fetchall()
conn.execute(
sql.replace(
"CREATE TABLE workflows", "CREATE TABLE workflows_library_new", 1
).replace(old, "CHECK(kind IN ('image', 'video', 'audio', 'file', 'unknown'))")
)
columns = ",".join(
'"' + r[1] + '"' for r in conn.execute("PRAGMA table_info(workflows)")
)
conn.execute(
f"INSERT INTO workflows_library_new ({columns}) SELECT {columns} FROM workflows"
)
conn.execute("DROP TABLE workflows")
conn.execute("ALTER TABLE workflows_library_new RENAME TO workflows")
if sequence is not None:
conn.execute(
"UPDATE sqlite_sequence SET seq=MAX(seq,?) WHERE name='workflows'",
(sequence[0],),
)
for obj in objects:
conn.execute(obj[0])
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.execute(f"PRAGMA foreign_keys={foreign_keys}")
existing = {r[1] for r in conn.execute("PRAGMA table_info(workflows)")}
for name, kind in LIBRARY_COLUMNS.items():
if name not in existing:
conn.execute(f"ALTER TABLE workflows ADD COLUMN {name} {kind}")
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_source ON workflows(source_repo_path) "
"WHERE source_repo_path IS NOT NULL"
)
conn.execute(
"CREATE TABLE IF NOT EXISTS workflow_revisions ("
"id INTEGER PRIMARY KEY, batch_id TEXT NOT NULL, workflow_id INTEGER NOT NULL, "
"before_json TEXT, after_fingerprint TEXT NOT NULL, rolled_back INTEGER DEFAULT 0)"
)
def _json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True)
def fingerprint(row: dict[str, Any]) -> str:
# Include enable/map/description edits so a later import cannot discard user work.
data = {k: v for k, v in row.items() if k not in ("id", "created_at", "library_fingerprint")}
return hashlib.sha256(_json(data).encode()).hexdigest()
def load_snapshot(path: Path | None) -> dict[str, Any] | None:
if path is None:
return None
value = json.loads(path.read_text())
if (
value.get("status") != "captured"
or not value.get("source_url", "").endswith("/object_info")
or not value.get("captured_at")
or not isinstance(value.get("object_info"), dict)
):
raise ValueError("需要含来源、时间和 object_info 的成功快照;连接失败记录不可用于转换")
return value
def build_document(
path: str,
source: Any,
prompt: dict | None,
snapshot: dict | None,
issues: list[dict],
review: dict | None = None,
) -> dict:
"""Evidence draft: keep UI values unnamed, never invent units or parameter ranges."""
info = (snapshot or {}).get("object_info", {})
params, inventory, notes, links = [], [], [], []
def inspect(graph: dict, scope: str = "") -> None:
is_ui = isinstance(graph.get("nodes"), list)
nodes = (
graph["nodes"]
if is_ui
else [
dict(v, id=k, type=v.get("class_type"))
for k, v in graph.items()
if isinstance(v, dict) and "class_type" in v
]
)
for node in nodes:
nid = scope + str(node.get("id", ""))
ct = node.get("type", "")
values = node.get("widgets_values", []) if is_ui else node.get("inputs", {})
inventory.append(
{
"node_id": nid,
"class_type": ct,
"title": node.get("title") or (node.get("_meta") or {}).get("title"),
"mode": node.get("mode", 0),
"source_values": values,
"inputs": node.get("inputs", []) if is_ui else values,
}
)
if ct in ("Note", "MarkdownNote"):
notes.append({"node_id": nid, "content": values})
# Only API/named fields have a defensible node+field association.
if isinstance(values, dict):
declared = {
**info.get(ct, {}).get("input", {}).get("required", {}),
**info.get(ct, {}).get("input", {}).get("optional", {}),
}
for name, value in values.items():
if is_ui and name == "videopreview":
# Editor preview metadata is preserved in source_nodes only.
continue
if not is_ui and isinstance(value, list) and len(value) == 2:
links.append(
{
"scope": scope,
"node_id": nid,
"field": name,
"upstream_node_id": str(value[0]),
"output_slot": value[1],
}
)
continue
spec = declared.get(name, [])
cfg = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
params.append(
{
"name": name,
"node_id": nid,
"field": name,
"type": spec[0]
if spec and isinstance(spec[0], str)
else type(value).__name__,
"default": value,
"unit": None,
"min": cfg.get("min"),
"max": cfg.get("max"),
"options": spec[0] if spec and isinstance(spec[0], list) else None,
"range_source": "object_info" if spec else "unverified",
"default_source": "source_file",
"value_origin": "ui_named_widget" if is_ui else "api_input",
"effect": cfg.get("tooltip") or "待节点定义及人工核对",
"advanced": False,
}
)
links.extend({"scope": scope, "link": link} for link in graph.get("links", []))
for sub in (graph.get("definitions") or {}).get("subgraphs", []):
inspect(sub, scope + str(sub.get("id", "?")) + "/")
if isinstance(source, dict):
inspect(source)
model_exts = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".onnx")
models = set()
def strings(value: Any) -> None:
if isinstance(value, str) and value.lower().endswith(model_exts):
models.add(value)
elif isinstance(value, (list, dict)):
for v in value.values() if isinstance(value, dict) else value:
strings(v)
# Editor metadata and note download URLs are not active model selections.
for node in inventory:
if node["class_type"] not in ("Note", "MarkdownNote"):
strings(node["source_values"])
inputs = parse_dynamic_inputs(prompt or {})
outputs = parse_dynamic_outputs(prompt or {})
document = {
"title": Path(path).stem,
"purpose": f"源目录:{Path(path).parent};工作流:{Path(path).stem}。用途待逐节点复核。",
"draft": True,
"source_path": path,
"materials": [
{"slot": i.get("nodeId"), "desc": i.get("label"), "required": i.get("required", False)}
for i in inputs
if i.get("kind") in ("image", "video", "audio", "image_list")
],
"dependencies": {
"models": sorted(models),
"node_types": sorted({n["class_type"] for n in inventory}),
"node_packs": [],
"missing": [],
"check_state": "unchecked",
"source": (snapshot or {}).get("source_url"),
"checked_at": None,
"model_locations": "待目标机提供",
},
"params": params,
"source_nodes": inventory,
"source_links": links,
"source_notes": notes,
"steps": [
f"打开源工作流 {path},逐项核对下列源节点与注释。",
"补齐节点定义、模型来源及所需素材;完成输入输出映射核对。",
"明确触发真实测试后核对产物;本批未提交生成任务。",
],
"io": {"inputs": inputs, "outputs": outputs},
"errors": [
{"symptom": i["message"], "fix": "按具体节点补材料或提供原 ComfyUI API 导出。"}
for i in issues
],
"status": {"imported": True, "doc_done": False, "exec_verified": False},
}
if review:
document["purpose"] = review["purpose"]
document["steps"].insert(0, review["instruction"])
if review.get("semantic_review"):
document["steps"].insert(1, review["semantic_review"]["operation_check"])
if review.get("fourth_review"):
document["steps"].insert(1, review["fourth_review"]["operation_check"])
document["source_review"] = {
**review,
"scope": review.get("scope", ["结构及用途局部审校"]),
"complete": False,
"unconfirmed": [
"逐参数语义、单位、范围和调整效果未定稿",
*(
["UI 位置控件未具名,含子图和旁路的实际执行路径待黄金导出"]
if isinstance(source, dict) and "nodes" in source
else []
),
"模型/节点包位置、素材可用性及实际输出未在目标环境核对",
],
"evidence": {
"node_count": len(inventory),
"link_count": len(links),
"named_value_count": len(params),
"note_node_ids": [n["node_id"] for n in notes],
"model_references": sorted(models),
"unnamed_widget_node_ids": [
n["node_id"]
for n in inventory
if n["class_type"] not in ("Note", "MarkdownNote")
and isinstance(n["source_values"], list)
and n["source_values"]
],
},
}
return document
def import_library(
conn: sqlite3.Connection, root: Path, *, source_commit: str, snapshot: dict | None = None
) -> dict:
"""Caller supplies a migrated local DB. Single transaction; no network or jobs."""
batch = str(uuid.uuid4())
report: dict[str, Any] = {"batch_id": batch, "source_commit": source_commit, "files": []}
info = (snapshot or {}).get("object_info")
conn.row_factory = sqlite3.Row
with conn:
conn.execute("BEGIN IMMEDIATE")
for path in sorted(p for p in root.rglob("*") if p.is_file()):
relative = root.name + "/" + path.relative_to(root).as_posix()
raw = path.read_bytes()
sha = hashlib.sha1(raw).hexdigest()
review = review_for(relative, sha)
review_sha = hashlib.sha256(_json(review).encode()).hexdigest()
existing = conn.execute(
"SELECT * FROM workflows WHERE source_repo_path=?", (relative,)
).fetchone()
record = {"path": relative, "source_sha": sha}
if existing and fingerprint(dict(existing)) != existing["library_fingerprint"]:
report["files"].append(
{
**record,
"result": "conflict",
"workflow_id": existing["id"],
"issues": [
{
"code": "user_modified",
"message": "已有条目被用户修改,完整保留;未覆盖",
}
],
}
)
continue
# A fresh snapshot may improve the conversion even when source bytes are unchanged.
snapshot_sha = hashlib.sha256(_json(snapshot).encode()).hexdigest()
if (
existing
and existing["source_sha"] == sha
and existing["import_state"] != "rolled_back"
):
old_doc = json.loads(existing["doc_json"])
if (
old_doc.get("snapshot_sha") == snapshot_sha
and old_doc.get("source_review_sha") == review_sha
):
report["files"].append(
{
**record,
"result": "skipped",
"workflow_id": existing["id"],
"state": existing["import_state"],
"issues": json.loads(existing["import_issues"]),
}
)
continue
source = None
try:
text = raw.decode("utf-8-sig")
source = json.loads(text)
result = convert(source, info)
prompt, issues = result.prompt, result.issues
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
text = raw.hex()
prompt, issues = (
None,
[{"code": "invalid_source", "message": f"源文件无法读取为 JSON:{exc}"}],
)
if prompt:
issues.extend(i for i in workflow_warnings(prompt) if i["level"] == "error")
findings = check_workflow_against(info, prompt) if info else []
issues.extend(findings)
if not info:
issues.append(
{"code": "environment_unchecked", "message": "目标机节点和模型尚未核对"}
)
if needs_user_output_map(prompt) or not auto_output_map(prompt):
issues.append(
{"code": "output_unconfirmed", "message": "输出节点或类型待人工确认"}
)
state = (
"needs_conversion"
if prompt is None
else "pending_check"
if issues
else "pending_test"
)
doc = build_document(relative, source, prompt, snapshot, issues, review)
doc["snapshot_sha"] = snapshot_sha
doc["source_review_sha"] = review_sha
judgment = classify_usage(prompt or {})
outputs = auto_output_map(prompt or {})
kinds = {o["kind"] for o in outputs}
kind = next(iter(kinds)) if len(kinds) == 1 else "unknown"
values = {
"name": path.stem,
"kind": kind,
"workflow_json": _json(prompt or {}),
"input_schema": _json(parse_dynamic_inputs(prompt or {})),
"output_schema": _json(parse_dynamic_outputs(prompt or {})),
"output_map": _json(outputs),
"output_map_source": "auto" if outputs else None,
"usage": judgment["usage"],
"usage_source": "auto",
"is_enabled": 0,
"source_repo_path": relative,
"source_sha": sha,
"source_commit": source_commit,
"batch_id": batch,
"raw_source_json": text,
"doc_json": _json(doc),
"import_state": state,
"import_issues": _json(issues),
"imported_at": datetime.now(timezone.utc).isoformat(),
"doc_completed_at": None,
"exec_verified_at": None,
}
before = dict(existing) if existing else None
if existing:
wid = existing["id"]
conn.execute(
"UPDATE workflows SET " + ",".join(f"{k}=?" for k in values) + " WHERE id=?",
[*values.values(), wid],
)
else:
cur = conn.execute(
"INSERT INTO workflows ("
+ ",".join(values)
+ ") VALUES ("
+ ",".join("?" for _ in values)
+ ")",
list(values.values()),
)
wid = cur.lastrowid
row = dict(conn.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone())
digest = fingerprint(row)
conn.execute("UPDATE workflows SET library_fingerprint=? WHERE id=?", (digest, wid))
conn.execute(
"INSERT INTO workflow_revisions "
"(batch_id,workflow_id,before_json,after_fingerprint) "
"VALUES (?,?,?,?)",
(batch, wid, _json(before) if before else None, digest),
)
report["files"].append(
{
**record,
"result": "updated" if existing else "imported",
"workflow_id": wid,
"state": state,
"issues": issues,
}
)
report["counts"] = dict(Counter(r["result"] for r in report["files"]))
report["states"] = dict(Counter(r.get("state", "conflict") for r in report["files"]))
report["total"] = len(report["files"])
report["doc_completed"] = 0
report["exec_verified"] = 0
return report
def rollback_batch(conn: sqlite3.Connection, batch_id: str) -> dict:
"""Restore previous snapshots. Refuse user edits and referenced new rows."""
conn.row_factory = sqlite3.Row
results = []
with conn:
conn.execute("BEGIN IMMEDIATE")
revisions = conn.execute(
"SELECT * FROM workflow_revisions WHERE batch_id=? AND rolled_back=0 ORDER BY id DESC",
(batch_id,),
).fetchall()
for revision in revisions:
wid = revision["workflow_id"]
row = conn.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone()
if row is None or fingerprint(dict(row)) != revision["after_fingerprint"]:
results.append({"workflow_id": wid, "result": "conflict"})
continue
if revision["before_json"]:
before = json.loads(revision["before_json"])
conn.execute(
"UPDATE workflows SET " + ",".join(f"{k}=?" for k in before) + " WHERE id=?",
[*before.values(), wid],
)
else:
# Deleting a new row could detach jobs/canvas references. Keep it disabled instead.
conn.execute(
"UPDATE workflows SET is_enabled=0, import_state=? WHERE id=?",
("rolled_back", wid),
)
new = dict(conn.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone())
conn.execute(
"UPDATE workflows SET library_fingerprint=? WHERE id=?", (fingerprint(new), wid)
)
conn.execute(
"UPDATE workflow_revisions SET rolled_back=1 WHERE id=?", (revision["id"],)
)
results.append(
{
"workflow_id": wid,
"result": "restored" if revision["before_json"] else "disabled",
}
)
return {"batch_id": batch_id, "files": results}
@@ -0,0 +1,99 @@
"""Explicit local library import/rollback and read-only object_info snapshot CLI."""
from __future__ import annotations
import argparse
import asyncio
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
import httpx
from calliope.comfyui.library import import_library, load_snapshot, rollback_batch
from calliope.db import get_db, migrate_db
def write(path: Path, data: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
snap = commands.add_parser("snapshot")
snap.add_argument("--url", required=True)
snap.add_argument("--output", type=Path, required=True)
for command in ("import", "rollback"):
sub = commands.add_parser(command)
sub.add_argument(
"--db", type=Path, required=True, help="Explicit local database; never a remote service"
)
sub.add_argument("--output-dir", type=Path, required=True)
if command == "import":
sub.add_argument("--root", type=Path, required=True)
sub.add_argument("--source-commit", required=True)
sub.add_argument("--snapshot", type=Path)
else:
sub.add_argument("--batch-id", required=True)
args = parser.parse_args()
if args.command == "snapshot":
data = {
"source_url": args.url.rstrip("/") + "/object_info",
"captured_at": datetime.now(timezone.utc).isoformat(),
}
try:
response = httpx.get(data["source_url"], timeout=10, trust_env=False)
response.raise_for_status()
info = response.json()
if not isinstance(info, dict) or not info:
raise ValueError("object_info must be a nonempty object")
data.update(status="captured", object_info=info)
except (httpx.HTTPError, ValueError) as exc:
data.update(status="unavailable", error=f"{type(exc).__name__}: {exc}")
write(args.output, data)
print(data["status"])
if data["status"] != "captured":
raise SystemExit(1)
return
if args.command == "import" and not args.root.is_dir():
parser.error("--root 必须为存在的目录,避免误报空库成功")
snapshot = load_snapshot(args.snapshot) if args.command == "import" else None
asyncio.run(migrate_db(args.db))
conn = get_db(args.db)
try:
if args.command == "import":
report = import_library(
conn, args.root, source_commit=args.source_commit, snapshot=snapshot
)
documents = []
for row in conn.execute(
"SELECT source_repo_path, source_sha, import_state, doc_json "
"FROM workflows WHERE source_repo_path IS NOT NULL ORDER BY source_repo_path"
):
doc = json.loads(row["doc_json"])
key = hashlib.sha256(row["source_repo_path"].encode()).hexdigest()[:16]
write(args.output_dir / "documents" / (key + ".json"), doc)
documents.append(
{
"path": row["source_repo_path"],
"source_sha": row["source_sha"],
"state": row["import_state"],
"doc_done": bool(row["doc_json"] and doc["status"]["doc_done"]),
"source_review_scope": (doc.get("source_review") or {}).get("scope", []),
"document": "documents/" + key + ".json",
}
)
write(args.output_dir / "manifest.json", documents)
else:
report = rollback_batch(conn, args.batch_id)
write(args.output_dir / (args.command + "_report.json"), report)
print(json.dumps({k: v for k, v in report.items() if k != "files"}, ensure_ascii=False))
finally:
conn.close()
if __name__ == "__main__":
main()
@@ -91,7 +91,7 @@ def workflow_warnings(workflow: dict[str, Any]) -> list[dict[str, str]]:
from calliope.comfyui.parser import detect_output_nodes
outputs = detect_output_nodes(workflow)
if not any(o.get("kind") in ("image", "video") for o in outputs):
if not any(o.get("kind") in ("image", "video", "audio", "file") for o in outputs):
unknown = [o for o in outputs if o.get("evidence") == "unknown_node"]
if not unknown:
found.append(
@@ -19,13 +19,12 @@ from typing import Any
from calliope.comfyui.parser import parse_dynamic_outputs
from calliope.comfyui.registry import (
IMAGE_OUTPUT_CLASSES,
NUMBER_CLASSES,
OUTPUT_CLASSES,
TEXT_AREA_CLASSES,
VIDEO_OUTPUT_CLASSES,
)
VALID_OUTPUT_KINDS = ("image", "video")
VALID_OUTPUT_KINDS = ("image", "video", "audio", "file")
def _wiring_class(class_type: str) -> bool:
@@ -91,10 +90,10 @@ def validate_output_map(
ct = str(node.get("class_type") or "").strip()
if not ct:
problems.append(f"节点 {nid} 缺少节点类型,不能作为输出节点")
elif _wiring_class(ct) and ct not in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
elif _wiring_class(ct) and ct not in OUTPUT_CLASSES:
problems.append(f"节点 {nid}({ct})是参数/连线节点,不能作为输出节点")
if entry.get("kind") not in VALID_OUTPUT_KINDS:
problems.append(f"输出节点 {nid} 的媒体类型必须是图片或视频")
problems.append(f"输出节点 {nid} 的媒体类型必须是图片、视频、音频或文件")
if nid in seen:
problems.append(f"输出节点 {nid} 重复指定")
seen.add(nid)
+14 -14
View File
@@ -10,13 +10,12 @@ from calliope.comfyui.patcher import FIELD_KEY_SEP, resolve_patch_field
from calliope.comfyui.registry import (
AUDIO_CLASSES,
IMAGE_CLASSES,
IMAGE_OUTPUT_CLASSES,
IMAGE_URL_CLASSES,
NUMBER_CLASSES,
OPTIONAL_FRAME_INPUTS,
OUTPUT_CLASSES,
TEXT_AREA_CLASSES,
VIDEO_CLASSES,
VIDEO_OUTPUT_CLASSES,
ComfyOutputKind,
class_to_input_kind,
class_to_output_kind,
@@ -791,10 +790,8 @@ def parse_dynamic_outputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
continue
out_kind: ComfyOutputKind = class_to_output_kind(node.get("class_type", ""))
canon = normalize_output_role(role)
if canon == "video":
out_kind = "video"
elif canon == "image":
out_kind = "image"
if canon in ("image", "video", "audio", "file"):
out_kind = canon
results.append(
{
"nodeId": str(node_id),
@@ -944,13 +941,13 @@ def detect_output_nodes(workflow: dict[str, Any]) -> list[dict[str, Any]]:
# 1) Trusted registry outputs at sink positions.
for nid, node in _sinks(workflow, consumers):
ct = str(node.get("class_type") or "")
if ct in IMAGE_OUTPUT_CLASSES or ct in VIDEO_OUTPUT_CLASSES:
if ct in OUTPUT_CLASSES:
registry_outputs.append(
{
"nodeId": nid,
"label": _title_of(node) or ct,
"role": None,
"kind": "video" if ct in VIDEO_OUTPUT_CLASSES else "image",
"kind": class_to_output_kind(ct),
"class_type": ct,
"evidence": "registry",
}
@@ -963,14 +960,14 @@ def detect_output_nodes(workflow: dict[str, Any]) -> list[dict[str, Any]]:
key=lambda kv: _node_sort_key(kv[0]),
):
ct = str(node.get("class_type") or "")
if nid in seen or ct not in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
if nid in seen or ct not in OUTPUT_CLASSES:
continue
registry_outputs.append(
{
"nodeId": nid,
"label": _title_of(node) or ct,
"role": None,
"kind": "video" if ct in VIDEO_OUTPUT_CLASSES else "image",
"kind": class_to_output_kind(ct),
"class_type": ct,
"evidence": "registry",
}
@@ -983,7 +980,7 @@ def detect_output_nodes(workflow: dict[str, Any]) -> list[dict[str, Any]]:
has_trusted = bool(registry_outputs)
for nid, node in _sinks(workflow, consumers):
ct = str(node.get("class_type") or "")
if nid in seen or _is_wiring(ct) or ct in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
if nid in seen or _is_wiring(ct) or ct in OUTPUT_CLASSES:
continue
if _unknown_sink_looks_like_save(node, workflow) or not has_trusted:
candidates.append(_unknown_output_entry(nid, node, workflow, videoish))
@@ -998,7 +995,7 @@ def detect_output_nodes(workflow: dict[str, Any]) -> list[dict[str, Any]]:
key=lambda kv: _node_sort_key(kv[0]),
):
ct = str(node.get("class_type") or "")
if nid in seen or _is_wiring(ct) or ct in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
if nid in seen or _is_wiring(ct) or ct in OUTPUT_CLASSES:
continue
if _node_has_save_widget(node):
candidates.append(_unknown_output_entry(nid, node, workflow, videoish))
@@ -1045,7 +1042,7 @@ def classify_usage(workflow: dict[str, Any]) -> dict[str, Any]:
"""
inputs = parse_dynamic_inputs(workflow) if isinstance(workflow, dict) else []
outputs = parse_dynamic_outputs(workflow)
media_outputs = [o for o in outputs if o.get("kind") in ("image", "video")]
media_outputs = [o for o in outputs if o.get("kind") in ("image", "video", "audio", "file")]
unknown_outputs = [o for o in outputs if o.get("evidence") == "unknown_node"]
out_kinds = sorted({o["kind"] for o in media_outputs})
@@ -1079,7 +1076,7 @@ def classify_usage(workflow: dict[str, Any]) -> dict[str, Any]:
summary: list[str] = []
for o in media_outputs:
zh = "图片" if o["kind"] == "image" else "视频"
zh = {"image": "图片", "video": "视频", "audio": "音频", "file": "文件"}[o["kind"]]
summary.append(f"输出:节点 {o['nodeId']}({o.get('class_type') or o['label']})→ {zh}")
if unknown_outputs:
names = "、".join(
@@ -1118,6 +1115,9 @@ def classify_usage(workflow: dict[str, Any]) -> dict[str, Any]:
elif len(out_kinds) == 1:
if out_kinds[0] == "image":
usage = "image2image" if bulk else "text2image"
elif out_kinds[0] in ("audio", "file"):
ambiguous = True
problem = "音频/文件输出已识别,请人工确认语音、音乐、转写或工具用途"
elif has_ref_group or len(bulk) >= 2:
usage = "ref2video"
elif has_frame and not bulk:
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Literal
ComfyInputKind = Literal["text", "textarea", "number", "image", "image_url", "audio", "video"]
ComfyOutputKind = Literal["image", "video", "other"]
ComfyOutputKind = Literal["image", "video", "audio", "file", "other"]
PatchField = Literal["image", "url", "audio", "video", "file", "text", "value", "int", "float"]
TEXT_AREA_CLASSES = frozenset(
@@ -33,6 +33,9 @@ IMAGE_OUTPUT_CLASSES = frozenset(
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket",
"SaveImageAdvanced"}
)
AUDIO_OUTPUT_CLASSES = frozenset({"SaveAudio", "SaveAudioMP3", "SaveAudioOpus", "PreviewAudio"})
OUTPUT_CLASSES = IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES | AUDIO_OUTPUT_CLASSES
# 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
@@ -110,6 +113,8 @@ def class_to_patch_field(class_type: str) -> PatchField:
def class_to_output_kind(class_type: str) -> ComfyOutputKind:
if class_type in AUDIO_OUTPUT_CLASSES:
return "audio"
if class_type in VIDEO_OUTPUT_CLASSES or "video" in class_type.lower():
return "video"
if class_type in IMAGE_OUTPUT_CLASSES or "image" in class_type.lower():
@@ -28,6 +28,8 @@ INPUT_ROLE_ALIASES: dict[str, frozenset[str]] = {
OUTPUT_ROLE_ALIASES: dict[str, frozenset[str]] = {
"image": frozenset({"image", "img"}),
"video": frozenset({"video", "vid"}),
"audio": frozenset({"audio", "sound"}),
"file": frozenset({"file", "subtitle", "srt"}),
}
# The five workflow usages the studio distinguishes (HEL-636). Derived from
@@ -40,6 +42,7 @@ USAGE_VALUES: tuple[str, ...] = (
"text2video",
"image2video",
"ref2video",
"text2speech", "speech2text", "text2music", "utility", "audio2video", "pose2video",
)
@@ -47,7 +50,11 @@ def usage_to_kind(usage: str | None) -> str | None:
"""Output media kind of a usage (image / video); None when unknown."""
if usage in ("text2image", "image2image"):
return "image"
if usage in ("text2video", "image2video", "ref2video"):
if usage in ("text2speech", "text2music"):
return "audio"
if usage == "speech2text":
return "file"
if usage in ("text2video", "image2video", "ref2video", "audio2video", "pose2video"):
return "video"
return None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
"""Curated source-only notes, bound to exact source bytes; never execution evidence."""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
@lru_cache(maxsize=1)
def _reviews() -> dict:
return json.loads(Path(__file__).with_name("source_reviews.json").read_text(encoding="utf-8"))
def review_for(path: str, source_sha: str) -> dict | None:
review = _reviews().get(path)
return review if review and review["source_sha"] == source_sha else None
@@ -0,0 +1,139 @@
"""Conservative offline conversion; partial or ambiguous prompts are never returned.
object_info does not define custom frontend widget serialization. Only named
widgets pass. Even input_order cannot prove arbitrary frontend serialization.
Subgraphs, virtual nodes, seed extra controls, mute and bypass need API exports.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class Conversion:
prompt: dict[str, Any] | None
issues: list[dict[str, Any]] = field(default_factory=list)
def convert(source: Any, object_info: dict[str, Any] | None = None) -> Conversion:
issues: list[dict[str, Any]] = []
def fail(code: str, message: str, node_id: Any = "") -> None:
issues.append({"code": code, "message": message, "node_id": str(node_id)})
if not isinstance(source, dict):
return Conversion(None, [{"code": "invalid_format", "message": "源文件不是 JSON 对象"}])
if "nodes" not in source:
if source and all(
isinstance(n, dict)
and isinstance(n.get("class_type"), str)
and isinstance(n.get("inputs"), dict)
for n in source.values()
):
for nid, node in source.items():
for name, value in node["inputs"].items():
if isinstance(value, list):
if (
len(value) != 2
or not isinstance(value[0], str)
or value[0] not in source
or type(value[1]) is not int
or value[1] < 0
):
fail("invalid_api_link", f"输入 {name} 的 API 连线无法核对", nid)
if issues:
return Conversion(None, issues)
return Conversion(source)
return Conversion(
None, [{"code": "invalid_format", "message": "无法识别 API 或 UI 图格式"}]
)
if not isinstance(source["nodes"], list):
return Conversion(None, [{"code": "invalid_format", "message": "nodes 必须为数组"}])
if (source.get("definitions") or {}).get("subgraphs"):
fail("subgraph_requires_export", "包含子图:需原 ComfyUI API 导出及差分核对")
info = object_info or {}
nodes = {str(n["id"]): n for n in source["nodes"] if isinstance(n, dict) and "id" in n}
if len(nodes) != len(source["nodes"]):
fail("invalid_nodes", "节点 ID 缺失或重复")
links = {}
for link in source.get("links") or []:
if not isinstance(link, list) or len(link) < 6:
fail("unsupported_links", "连线格式需原 ComfyUI API 导出核对")
else:
if str(link[0]) in links:
fail("duplicate_link", "连线 ID 重复")
links[str(link[0])] = link
prompt = {}
for nid, node in nodes.items():
ct = node.get("type", "")
if ct in ("Note", "MarkdownNote"):
continue
if node.get("mode", 0) != 0:
fail("mode_requires_export", f"{ct} 包含静音/旁路状态,需 API 导出核对", nid)
continue
spec = info.get(ct)
if not isinstance(spec, dict):
fail("node_definition_unavailable", f"待提供目标机节点定义:{ct}", nid)
continue
declared = {
**spec.get("input", {}).get("required", {}),
**spec.get("input", {}).get("optional", {}),
}
sockets = {s["name"]: s for s in node.get("inputs", []) if "name" in s}
widgets = node.get("widgets_values") or []
inputs = {}
if isinstance(widgets, dict):
if set(widgets) - set(declared):
fail("unknown_widget", f"{ct} 存在节点定义以外的具名控件", nid)
inputs.update({k: v for k, v in widgets.items() if k in declared})
elif widgets:
fail(
"widget_layout_unverified",
f"{ct} 控件为位置数组;input_order 不证明前端序列化顺序,需 API 导出核对",
nid,
)
continue
for name, socket in sockets.items():
lid = socket.get("link")
if lid is None:
continue
link = links.get(str(lid))
if (
not link
or str(link[3]) != nid
or not isinstance(link[4], int)
or not 0 <= link[4] < len(node.get("inputs", []))
):
fail("invalid_link", f"输入 {name} 的连线无效", nid)
continue
if node["inputs"][link[4]].get("name") != name or name not in declared:
fail("invalid_socket", f"输入 {name} 的目标槽不匹配", nid)
continue
upstream = nodes.get(str(link[1]))
outputs = info.get((upstream or {}).get("type"), {}).get("output", [])
if not upstream or not isinstance(link[2], int) or not 0 <= link[2] < len(outputs):
fail("invalid_output_slot", f"输入 {name} 的来源槽无法核对", nid)
continue
inputs[name] = [str(link[1]), link[2]]
expected = declared[name][0] if declared[name] else None
actual = outputs[link[2]]
# Custom unions/wildcards need frontend evidence; do not guess coercions.
if expected != actual:
fail(
"link_type_unverified",
f"输入 {name} 的槽类型不一致:{actual} → {expected}",
nid,
)
for name in spec.get("input", {}).get("required", {}):
if name not in inputs:
fail("missing_required_input", f"{ct}.{name} 缺少源文件值或连线", nid)
prompt[nid] = {
"class_type": ct,
"inputs": inputs,
"_meta": {"title": node.get("title") or ct},
}
if not prompt:
fail("empty_prompt", "未得到可执行节点")
return Conversion(None if issues else prompt, issues)
+9 -4
View File
@@ -113,7 +113,7 @@ CREATE INDEX IF NOT EXISTS idx_clips_project ON clips(project_id);
CREATE TABLE IF NOT EXISTS workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('image', 'video')),
kind TEXT NOT NULL CHECK(kind IN ('image', 'video', 'audio', 'file', 'unknown')),
workflow_json TEXT NOT NULL,
input_node_map TEXT,
output_node_name TEXT,
@@ -355,7 +355,8 @@ def _backfill_workflow_usage(conn: sqlite3.Connection) -> None:
from calliope.comfyui.parser import classify_usage
rows = conn.execute(
"SELECT id, workflow_json FROM workflows WHERE usage IS NULL OR usage = ''"
"SELECT id, workflow_json FROM workflows WHERE source_repo_path IS NULL"
" AND (usage IS NULL OR usage = '')"
).fetchall()
for row in rows: # plain connect — positional access
try:
@@ -388,7 +389,8 @@ def _backfill_workflow_output_map(conn: sqlite3.Connection) -> None:
rows = conn.execute(
"SELECT id, workflow_json FROM workflows"
" WHERE output_map_source IS NULL OR output_map_source != 'user'"
" WHERE source_repo_path IS NULL"
" AND (output_map_source IS NULL OR output_map_source != 'user')"
).fetchall()
for row in rows:
try:
@@ -423,7 +425,7 @@ _FL2VA_DESCRIPTION = "首帧必填,尾帧可选:只传首帧为图生视频
def _rename_workflows(conn: sqlite3.Connection) -> None:
for wid, name, wf_json in conn.execute(
"SELECT id, name, workflow_json FROM workflows"
"SELECT id, name, workflow_json FROM workflows WHERE source_repo_path IS NULL"
).fetchall():
if not name or "尾帧" in name or not _FIRST_FRAME_NAME_RE.search(name):
continue
@@ -452,6 +454,9 @@ async def migrate_db(db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.executescript(SCHEMA)
from calliope.comfyui.library import migrate_library
migrate_library(conn)
# Additive migrations for existing DBs
# Dedupe guard: ARTIFACT nodes keyed by (canvas_id, job_id). Without the
# unique index, the agent's post_artifact_to_canvas and the frontend
@@ -204,12 +204,13 @@ WorkflowUsageKind = Literal[
"text2video",
"image2video",
"ref2video",
"text2speech", "speech2text", "text2music", "utility", "audio2video", "pose2video",
]
class WorkflowCreate(BaseModel):
name: str = Field(..., min_length=1)
kind: Literal["image", "video"] | None = None
kind: Literal["image", "video", "audio", "file"] | None = None
workflow_json: dict[str, Any]
description: str | None = None
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
@@ -231,7 +232,7 @@ class WorkflowCreate(BaseModel):
class WorkflowUpdate(BaseModel):
name: str | None = None
kind: Literal["image", "video"] | None = None
kind: Literal["image", "video", "audio", "file"] | None = None
description: str | None = None
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
is_enabled: bool | None = None
@@ -224,6 +224,10 @@ class QueueWorker:
raise RuntimeError(
"找不到该任务使用的工作流(可能已被删除),请重新选择工作流后再生成"
)
if workflow_row.get("import_state") in (
"needs_conversion", "pending_check", "rolled_back"
):
raise RuntimeError("工作流库条目尚未完成转换或依赖核对,禁止提交生成任务")
workflow = workflow_row["workflow_json"]
broken = blocking_problem(workflow)
if broken:
@@ -279,7 +283,8 @@ class QueueWorker:
if not outputs_meta:
checked = "、".join(sorted(node_kinds))
raise RuntimeError(
f"ComfyUI 显示任务已结束(任务 ID {prompt_id}),但没有返回任何图片或视频,"
f"ComfyUI 显示任务已结束(任务 ID {prompt_id}),"
"但没有返回任何图片、视频、音频或文件,"
"不算生成完成。已按输出映射检查节点:" f"{checked};"
"请到 ComfyUI 历史中核对该任务,并确认所指定的输出节点正确。"
)
@@ -539,7 +544,7 @@ class QueueWorker:
conn = get_db(config.settings.db_path)
try:
row = conn.execute(
"SELECT workflow_json, output_map, output_map_source FROM workflows"
"SELECT workflow_json, output_map, output_map_source, import_state FROM workflows"
" WHERE id = ?",
(workflow_id,),
).fetchone()
@@ -549,6 +554,7 @@ class QueueWorker:
"workflow_json": json.loads(row["workflow_json"]),
"output_map": row["output_map"],
"output_map_source": row["output_map_source"],
"import_state": row["import_state"],
}
finally:
conn.close()
@@ -45,9 +45,15 @@ from calliope.queue.workflow_test import (
router = APIRouter()
def _serialize_workflow(row: Any) -> dict[str, Any]:
def _serialize_workflow(row: Any, *, include_document: bool = True) -> dict[str, Any]:
data = row_to_dict(row)
data["workflow_json"] = json.loads(data["workflow_json"])
if not include_document:
data.pop("doc_json", None)
for field in (("doc_json", "import_issues") if include_document else ("import_issues",)):
data[field] = json.loads(data[field]) if data.get(field) else None
data.pop("raw_source_json", None)
data.pop("library_fingerprint", None)
stored = json.loads(data["input_schema"]) if data.get("input_schema") else []
data["input_schema"] = effective_input_schema(stored, data["workflow_json"])
# Outputs are pure graph derivation (no manual overrides) — always fresh,
@@ -172,6 +178,8 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
else:
output_map, output_map_source = json.dumps(auto_output_map(workflow)), "auto"
is_enabled = data["is_enabled"]
if data.get("import_state") in ("needs_conversion", "pending_check", "rolled_back"):
is_enabled = 0
if output_map_source is None or usage is None:
is_enabled = 0
conn.execute(
@@ -220,7 +228,7 @@ async def list_workflows() -> list[dict[str, Any]]:
conn = get_db(settings.db_path)
try:
rows = conn.execute("SELECT * FROM workflows ORDER BY id DESC").fetchall()
out = [_serialize_workflow(r) for r in rows]
out = [_serialize_workflow(r, include_document=False) for r in rows]
# 上次测试 state is read live from the newest test job per workflow —
# no cached column that could go stale (HEL-643).
latest: dict[int, dict[str, Any]] = {}
@@ -246,12 +254,15 @@ async def check_workflow(workflow_id: int, payload: WorkflowCheckRequest) -> dic
conn = get_db(settings.db_path)
try:
row = conn.execute(
"SELECT workflow_json, endpoint_id FROM workflows WHERE id = ?", (workflow_id,)
"SELECT workflow_json, endpoint_id, import_state FROM workflows WHERE id = ?",
(workflow_id,)
).fetchone()
finally:
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Workflow not found")
if row["import_state"] in ("needs_conversion", "rolled_back"):
raise HTTPException(status_code=400, detail="请先完成转换或恢复库条目,不能核查空图")
endpoint_id = payload.endpoint_id or row["endpoint_id"]
return await _connection_check(endpoint_id, json.loads(row["workflow_json"]), payload.refresh)
@@ -266,7 +277,8 @@ async def dedupe_workflows() -> dict[str, Any]:
conn = get_db(settings.db_path)
try:
rows = conn.execute(
"SELECT id, name, kind, workflow_json FROM workflows ORDER BY id"
"SELECT id, name, kind, workflow_json FROM workflows"
" WHERE source_repo_path IS NULL ORDER BY id"
).fetchall()
seen: dict[tuple[str, str, str], int] = {}
drop_ids: list[int] = []
@@ -303,9 +315,9 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
out_kinds = {
o.get("kind")
for o in parse_dynamic_outputs(payload.workflow_json)
if o.get("kind") in ("image", "video")
if o.get("kind") in ("image", "video", "audio", "file")
}
kind = "video" if out_kinds == {"video"} else "image"
kind = next(iter(out_kinds)) if len(out_kinds) == 1 else "image"
profile = payload.prompt_profile or detect_prompt_profile(payload.workflow_json)
# HEL-638: an explicit user output map wins; otherwise the trusted auto
# map. Either way a graph with unknown output candidates is unusable
@@ -393,6 +405,12 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
if not existing:
raise HTTPException(status_code=404, detail="Workflow not found")
data = payload.model_dump(exclude_unset=True)
if existing["import_state"] in ("needs_conversion", "pending_check", "rolled_back"):
if data.get("is_enabled"):
raise HTTPException(
status_code=400, detail="该库条目尚未完成转换或依赖核对,不能启用"
)
data["is_enabled"] = 0
if "is_enabled" in data and data["is_enabled"] is not None:
data["is_enabled"] = 1 if data["is_enabled"] else 0
workflow_json = json.loads(existing["workflow_json"])
@@ -486,11 +504,13 @@ async def test_workflow(workflow_id: int, payload: WorkflowTestRequest) -> dict[
try:
wf = conn.execute(
"SELECT id, kind, workflow_json, input_schema, output_map, output_map_source,"
" endpoint_id FROM workflows WHERE id = ?",
" endpoint_id, import_state FROM workflows WHERE id = ?",
(workflow_id,),
).fetchone()
if not wf:
raise HTTPException(status_code=404, detail="Workflow not found")
if wf["import_state"] in ("needs_conversion", "pending_check", "rolled_back"):
raise HTTPException(status_code=400, detail="该库条目尚未完成转换或依赖核对,不能测试")
workflow_json = json.loads(wf["workflow_json"])
broken = blocking_problem(workflow_json)
if broken:
@@ -0,0 +1,128 @@
"Fourth-batch partition, literal source structure and isolated DB refresh."
import copy
import hashlib
import json
from scripts.audit_conversion_prerequisites import cycles, graph_rows, inventory
from scripts.audit_library_documents import audit, audit_fourth
from calliope.comfyui import source_reviews
from calliope.comfyui.library import import_library
from calliope.db import get_db, migrate_db
from tests.test_workflow_library import ROOT
def test_actual_field_partition_preserves_every_prior_review():
repo = ROOT.parent
report = audit(repo, repo / "docs/hel-892/offline")
result = audit_fourth(repo, report, repo / "docs/hel-892/fourth-evidence/field-partition.json")
assert result["classification_counts_new"] == {
"runtime_parameter": 590,
"frontend_state": 6,
"current_value_only": 41,
"semantic_unknown": 48,
}
assert report["unnamed_widget_nodes"] == 3701
assert result["verified_units_ranges_effects"] == 0
def test_conversion_inventory_is_complete_scoped_and_never_readiness_evidence():
repo = ROOT.parent
report = inventory(repo)
assert report["total_ui_files"] == report["remaining_reliable_conversions"] == 146
assert report["exact_golden_candidates"] == 7
expected = json.loads(
(repo / ("docs/hel-892/fourth-evidence/conversion-prerequisites.json")).read_text(
encoding="utf-8"
)
)
assert report == expected
for row in report["files"]:
raw = (repo / row["path"]).read_bytes()
assert row["source_sha"] == hashlib.sha1(raw).hexdigest()
source = json.loads(raw)
graphs = list(graph_rows(source))
assert sum(len(g["nodes"]) for g, _, _ in graphs) == len(row["findings"]["slots"])
for (g, pointer, scope), recorded in zip(graphs, row["graphs"], strict=True):
assert recorded["links"] == g.get("links", [])
assert recorded["scope"] == scope
assert recorded["pointer"] == (pointer or "/")
for records in row["findings"].values():
for ref in records:
if "pointer" not in ref:
continue
value = source
for part in ref["pointer"].strip("/").split("/"):
value = value[int(part)] if isinstance(value, list) else value[part]
if "node_id" in ref and "/nodes/" in ref["pointer"]:
assert ref["node_id"].split("/")[-1] == str(value["id"])
if "link" in ref:
assert ref["link"] == value
assert row["converted_reliably"] is row["executable"] is row["execution_verified"] is False
assert all(r["status"] == "requires_materials_not_implemented" for r in row["rule_review"])
def test_literal_cycles_are_not_assumed_from_node_names():
nodes = [{"id": 1}, {"id": 2}, {"id": 3}]
assert cycles(nodes, [[1, 1, 0, 2, 0, "*"], [2, 2, 0, 3, 0, "*"]]) == []
assert cycles(nodes, [[1, 1, 0, 2, 0, "*"], [2, 2, 0, 1, 0, "*"]]) == [["1", "2"]]
assert cycles(nodes, [{"origin_id": 3, "target_id": 3}]) == [["3"]]
async def test_all_16_changed_reviews_refresh_and_user_row_stays_exact(tmp_path, monkeypatch):
current = source_reviews._reviews()
old = copy.deepcopy(current)
revised = {path for path, r in current.items() if r.get("fourth_review")}
for path in revised:
review = old[path]
keys = {tuple(k) for k in review.pop("fourth_review")["fields"]}
review["reviewed_named_fields"] = [
f for f in review["reviewed_named_fields"] if (f["node_id"], f["field"]) not in keys
]
monkeypatch.setattr(source_reviews, "_reviews", lambda: old)
db = tmp_path / "fourth.db"
await migrate_db(db)
conn = get_db(db)
try:
assert import_library(conn, ROOT, source_commit="fixture")["counts"] == {"imported": 156}
prior = {r["source_repo_path"]: dict(r) for r in conn.execute("SELECT * FROM workflows")}
protected_path = sorted(revised)[0]
conn.execute(
("UPDATE workflows SET description=? WHERE source_repo_path=?"),
("用户修改保留", protected_path),
)
conn.commit()
protected = dict(
conn.execute(
"SELECT * FROM workflows WHERE source_repo_path=?", (protected_path,)
).fetchone()
)
monkeypatch.setattr(source_reviews, "_reviews", lambda: current)
assert import_library(conn, ROOT, source_commit="fixture")["counts"] == {
"updated": 15,
"skipped": 140,
"conflict": 1,
}
assert import_library(conn, ROOT, source_commit="fixture")["counts"] == {
"skipped": 155,
"conflict": 1,
}
for row in conn.execute("SELECT * FROM workflows"):
path = row["source_repo_path"]
if path == protected_path:
assert dict(row) == protected
elif path not in revised:
assert dict(row) == prior[path]
else:
doc = json.loads(row["doc_json"])
assert doc["source_review"]["fourth_review"] == current[path]["fourth_review"]
assert current[path]["fourth_review"]["operation_check"] in doc["steps"]
assert (
doc["source_review"]["reviewed_named_fields"]
== current[path]["reviewed_named_fields"]
)
assert row["is_enabled"] == 0
assert row["doc_completed_at"] is row["exec_verified_at"] is None
finally:
conn.close()
@@ -0,0 +1,139 @@
"""Synthetic tooling tests; none of these objects is a real frontend golden export."""
import copy
import hashlib
import json
import pytest
from scripts.compare_frontend_exports import compare_case, differences
@pytest.fixture
def pair(tmp_path):
source = {
"nodes": [
{"id": "1:2", "type": "Source", "widgets_values": {"text": "中文\n原文"}},
{"id": 3, "type": "Sink", "inputs": [{"name": "value", "link": 9}]},
],
"links": [[9, "1:2", 1, 3, 0, "STRING"]],
}
expected = {
"1:2": {
"class_type": "Source",
"inputs": {"text": "中文\n原文"},
"_meta": {"title": "Source"},
},
"3": {"class_type": "Sink", "inputs": {"value": ["1:2", 1]}, "_meta": {"title": "Sink"}},
}
info = {
"Source": {"input": {"required": {"text": ["STRING", {}]}}, "output": ["IMAGE", "STRING"]},
"Sink": {"input": {"required": {"value": ["STRING", {}]}}, "output": []},
}
raw = json.dumps(source).encode()
(tmp_path / "source.json").write_bytes(raw)
case = {
"id": "fixture",
"source_path": "source.json",
"source_sha": hashlib.sha1(raw).hexdigest(),
"export_file": "01.api.json",
"binding_file": "01.binding.json",
"comparison": "全部字段与连线",
}
def write_export(value):
payload = json.dumps(value).encode()
(tmp_path / case["export_file"]).write_bytes(payload)
binding = {
"source_path": case["source_path"],
"source_sha": case["source_sha"],
"export_sha256": hashlib.sha256(payload).hexdigest(),
"original_frontend_export": True,
"exported_at": "fixture-time",
"frontend_version": "fixture",
"node_versions": "fixture",
"origin": "synthetic tool test; NOT original export",
}
(tmp_path / case["binding_file"]).write_text(json.dumps(binding))
write_export(expected)
return tmp_path, case, expected, {"object_info": info}, write_export
def test_equal_pair_does_not_enable_or_claim_execution(pair):
root, case, expected, snapshot, _ = pair
before = (root / "source.json").read_bytes()
result = compare_case(root, root, case, snapshot)
assert result["status"] == "equal"
assert result["differences"] == [] and result["metadata_differences"] == []
assert {"path": "/3/inputs/value/1", "value": 1} in result["preserved_fields"]
assert not result["executable"] and not result["execution_verified"]
assert (root / "source.json").read_bytes() == before
assert not list(root.glob("*.db"))
def test_missing_exports_and_definitions_fail_closed(pair):
root, case, _, _, _ = pair
(root / case["export_file"]).unlink()
result = compare_case(root, root, case)
assert result["status"] == "missing_materials"
assert result["missing"] == [case["export_file"]]
assert result["node_definitions_available"] is False
def test_export_present_but_conversion_incomplete_is_blocked(pair):
root, case, _, _, _ = pair
result = compare_case(root, root, case)
assert result["status"] == "conversion_blocked"
assert "differences" not in result
assert {i["code"] for i in result["issues"]} == {"node_definition_unavailable", "empty_prompt"}
@pytest.mark.parametrize(
"mutation, expected_path",
[
(lambda g: g["3"]["inputs"].update(value=["1:2", 0]), "/3/inputs/value/1"),
(lambda g: g["1:2"]["inputs"].update(text="改字"), "/1:2/inputs/text"),
(lambda g: g["3"].update(class_type="Other"), "/3/class_type"),
],
)
def test_field_and_nonzero_link_slot_differences_are_exact(pair, mutation, expected_path):
root, case, golden, snapshot, write = pair
mutation(golden)
write(golden)
result = compare_case(root, root, case, snapshot)
assert result["status"] == "different"
assert expected_path in {d["path"] for d in result["differences"]}
def test_meta_is_separate_and_not_silently_ignored(pair):
root, case, golden, snapshot, write = pair
golden["3"]["_meta"]["title"] = "不同标题"
write(golden)
result = compare_case(root, root, case, snapshot)
assert result["status"] == "different"
assert result["differences"] == []
assert result["metadata_differences"][0]["path"] == "/3/title"
def test_changed_source_and_changed_export_cannot_borrow_old_binding(pair):
root, case, _, snapshot, _ = pair
wrong = copy.deepcopy(case)
wrong["source_sha"] = "bad"
assert compare_case(root, root, wrong, snapshot)["status"] == "source_sha_mismatch"
(root / case["export_file"]).write_text("{}")
assert compare_case(root, root, case, snapshot)["status"] == "invalid_binding"
@pytest.mark.parametrize(
"payload", [[], {"nodes": []}, {}, {"1": {"class_type": "Sink", "inputs": {"x": ["2", 0]}}}]
)
def test_wrong_or_broken_export_is_not_compared(pair, payload):
root, case, _, snapshot, write = pair
write(payload)
assert compare_case(root, root, case, snapshot)["status"] == "invalid_export"
def test_comparison_keeps_missing_null_type_list_order_and_json_pointer_escaping():
result = differences({"a/b~c": [1, None], "empty": None}, {"a/b~c": [True]})
assert {r["path"] for r in result} == {"/a~1b~0c/0", "/a~1b~0c/1", "/empty"}
assert result[0]["kind"] == "type"
@@ -314,7 +314,7 @@ def test_empty_outputs_never_count_as_done(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(空结果)", "image", _load(T2I))
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
assert job["status"] == "failed"
assert "没有返回任何图片或视频" in job["error"]
assert "没有返回任何图片、视频、音频或文件" in job["error"]
assert job["comfy_prompt_id"] and job["comfy_prompt_id"] in job["error"]
assert job["output_paths"] == []
@@ -0,0 +1,77 @@
"""Actual catalogue review refresh, user protection and source-evidence audit."""
import copy
import json
from pathlib import Path
from scripts.audit_library_documents import audit, audit_partition
from calliope.comfyui import source_reviews
from calliope.comfyui.library import import_library
from calliope.db import get_db, migrate_db
from tests.test_workflow_library import ROOT
def test_actual_review_partition_and_evidence_match_original_sources():
repo = ROOT.parent
report = audit(repo, repo / "docs/hel-892/offline")
partition = audit_partition(
repo, report, repo / "docs/hel-892/third-evidence/review-partition.json"
)
assert partition["new_named_fields"] == 500
assert partition["new_without_named_fields"] == 114
assert report["named_values_preserved"] == 1227
assert report["selected_named_fields_reviewed"] == 1227
assert report["full_documents_completed"] == report["real_executions"] == 0
async def test_all_140_reviews_refresh_but_user_modified_row_is_preserved(tmp_path, monkeypatch):
current = json.loads(Path(source_reviews.__file__).with_suffix(".json").read_text())
previous = copy.deepcopy(current)
revised = {path for path, review in current.items() if review.get("semantic_review")}
for path in revised:
previous[path] = {
key: previous[path][key] for key in ("source_sha", "purpose", "instruction")
}
monkeypatch.setattr(source_reviews, "_reviews", lambda: previous)
await migrate_db(tmp_path / "catalogue.db")
conn = get_db(tmp_path / "catalogue.db")
try:
assert import_library(conn, ROOT, source_commit="fixture")["counts"] == {"imported": 156}
prior = {
row["source_repo_path"]: dict(row)
for row in conn.execute("SELECT * FROM workflows")
if row["source_repo_path"] not in revised
}
protected_path = sorted(revised)[0]
conn.execute(
"UPDATE workflows SET description=? WHERE source_repo_path=?",
("用户整行版本", protected_path),
)
conn.commit()
protected = dict(
conn.execute(
"SELECT * FROM workflows WHERE source_repo_path=?", (protected_path,)
).fetchone()
)
monkeypatch.setattr(source_reviews, "_reviews", lambda: current)
result = import_library(conn, ROOT, source_commit="fixture")
assert result["counts"] == {"updated": 139, "skipped": 16, "conflict": 1}
assert import_library(conn, ROOT, source_commit="fixture")["counts"] == {
"skipped": 155,
"conflict": 1,
}
for row in conn.execute("SELECT * FROM workflows"):
path = row["source_repo_path"]
if path == protected_path:
assert dict(row) == protected
elif path in prior:
assert dict(row) == prior[path]
else:
doc = json.loads(row["doc_json"])
assert doc["source_review"]["semantic_review"] == current[path]["semantic_review"]
assert current[path]["semantic_review"]["operation_check"] in doc["steps"]
assert row["is_enabled"] == 0
assert row["doc_completed_at"] is None and row["exec_verified_at"] is None
finally:
conn.close()
@@ -0,0 +1,164 @@
"""Source API preservation and synthetic direct-link differential evidence.
The synthetic pair is not a substitute for the seven real frontend exports.
"""
from __future__ import annotations
import copy
import hashlib
import json
import pytest
from calliope.comfyui import source_reviews
from calliope.comfyui.library import import_library
from calliope.comfyui.ui2api import convert
from calliope.db import get_db, migrate_db
from tests.test_workflow_library import ROOT
API_FILES = sorted(ROOT.glob("*_API.json"))
@pytest.mark.parametrize("path", API_FILES, ids=lambda path: path.name)
def test_ten_existing_api_exports_preserve_every_field_and_link(path):
source = json.loads(path.read_text())
before = copy.deepcopy(source)
result = convert(source)
assert result.issues == []
assert result.prompt == before
assert source == before
# Verify references independently of the converter, including colon/dotted IDs.
for node in result.prompt.values():
for value in node["inputs"].values():
if isinstance(value, list):
assert len(value) == 2 and value[0] in result.prompt
assert type(value[1]) is int and value[1] >= 0
def test_named_direct_link_pair_preserves_nonzero_slot_and_named_values():
info = {
"Source": {"input": {"required": {"text": ["STRING", {}]}}, "output": ["IMAGE", "STRING"]},
"Sink": {"input": {"required": {"value": ["STRING", {}]}}, "output": []},
}
ui = {
"nodes": [
{"id": "1:7", "type": "Source", "widgets_values": {"text": "逐字保留\n中文"}},
{"id": 2, "type": "Sink", "inputs": [{"name": "value", "link": 9}]},
{"id": 3, "type": "Note", "widgets_values": ["not executable"]},
],
"links": [[9, "1:7", 1, 2, 0, "STRING"]],
}
before = copy.deepcopy(ui)
expected = {
"1:7": {
"class_type": "Source",
"inputs": {"text": "逐字保留\n中文"},
"_meta": {"title": "Source"},
},
"2": {"class_type": "Sink", "inputs": {"value": ["1:7", 1]}, "_meta": {"title": "Sink"}},
}
assert convert(ui, info).prompt == expected
assert ui == before
ui["links"][0][2] = 0 # valid slot, incompatible IMAGE -> STRING
result = convert(ui, info)
assert result.prompt is None
assert "link_type_unverified" in {i["code"] for i in result.issues}
@pytest.mark.parametrize("value", [["missing", 0], ["1", -1], ["1", True], ["1"], []])
def test_invalid_api_links_fail_closed(value):
source = {"1": {"class_type": "Sink", "inputs": {"value": value}}}
result = convert(source)
assert result.prompt is None
assert result.issues[0]["code"] == "invalid_api_link"
def test_real_ui_sources_stay_closed_without_frontend_evidence():
paths = [p for p in ROOT.rglob("*") if p.is_file() and p not in API_FILES]
assert len(API_FILES) == 10 and len(paths) == 146
for path in paths:
source = json.loads(path.read_text())
result = convert(source)
assert result.prompt is None, path
assert result.issues, path
async def test_review_revision_refreshes_docs_preserving_user_edits_and_sha_binding(
tmp_path,
monkeypatch,
):
root = tmp_path / "examples"
root.mkdir()
source = root / "a.json"
source.write_text(json.dumps({"1": {"class_type": "SaveImage", "inputs": {}}}))
sha = hashlib.sha1(source.read_bytes()).hexdigest()
notes = {}
monkeypatch.setattr(source_reviews, "_reviews", lambda: notes)
await migrate_db(tmp_path / "local.db")
conn = get_db(tmp_path / "local.db")
try:
assert import_library(conn, root, source_commit="fixture")["counts"] == {"imported": 1}
notes["examples/a.json"] = {
"source_sha": sha,
"purpose": "图像保存结构",
"instruction": "核对 SaveImage",
}
assert import_library(conn, root, source_commit="fixture")["counts"] == {"updated": 1}
doc = json.loads(conn.execute("SELECT doc_json FROM workflows").fetchone()[0])
assert doc["purpose"] == "图像保存结构"
assert not doc["source_review"]["complete"]
assert doc["status"] == {"imported": True, "doc_done": False, "exec_verified": False}
assert import_library(conn, root, source_commit="fixture")["counts"] == {"skipped": 1}
# A changed source must not inherit notes for different source bytes.
source.write_text(json.dumps({"2": {"class_type": "SaveAudio", "inputs": {}}}))
assert import_library(conn, root, source_commit="fixture")["counts"] == {"updated": 1}
doc = json.loads(conn.execute("SELECT doc_json FROM workflows").fetchone()[0])
assert "source_review" not in doc
conn.execute("UPDATE workflows SET description='用户版'")
conn.commit()
notes["examples/a.json"]["instruction"] = "changed review"
assert import_library(conn, root, source_commit="fixture")["counts"] == {"conflict": 1}
assert conn.execute("SELECT description FROM workflows").fetchone()[0] == "用户版"
finally:
conn.close()
async def test_reviewed_catalogue_has_real_named_values_and_api_edges(tmp_path):
await migrate_db(tmp_path / "local.db")
conn = get_db(tmp_path / "local.db")
try:
report = import_library(conn, ROOT, source_commit="fixture")
assert report["states"] == {"needs_conversion": 146, "pending_check": 10}
for row in conn.execute("SELECT * FROM workflows"):
doc = json.loads(row["doc_json"])
assert doc["source_review"]["source_sha"] == row["source_sha"]
review = doc["source_review"]
assert review["scope"][0] == "结构及用途局部审校"
if review.get("semantic_review"):
assert review["semantic_review"]["operation_check"] in doc["steps"]
assert len(review["scope"]) == 2
assert not doc["source_review"]["complete"]
assert doc["status"]["doc_done"] is False
source = json.loads(row["raw_source_json"])
if "nodes" not in source:
links = [
(nid, name, value)
for nid, node in source.items()
for name, value in node["inputs"].items()
if isinstance(value, list)
]
assert len(doc["source_links"]) == len(links)
for nid, name, value in links:
assert {
"scope": "",
"node_id": nid,
"field": name,
"upstream_node_id": value[0],
"output_slot": value[1],
} in doc["source_links"]
for param in doc["params"]:
assert param["unit"] is None and param["range_source"] == "unverified"
assert param["field"] != "videopreview"
finally:
conn.close()
@@ -184,7 +184,7 @@ def test_mapped_node_returning_nothing_fails_for_real(client, fake_comfy):
assert r.status_code == 200, r.text
job = _wait_done(client, r.json()["job"]["id"])
assert job["status"] == "failed"
assert "没有返回任何图片或视频" in job["error"]
assert "没有返回任何图片、视频、音频或文件" in job["error"]
assert "15" in job["error"] # names the node it waited on
assert job["output_paths"] == []
@@ -234,7 +234,7 @@ def test_invalid_mappings_are_rejected_in_chinese(client):
"/api/workflows",
json={"name": "坏映射2", "workflow_json": _vault_workflow(),
"usage": "text2video",
"output_map": [{"nodeId": "12", "kind": "audio"}]},
"output_map": [{"nodeId": "12", "kind": "unsupported_media"}]},
)
assert r.status_code == 422 and "媒体类型" in r.json()["detail"]
@@ -38,6 +38,7 @@ def test_analyze_returns_usage_evidence_and_problem(client):
assert any(o["kind"] == "image" for o in body["usage_evidence"]["outputs"])
assert body["usable_usage"] == [
"text2image", "image2image", "text2video", "image2video", "ref2video",
"text2speech", "speech2text", "text2music", "utility", "audio2video", "pose2video",
]
r = client.post("/api/workflows/analyze", json={"workflow_json": AMBIGUOUS})
@@ -0,0 +1,285 @@
"""HEL-892: offline catalogue completeness, preservation and conservative conversion."""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
import pytest
from calliope.comfyui.client import ComfyUIClient
from calliope.comfyui.library import import_library, load_snapshot, migrate_library, rollback_batch
from calliope.comfyui.output_map import auto_output_map
from calliope.comfyui.ui2api import convert
from calliope.config import settings
from calliope.db import get_db, migrate_db
ROOT = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
API = {"1": {"class_type": "SaveImage", "inputs": {"filename_prefix": "test"}}}
async def test_all_sources_and_repeat_after_restart(tmp_path):
db = tmp_path / "library.db"
await migrate_db(db)
conn = get_db(db)
conn.execute(
"INSERT INTO workflows(name,kind,workflow_json) VALUES ('user','image',?)",
(json.dumps(API),),
)
conn.commit()
before = dict(conn.execute("SELECT * FROM workflows WHERE id=1").fetchone())
report = import_library(conn, ROOT, source_commit="fixture")
assert report["total"] == 156
assert report["states"] == {"needs_conversion": 146, "pending_check": 10}
assert conn.execute("SELECT count(*) FROM workflows WHERE is_enabled=0").fetchone()[0] == 156
assert dict(conn.execute("SELECT * FROM workflows WHERE id=1").fetchone()) == before
for row in conn.execute("SELECT * FROM workflows WHERE source_repo_path IS NOT NULL"):
doc = json.loads(row["doc_json"])
assert doc["source_nodes"]
assert doc["status"] == {"imported": True, "doc_done": False, "exec_verified": False}
assert json.loads(row["raw_source_json"]) == json.loads(
(ROOT.parent / row["source_repo_path"]).read_text()
)
conn.close()
await migrate_db(db)
conn = get_db(db)
second = import_library(conn, ROOT, source_commit="fixture")
assert second["counts"] == {"skipped": 156}
assert conn.execute("SELECT count(*) FROM workflows").fetchone()[0] == 157
conn.close()
async def test_update_user_conflict_and_rollback(tmp_path):
root = tmp_path / "sources"
root.mkdir()
source = root / "a.json"
source.write_text(json.dumps(API))
db = tmp_path / "local.db"
await migrate_db(db)
conn = get_db(db)
first = import_library(conn, root, source_commit="first")
old = dict(conn.execute("SELECT * FROM workflows").fetchone())
source.write_text(json.dumps({"2": {"class_type": "SaveAudio", "inputs": {}}}))
second = import_library(conn, root, source_commit="second")
assert second["counts"] == {"updated": 1}
assert conn.execute("SELECT kind FROM workflows").fetchone()[0] == "audio"
assert rollback_batch(conn, second["batch_id"])["files"][0]["result"] == "restored"
assert dict(conn.execute("SELECT * FROM workflows").fetchone()) == old
conn.execute("UPDATE workflows SET description='用户版' WHERE id=?", (old["id"],))
conn.commit()
assert import_library(conn, root, source_commit="third")["counts"] == {"conflict": 1}
assert rollback_batch(conn, first["batch_id"])["files"][0]["result"] == "conflict"
assert conn.execute("SELECT description FROM workflows").fetchone()[0] == "用户版"
conn.close()
async def test_rollback_new_rows_preserves_references(tmp_path):
await migrate_db(tmp_path / "db")
conn = get_db(tmp_path / "db")
root = tmp_path / "sources"
root.mkdir()
(root / "a.json").write_text(json.dumps(API))
report = import_library(conn, root, source_commit="first")
assert rollback_batch(conn, report["batch_id"])["files"][0]["result"] == "disabled"
assert conn.execute("SELECT import_state FROM workflows").fetchone()[0] == "rolled_back"
assert rollback_batch(conn, report["batch_id"])["files"] == []
assert import_library(conn, root, source_commit="first")["counts"] == {"updated": 1}
conn.close()
def test_legacy_constraint_migration_preserves_fk_and_indexes():
conn = sqlite3.connect(":memory:")
conn.execute("PRAGMA foreign_keys=ON")
conn.executescript("""
CREATE TABLE workflows(id INTEGER PRIMARY KEY, name TEXT,
kind TEXT CHECK(kind IN ('image', 'video')), workflow_json TEXT);
CREATE INDEX wf_name ON workflows(name);
CREATE TABLE jobs(id INTEGER PRIMARY KEY, workflow_id INTEGER REFERENCES workflows(id));
INSERT INTO workflows VALUES (9,'existing','image','{}');
INSERT INTO jobs VALUES(1,9);
""")
migrate_library(conn)
migrate_library(conn)
assert conn.execute("SELECT name FROM workflows WHERE id=9").fetchone()[0] == "existing"
assert conn.execute("SELECT workflow_id FROM jobs").fetchone()[0] == 9
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1
assert conn.execute("SELECT name FROM sqlite_master WHERE name='wf_name'").fetchone()
conn.execute("INSERT INTO workflows(id,kind) VALUES(10,'audio')")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("DELETE FROM workflows WHERE id=9")
def test_ui_conversion_golden_and_ambiguous_controls():
info = {
"Scalar": {
"input": {"required": {"text": ["STRING", {}]}},
"input_order": {"required": ["text"]},
"output": ["STRING"],
},
"Sink": {
"input": {"required": {"value": ["STRING", {"forceInput": True}]}},
"input_order": {"required": ["value"]},
"output": [],
},
}
ui = {
"nodes": [
{"id": 1, "type": "Scalar", "widgets_values": {"text": "hello"}},
{"id": 2, "type": "Sink", "inputs": [{"name": "value", "link": 7}]},
],
"links": [[7, 1, 0, 2, 0, "STRING"]],
}
result = convert(ui, info)
assert result.issues == []
assert result.prompt == {
"1": {"class_type": "Scalar", "inputs": {"text": "hello"}, "_meta": {"title": "Scalar"}},
"2": {"class_type": "Sink", "inputs": {"value": ["1", 0]}, "_meta": {"title": "Sink"}},
}
ui["nodes"][0]["widgets_values"] = ["hello", "randomize"]
assert convert(ui, info).prompt is None
assert any(i["code"] == "widget_layout_unverified" for i in convert(ui, info).issues)
ui["nodes"][0]["mode"] = 4
assert any(i["code"] == "mode_requires_export" for i in convert(ui, info).issues)
assert convert(ui).prompt is None
def test_snapshot_failure_is_not_conversion_evidence(tmp_path):
path = tmp_path / "snapshot.json"
path.write_text(
json.dumps(
{
"source_url": "http://localhost/object_info",
"captured_at": "now",
"status": "unavailable",
}
)
)
with pytest.raises(ValueError):
load_snapshot(path)
def test_audio_and_subtitle_harvest_only_mapped_nodes():
client = ComfyUIClient("http://unused")
history = {
"outputs": {
"1": {"audio": [{"filename": "speech.flac"}]},
"2": {"files": [{"filename": "字幕.srt"}], "text": ["not a file"]},
"3": {"audio": [{"filename": "hidden.wav"}]},
}
}
outputs = client.extract_outputs(history, {"1": "audio", "2": "file"})
assert [o["media_kind"] for o in outputs] == ["audio", "file"]
assert auto_output_map({"1": {"class_type": "SaveAudio", "inputs": {}}})[0]["kind"] == "audio"
assert client.extract_outputs({"outputs": {"1": {"audio": ["inline text", {}]}}}) == []
def test_catalogue_routes_cannot_enable_or_execute_placeholder(client, tmp_path):
root = tmp_path / "sources"
root.mkdir()
(root / "ui.json").write_text(json.dumps({"nodes": [{"id": 1, "type": "Missing"}]}))
conn = get_db(settings.db_path)
report = import_library(conn, root, source_commit="fixture")
wid = report["files"][0]["workflow_id"]
conn.close()
body = client.get(f"/api/workflows/{wid}").json()
assert body["doc_json"]["draft"] is True
assert body["import_issues"]
assert "raw_source_json" not in body
assert (
client.patch(
f"/api/workflows/{wid}", json={"usage": "utility", "is_enabled": True}
).status_code
== 400
)
assert (
client.patch(f"/api/workflows/{wid}", json={"usage": "utility"}).json()["is_enabled"]
is False
)
assert client.post(f"/api/workflows/{wid}/reanalyze").json()["is_enabled"] is False
assert client.post(f"/api/workflows/{wid}/check", json={}).status_code == 400
settings.dry_run = False
try:
assert (
client.post(f"/api/workflows/{wid}/test", json={"input_values": {}}).status_code == 400
)
finally:
settings.dry_run = True
@pytest.mark.parametrize(
"kind,key,filename,usage,ct",
[
("audio", "audio", "speech.flac", "text2speech", "SaveAudio"),
("file", "files", "subtitle.srt", "speech2text", "CustomSubtitleSaver"),
],
)
def test_audio_file_queue_download(client, fake_comfy, kind, key, filename, usage, ct):
from tests.test_studio_generation import PNG_1PX, _real_run
from tests.test_workflow_test import _wait_terminal
with _real_run(client, fake_comfy):
fake_comfy.forced_output_node = "9"
fake_comfy.outputs_key = key
fake_comfy.out_name = filename
workflow = {"9": {"class_type": ct, "inputs": {"filename_prefix": "test"}}}
response = client.post(
"/api/workflows",
json={
"name": filename,
"kind": kind,
"usage": usage,
"workflow_json": workflow,
"output_map": [{"nodeId": "9", "kind": kind}],
},
)
assert response.status_code == 200, response.text
wid = response.json()["id"]
response = client.post(f"/api/workflows/{wid}/test", json={"input_values": {}})
assert response.status_code == 200, response.text
job = _wait_terminal(client, response.json()["job"]["id"])
assert job["status"] == "done", job.get("error")
output = Path(job["output_paths"][0])
assert output.name == filename and output.read_bytes() == PNG_1PX
# This fixture verifies transport and persistence, not a real audio codec/subtitle.
assert any(path.startswith("/view?") for path in fake_comfy.requests)
def test_dedupe_preserves_separate_source_paths(client, tmp_path):
root = tmp_path / "sources"
for name in ("a", "b"):
(root / name).mkdir(parents=True)
(root / name / "same.json").write_text(json.dumps(API))
conn = get_db(settings.db_path)
import_library(conn, root, source_commit="fixture")
conn.close()
assert client.post("/api/workflows/dedupe").json()["removed"] == 0
assert len(client.get("/api/workflows").json()) == 2
def test_rebuild_preserves_autoincrement_watermark():
conn = sqlite3.connect(":memory:")
conn.executescript("""
CREATE TABLE workflows(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT,
kind TEXT CHECK(kind IN ('image', 'video')), workflow_json TEXT);
INSERT INTO workflows(id,kind) VALUES(100,'image');
DELETE FROM workflows;
""")
migrate_library(conn)
assert conn.execute("INSERT INTO workflows(kind) VALUES('audio')").lastrowid == 101
def test_document_dependencies_do_not_treat_notes_or_editor_urls_as_models():
from calliope.comfyui.library import build_document
source = {
"nodes": [
{"id": 1, "type": "Loader", "widgets_values": ["actual.safetensors"]},
{"id": 2, "type": "Note", "widgets_values": ["example.safetensors"]},
],
"properties": {"url": "https://example.com/download.safetensors"},
}
doc = build_document("example/test.json", source, None, None, [])
assert doc["dependencies"]["models"] == ["actual.safetensors"]
assert doc["source_notes"][0]["content"] == ["example.safetensors"]
+1 -1
View File
@@ -95,7 +95,7 @@ def test_no_output_does_not_pass(client, fake_comfy):
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
job = _wait_terminal(client, r.json()["job"]["id"])
assert job["status"] == "failed"
assert "没有返回任何图片或视频" in (job["error"] or "")
assert "没有返回任何图片、视频、音频或文件" in (job["error"] or "")
def test_dry_run_setting_refuses_real_test(client, fake_comfy):
+61
View File
@@ -150,9 +150,70 @@ export interface WorkflowNode {
_meta?: { title?: string };
}
/** Persisted library document; list responses omit doc_json, detail GET includes it. */
export interface WorkflowDocument {
title?: string;
purpose?: string;
source_path?: string;
materials?: { slot?: string; desc?: string; required?: boolean }[];
io?: { inputs?: ComfyDynamicInput[]; outputs?: ComfyDynamicOutput[] };
dependencies?: {
models?: string[];
node_types?: string[];
node_packs?: string[];
check_state?: string;
checked_at?: string | null;
source?: string | null;
model_locations?: string;
missing?: unknown[];
};
params?: {
node_id: string;
field: string;
name?: string;
default?: unknown;
unit?: string | null;
min?: number | null;
max?: number | null;
options?: unknown[] | null;
range_source?: string;
effect?: string;
}[];
steps?: string[];
errors?: { symptom: string; fix: string }[];
status?: { imported?: boolean; doc_done?: boolean; exec_verified?: boolean };
source_notes?: { node_id: string; content: unknown }[];
source_review?: {
scope?: string[];
unconfirmed?: string[];
reviewed_named_fields?: {
node_id: string;
field: string;
label?: string;
category?: string;
source_value?: unknown;
scope?: string;
operation_check?: string;
connections?: unknown;
}[];
semantic_review?: {
conclusions?: string[];
material_requirements?: unknown;
[key: string]: unknown;
};
};
}
export interface Workflow {
id: number;
name: string;
doc_json?: WorkflowDocument | null;
import_state?: string | null;
source_format?: string | null;
source_repo_path?: string | null;
import_issues?: { message: string; code?: string; node_id?: string }[] | null;
doc_completed_at?: string | null;
exec_verified_at?: string | null;
kind: 'image' | 'video';
/** Five-way usage (HEL-636); null = pending human confirmation. */
usage?: WorkflowUsage | null;
@@ -0,0 +1,246 @@
<script lang="ts">
import { toStore } from "svelte/store";
import { createQuery } from "@tanstack/svelte-query";
import { workflows, apiErrorMessage, type Workflow } from "$lib/api";
import Drawer from "./ui/Drawer.svelte";
import Button from "./ui/Button.svelte";
import Card from "./ui/Card.svelte";
import StatusChip from "./ui/StatusChip.svelte";
let {
open = $bindable(false),
workflow,
}: { open?: boolean; workflow: Workflow | null } = $props();
// Keep the list lightweight. Only this read-only GET loads the selected document.
const detail = createQuery(
toStore(() => {
const id = workflow?.id;
return {
queryKey: ["workflow-document", id],
queryFn: () => workflows.get(id!),
enabled: open && workflow !== null,
staleTime: 0,
};
}),
);
const wf = $derived($detail.data);
const doc = $derived(wf?.doc_json);
const review = $derived(doc?.source_review);
const deps = $derived(doc?.dependencies);
const categories: Record<string, string> = {
runtime_parameter: "有源侧用途依据",
frontend_state: "前端状态(非运行参数)",
current_value_only: "仅确认现值",
semantic_unknown: "语义待确认",
};
function value(v: unknown): string {
if (v === undefined) return "未记录";
return typeof v === "string" ? v : JSON.stringify(v, null, 2);
}
</script>
<Drawer bind:open title="工作流说明" width={440}>
<div class="document-content" aria-live="polite">
<h3>{workflow?.name}</h3>
{#if $detail.isPending || $detail.isFetching}
<p role="status">正在读取说明…</p>
{:else if $detail.isError}
<p role="alert">说明读取失败:{apiErrorMessage($detail.error)}</p>
<Button onclick={() => $detail.refetch()}>重试</Button>
{:else if wf}
<div class="document-status">
<StatusChip status="pending" label="已入库" />
<StatusChip
status="pending"
label={doc?.status?.doc_done && wf.doc_completed_at
? "说明已定稿"
: "说明未定稿"}
/>
<StatusChip
status="pending"
label={doc?.status?.exec_verified && wf.exec_verified_at
? "有实跑通过记录"
: "未验证实跑"}
/>
</div>
{#if wf.import_state === "needs_conversion"}
<Card title="待转换 / 待补材料">
<p>此条目尚不可执行,仍可阅读说明。</p>
<p>{wf.import_issues?.[0]?.message || "可靠转换尚未完成。"}</p>
<details>
<summary>全部转换阻碍({wf.import_issues?.length ?? 0})</summary>
{#each wf.import_issues ?? [] as issue}<p>{issue.message}</p>{/each}
</details>
</Card>
{/if}
<Card title="用途与来源">
<p>
{doc?.purpose ||
wf.description ||
"此旧工作流尚无详细说明,保留现有输入输出供核对。"}
</p>
{#if doc?.source_path || wf.source_repo_path}<p>
源文件:{doc?.source_path || wf.source_repo_path}
</p>{/if}
{#if review?.scope?.length}<p>
已审范围:{review.scope.join(";")}
</p>{/if}
{#each review?.semantic_review?.conclusions ?? [] as conclusion}<p>
{conclusion}
</p>{/each}
</Card>
<Card title="素材要求与输入输出">
{#each doc?.materials ?? [] as material}
<p>
素材槽 {material.slot}:{material.desc}({material.required
? "必需"
: "可选"})
</p>
{/each}
<h4>输入映射</h4>
{#each doc?.io?.inputs ?? wf.input_schema ?? [] as input}
<p>
{input.label} · {input.kind} · 节点 {input.nodeId} / {input.role ??
"未标记角色"}({input.required ? "必需" : "可选"})
</p>
{:else}<p>未记录可靠输入映射;不能据此判定无需素材。</p>{/each}
<h4>输出映射</h4>
{#each doc?.io?.outputs ?? wf.output_schema ?? [] as output}
<p>{output.label} · {output.kind} · 节点 {output.nodeId}</p>
{:else}<p>未记录可靠输出映射,实际输出待核对。</p>{/each}
{#if review?.semantic_review?.material_requirements}
<details>
<summary>源文件素材审校</summary>
<pre>{value(review.semantic_review.material_requirements)}</pre>
</details>
{/if}
</Card>
<Card title="参数与源现值">
<p>
源现值不代表推荐值。下列审校分类与单位、范围、调整效果的确认分别记录。
</p>
{#each doc?.params ?? [] as param}
{@const fields = (review?.reviewed_named_fields ?? []).filter(
(f) => f.node_id === param.node_id && f.field === param.field,
)}
<details>
<summary>节点 {param.node_id} · {param.field}</summary>
<p>源现值:</p>
<pre>{value(param.default)}</pre>
{#each fields as field}
<p>
{categories[field.category ?? ""] ??
"源字段局部审校"}:{field.label}
</p>
{#if field.scope}<p>{field.scope}</p>{/if}
{#if field.operation_check}<p>{field.operation_check}</p>{/if}
{#if field.connections}<details>
<summary>源连线依据</summary>
<pre>{value(field.connections)}</pre>
</details>{/if}
{:else}<p>已保留源字段与现值;语义待确认。</p>{/each}
<p>单位:{param.unit ?? "待确认"}</p>
<p>
范围:{param.range_source && param.range_source !== "unverified"
? `${param.min ?? "未记录下界"} ~ ${param.max ?? "未记录上界"}(${param.range_source})`
: "待确认"}
</p>
{#if param.options}<p>可选值:{value(param.options)}</p>{/if}
<p>调整效果:{param.effect || "待确认"}</p>
</details>
{:else}<p>暂无具名参数说明;位置控件不能按顺序猜测。</p>{/each}
</Card>
<Card title="模型与节点引用">
<p>
环境检查:{deps?.check_state === "unchecked" || !deps?.check_state
? "未检查"
: deps.check_state}。源引用不等于实际加载依赖,未检查不能判定缺失或就位。
</p>
{#if deps?.checked_at}<p>检查时间:{deps.checked_at}</p>{/if}
{#if deps?.source}<p>定义来源:{deps.source}</p>{/if}
<p>模型位置:{deps?.model_locations || "未检查"}</p>
<details>
<summary>模型引用({deps?.models?.length ?? 0})</summary>
{#each deps?.models ?? [] as model}<p>{model}</p>{:else}<p>
无记录,不代表环境就绪。
</p>{/each}
</details>
<details>
<summary>源节点类型({deps?.node_types?.length ?? 0})</summary>
{#each deps?.node_types ?? [] as node}<p>{node}</p>{/each}
</details>
{#if deps?.node_packs?.length}<p>
节点包:{deps.node_packs.join(";")}
</p>{/if}
</Card>
<Card title="使用步骤">
<ol>
{#each doc?.steps ?? [] as step}<li>{step}</li>{:else}<li>
此工作流尚未补充使用步骤。
</li>{/each}
</ol>
</Card>
<Card title="常见问题与待确认项">
{#each doc?.errors ?? [] as error}<details>
<summary>{error.symptom}</summary>
<p>{error.fix}</p>
</details>{/each}
{#each review?.unconfirmed ?? [] as item}<p>待确认:{item}</p>{/each}
{#if !doc}<p>
此旧工作流暂无说明数据,说明定稿、依赖环境和实跑状态均待核对。
</p>{/if}
</Card>
{#if doc?.source_notes?.length}
<Card title="源文件注释">
{#each doc.source_notes as note}<details>
<summary>节点 {note.node_id}</summary>
<pre>{value(note.content)}</pre>
</details>{/each}
</Card>
{/if}
{/if}
</div>
{#snippet footer()}<Button onclick={() => (open = false)}>关闭</Button
>{/snippet}
</Drawer>
<style>
.document-content {
display: grid;
gap: 12px;
min-width: 0;
overflow-wrap: anywhere;
}
.document-content :global(*) {
min-width: 0;
}
.document-status {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
h3,
h4,
p {
margin: 0 0 8px;
}
details {
margin: 8px 0;
}
summary {
cursor: pointer;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
font: inherit;
margin: 8px 0;
}
ol {
margin: 0;
padding-left: 20px;
}
li + li {
margin-top: 8px;
}
</style>
@@ -8,6 +8,7 @@
import Icon from '$lib/components/ui/Icon.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import WorkflowImportDrawer from '$lib/components/WorkflowImportDrawer.svelte';
import WorkflowDocumentDrawer from '$lib/components/WorkflowDocumentDrawer.svelte';
import WorkflowTestDialog from '$lib/components/WorkflowTestDialog.svelte';
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowOutputMapEntry, type WorkflowUsage, type WorkflowConnectionCheck } from '$lib/api';
import { WORKFLOW_USAGES } from '$lib/comfy/types';
@@ -332,6 +333,8 @@
// wizard itself lives in WorkflowImportDrawer (HEL-655 E 批).
// ------------------------------------------------------------------
let wfImportOpen = $state(false);
let documentWf = $state<Workflow | null>(null);
let documentOpen = $state(false);
function startWfImport() {
wfImportOpen = true;
@@ -745,7 +748,7 @@
<span class="wf-kind {wf.kind}">
<Icon name={wf.kind === 'image' ? 'image' : 'film'} size={14} />
</span>
<span class="wf-name" title={wf.description ?? wf.name}>{wf.name}</span>
<span class="wf-name" title={[wf.name, wf.description].filter(Boolean).join("\n")}>{wf.name}</span>
{#if wf.usage}
<span
class="tag tag-usage"
@@ -791,10 +794,12 @@
</span>
{/if}
<span class="wf-params">{t('conn.wfParams', { n: wf.input_schema?.length ?? 0 })}</span>
<Button size="sm" variant="ghost" onclick={() => { documentWf = wf; documentOpen = true; }}>说明</Button>
<button
type="button"
class="link-accent wf-test-btn"
title={t('wft.title')}
disabled={wf.import_state === 'needs_conversion'}
title={wf.import_state === 'needs_conversion' ? '待可靠转换,暂不可执行;可打开说明' : t('wft.title')}
onclick={() => openTest(wf)}
>
<Icon name="play" size={13} />
@@ -1109,6 +1114,8 @@
{endpoints}
onchanged={() => client.invalidateQueries({ queryKey: ['workflows'] })}
/>
<WorkflowDocumentDrawer bind:open={documentOpen} workflow={documentWf} />
<WorkflowTestDialog
bind:open={testOpen}
workflow={testWf}
@@ -1560,6 +1567,7 @@
}
.wf-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 8px 10px;
@@ -1584,7 +1592,7 @@
background: var(--bg-4);
}
.wf-name {
flex: 1;
flex: 1 0 160px;
min-width: 0;
font-size: 13px;
font-weight: 500;
@@ -1910,9 +1918,6 @@
.card-foot {
margin-left: 0;
}
.wf-row {
flex-wrap: wrap;
}
.wf-name {
flex-basis: 100%;
order: 5;
File diff suppressed because one or more lines are too long
+79
View File
@@ -0,0 +1,79 @@
> 当前第四批:见 `第四批字段审校与转换前置清单.md`、`逐字段第四批审校.md`、`146份UI转换前置清单.md` 及 `fourth-evidence/`。685项新增局部审校已入实际说明;旧542项和140份说明保留。可靠转换仍剩146份,完整定稿/实跑均为0;以下为历史批次。
> 当前批次:见 `第三批语义审校与差分准备.md`、`逐文件第三批审校.md` 和 `third-evidence/`。140份源语义局部审校已补入实际说明;不要求暂停TTS或交出本地节点包/模型。下文及旧批次材料要求为历史记录,全量目标仍未完成。
> 最新批次:两处审查返工与 156 份结构/用途局部审校见 `整改与局部审校.md`,检查证据见 `revision-evidence/`。下文为首批历史交付记录;“15 项 lint”仅是首批变更文件范围,当前全后台检查口径详见最新报告。`offline/documents/` 已更新为本批实际说明,完整定稿仍为 0。
# HEL-892 离线施工交付
本次只交付后台、保守转换框架、全量库导入和说明证据草稿;不代表总目标完成。未修改界面和视觉样式,未部署、未写测试机数据库,未停止 TTS,未提交目标机生成任务。
## 实际结果
源版本 `1fc8bdb8bb2a88ccace6f21a507fd77bcf22f532`,递归覆盖 156 个源文件,数量与技术方案基线一致。全部在本地隔离库建立条目并默认停用;10 份 API 图为 `pending_check`,146 份 UI 图为 `needs_conversion`。重复导入 156 份全部 `skipped`。
说明草稿 156 份、完成说明 0 份、真实生成验证 0 份。每份草稿保留该文件自身的节点 ID/类型、原始输入和控件值、连线、子图及文件注释,并提取模型引用;API 图可确认的具名参数记录源文件现值。UI 位置数组未具名,未猜测单位、范围或调整效果。用途、步骤和错误建议仍需人工逐项补全;本阶段不是 156 份可指导实际操作的定稿。
## 架构和接口
沿用 README 描述的 SQLite → 工作流 router → ComfyUI parser/output map → queue worker 模块边界,仓库没有独立架构文档。新增代码均置于后台现有 `comfyui` 目录,不引入依赖。
- `library.py`:来源路径唯一键、SHA1、源提交、批次、原始源文、说明结构、转换问题及三项独立时间戳;旧表约束迁移保留列、索引、引用和自增水位。启动时旧用法/输出回填不改来源库条目。
- `ui2api.py`:API 图保留原具名输入;UI 图只接受目标节点定义可核对的具名控件和直接连线。子图、虚拟节点、静音/旁路和位置数组全部失败关闭,返回节点级原因。`input_order` 不足以证明第三方前端的序列化顺序,不能据此猜字段。尚未实现完整 graphToPrompt 兼容,7 类真实黄金导出差分尚未取得。
- `library_cli.py`:显式本地库导入、批次回退、只读 `/object_info` 快照。成功快照必须含来源、时间及节点定义;失败记录不能作为转换依据。
- 类型:工作流 kind 增 `audio/file/unknown`,unknown 用于尚未确认输出的条目,不伪装图片或视频;usage 增语音、转写、音乐、工具、数字人和动作迁移。后端 output_map 支持 image/video/audio/file。音频用途不按文件名自动下结论,须确认。
- worker:从已映射节点收取 `audio/audios/files` 文件描述符,经 `/view` 下载。只接受含 filename 的文件对象;内联文本、SRT 字符串、外部 URL 和自定义响应结构不冒充已支持产物。字幕播放器、音频页面和实际节点的历史输出结构尚待核对。
- `GET /api/workflows` 提供来源、阻塞和状态;列表不带庞大的 doc_json/源文。`GET /api/workflows/{id}` 返回结构化 doc_json 和 import_issues,原始源文仍仅留库。占位图、待核查和已回退条目不可通过启用、确认用途、重新分析或测试入口绕过阻塞。
## 本地复现
从仓库根运行,Python 3.11,使用锁文件安装既有依赖。数据库路径由操作人明确指定,以下是本地演示路径,不针对任何在线应用。
```bash
uv sync --project calliope-backend --frozen --extra dev --python 3.11
uv run --project calliope-backend --frozen python -m calliope.comfyui.library_cli import \
--root example_ComfyUI_workflows --db .local-evidence/catalogue.db \
--source-commit 1fc8bdb8bb2a88ccace6f21a507fd77bcf22f532 --output-dir .local-evidence/report
```
重复同命令应全部 skipped。源变更写入 `workflow_revisions` 旧行快照;若条目已被用户修改,返回 conflict 并保持整行不动。导入和回退均持有数据库写锁并在单事务内完成。按批次回退:
```bash
uv run --project calliope-backend --frozen python -m calliope.comfyui.library_cli rollback \
--db .local-evidence/catalogue.db --batch-id <import_report中的batch_id> \
--output-dir .local-evidence/rollback
```
升级条目恢复旧快照;新建条目保留记录及引用并停用,状态 rolled_back;用户修改冲突不覆盖。再次导入可恢复未被修改的 rolled_back 条目。导入器不会自动安装节点、下载模型、启用工作流或执行任务。现有用户工作流(无 source_repo_path)永不被库导入器覆盖;常规去重也不删除来源库条目。
拿到授权窗口后只读获取快照(失败也写来源/时间/错误,退出码 1):
```bash
uv run --project calliope-backend --frozen python -m calliope.comfyui.library_cli snapshot \
--url http://192.168.200.12:8188 --output .local-evidence/object-info.json
```
成功快照经总工核对来源后,用 import 的 `--snapshot` 传入;即使源 SHA 不变,新快照也触发重新分析。`pending_test` 只表示离线检查未发现问题,绝不写 exec_verified_at。`/prompt` 不是只读预检,本轮没有针对目标机调用。
## 审计材料
`offline/manifest.json` 将全部源路径和 SHA 对应到 `offline/documents/*.json`;`逐文件清单.md` 便于人工审查。`offline/import_report.json` 是首次导入逐文件结果,`reimport-report.json` 是重启迁移后的重复导入。`object-info-attempt.json` 记录目标连接拒绝。`待补材料.json` 列出每份文件受影响节点和待具名控件,模型引用仅代表源文件引用,不代表目标机已确认缺失。
## 后续工作及操作影响
1. 请总管安排目标机只读 `/object_info` 获取窗口,无需发生成任务;当前目标机拒绝连接。不能要求用户为离线入库提前长时间停止 TTS。
2. 请提供 7 类各一份从实际 ComfyUI 前端导出的原始 API 黄金文件,并补齐复杂子图/虚拟连线/旁路/循环图的可靠转换适配。位置数组不因得到节点定义就自动变成可信具名输入。仍需转换 146 份。
3. 节点包版本/实际模型位置和获取来源、可用测试图片/参考音频/视频待提供。材料逐文件在待补清单中,不把源引用 102 项模型或 436 种节点写成实际缺项。
4. 156 份说明仍须具名参数语义、单位、范围、调整效果和中文操作步骤逐份审校。复杂图可用原 API 导出兜底;导入器不会改原始文件。
5. 界面按既有完整规范另行施工;本轮不采用 HEL-891 新图。音频/字幕展示与入口、实际材料槽组合、键盘和窄屏验收均未施工。
6. 真实生成待明确授权窗口和资源后逐类先一份;届时需空闲 GPU 和素材,由总管协调用户切换。施工员不停止 TTS、不启动 ComfyUI、不改端口、不部署。时长、显存及付费节点开关须取得真实参数后估计。
本轮材料扫描明确采用更宽的可审计口径:472 种源图 type 字符串(包括注释、虚拟节点、子图 UUID 以及未使用子图),186 条节点控件模型引用(不同路径分别保留,包含旁路/未连接节点;排除编辑器模型下载 URL 和注释)。这不是目标机服务端节点数,也不是实际加载权重数。与前方案的 436 类节点、102 项模型清单不能直接相减或当成新发现缺项;逐份原值均在说明草稿中,待目标快照和实际 API 导出确定执行依赖集合。
## 自测结果与限制
- Python 3.11 + 锁定依赖:相关后台回归 **107 passed**;最后补充“注释/编辑器 URL 不作为模型依赖”的提取检查后,库专项 **13 passed**。覆盖全量156、重启幂等、来源更新、用户修改保护、回退、外键/索引/自增水位、占位条目启用和测试拦截、不同路径同名条目防误去重、音频/字幕夹具队列下载;原图片/视频、真实状态、预检及批处理回归通过。
- 后端 wheel/sdist 构建成功;前端生产构建成功;前端既有 4 组脚本测试通过。
- 新模块和新增测试 Ruff 通过;变更文件对照基线无新增 lint 问题,现有15项问题仍在(基线17项,见 lint-comparison.json),不能称全仓 lint 全绿。
- 前端 `npm run check` 未通过:原有 `PromptPreviewModal.svelte:125` 调用 mutate 缺参数,另有2项警告。本轮没有前端源文件或依赖改动,未扩展范围去修此前问题。详细日志随交付附上。
- 真实目标机只做一次 GET /object_info,连接拒绝;真实节点/模型核对、7类黄金文件差分、音频编码/SRT格式、真实生成和新界面均未验证。夹具回传的占位字节只证明传输落盘,不证明媒体编码正确。
+4
View File
@@ -0,0 +1,4 @@
Building source distribution...
Building wheel from source distribution...
Successfully built .local-evidence/dist/calliope-1.5.8.tar.gz
Successfully built .local-evidence/dist/calliope-1.5.8-py3-none-any.whl
+9
View File
@@ -0,0 +1,9 @@
........................................................................ [ 67%]
................................... [100%]
=============================== warnings summary ===============================
calliope-backend/.venv/lib/python3.11/site-packages/starlette/testclient.py:53
<workspace>/calliope-backend/.venv/lib/python3.11/site-packages/starlette/testclient.py:53: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.
_PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
107 passed, 1 warning in 57.21s
@@ -0,0 +1,4 @@
Building source distribution...
Building wheel from source distribution...
Successfully built calliope-backend/dist/calliope-1.5.8.tar.gz
Successfully built calliope-backend/dist/calliope-1.5.8-py3-none-any.whl
@@ -0,0 +1 @@
All checks passed!
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
............................... [100%]
=============================== warnings summary ===============================
calliope-backend/.venv/lib/python3.12/site-packages/starlette/testclient.py:53
/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-892-5b144d454c0a/workdir/aigc-home/calliope-backend/.venv/lib/python3.12/site-packages/starlette/testclient.py:53: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.
_PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
31 passed, 1 warning in 8.83s
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
.... [100%]
=============================== warnings summary ===============================
calliope-backend/.venv/lib/python3.12/site-packages/starlette/testclient.py:53
/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-892-5b144d454c0a/workdir/aigc-home/calliope-backend/.venv/lib/python3.12/site-packages/starlette/testclient.py:53: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.
_PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
4 passed, 1 warning in 1.27s
+1
View File
@@ -0,0 +1 @@
{"batch_id": "74486526-91eb-4576-b500-5790eb806a6d", "source_commit": "1fc8bdb8bb2a88ccace6f21a507fd77bcf22f532", "counts": {"imported": 156}, "states": {"needs_conversion": 146, "pending_check": 10}, "total": 156, "doc_completed": 0, "exec_verified": 0}
@@ -0,0 +1,14 @@
{
"baseline_commit": "1390489",
"baseline_count": 369,
"current_count": 369,
"same_diagnostic_path_code_message": {
"added": [],
"removed": []
},
"line_level": {
"added": [],
"removed": []
},
"all_repo_lint_green": false
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
{
"isolated_db_rows": 156,
"new_reviews_readable": 16,
"new_fields_readable": 685,
"all_disabled": true,
"completed_and_execution_timestamps_all_null": true,
"wheel_review_bytes_match": true,
"review_sha256": "dae1f7edd067f0134c096f73320880deac697c81c63ba6cb7dd266bbc164a2ab"
}
@@ -0,0 +1 @@
{"batch_id": "aaa91be7-a3ac-4e2c-9a0d-ec7c80a1d22e", "source_commit": "1fc8bdb8bb2a88ccace6f21a507fd77bcf22f532", "counts": {"skipped": 156}, "states": {"needs_conversion": 146, "pending_check": 10}, "total": 156, "doc_completed": 0, "exec_verified": 0}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
{"total": 156, "source_structure_purpose_partially_reviewed": 156, "full_documents_completed": 0, "files_with_selected_named_fields_reviewed": 42, "selected_named_fields_reviewed": 1227, "real_executions": 0, "states": {"needs_conversion": 146, "pending_check": 10}, "named_values_preserved": 1227, "unnamed_widget_nodes": 3701, "semantic_batch_files": 140, "semantic_batch_without_named_fields": 114, "semantic_batch_named_fields": 500, "named_values_without_manual_field_review": 0, "named_values_without_verified_units_ranges_effects": 1227, "scope_limit": "人工结论与机械原值清单分开;源语义局部审校非完整说明定稿", "partition": {"prior_files": 16, "prior_named_fields": 42, "new_files": 140, "new_named_fields": 500, "new_without_named_fields": 114, "disjoint_complete_sha_verified": true, "prior_review_payloads_unchanged": true}, "fourth_partition": {"mechanical_values_checked": 1227, "prior_reviewed": 542, "added_reviewed": 685, "new_files": 16, "classification_counts_new": {"runtime_parameter": 590, "frontend_state": 6, "current_value_only": 41, "semantic_unknown": 48}, "duplicates": 0, "prior_156_payloads_preserved": true, "added_fields_with_runtime_source_use": 590, "added_fields_without_confirmed_runtime_meaning": 95, "verified_units_ranges_effects": 0, "full_documents_completed": 0, "real_executions": 0}}
+8
View File
@@ -0,0 +1,8 @@
................................ [100%]
=============================== warnings summary ===============================
calliope-backend/.venv/lib/python3.12/site-packages/starlette/testclient.py:53
/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-892-5b144d454c0a/workdir/aigc-home/calliope-backend/.venv/lib/python3.12/site-packages/starlette/testclient.py:53: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.
_PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
32 passed, 1 warning in 6.40s
+273
View File
@@ -0,0 +1,273 @@
> calliope-web@1.5.8 build
> vite build
▲ [WARNING] Cannot find base config file "./.svelte-kit/tsconfig.json" [tsconfig.json]
tsconfig.json:2:12:
2 │ "extends": "./.svelte-kit/tsconfig.json",
╵ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
vite v6.4.3 building SSR bundle for production...
transforming...
11:33:10 PM [vite-plugin-svelte] src/routes/workbench/[id]/+page.svelte:345:3 Self-closing HTML tags for non-void elements are ambiguous — use `<div ...></div>` rather than `<div ... />`
https://svelte.dev/e/element_invalid_self_closing_tag
343: <div class="page-pad">
344: <Skeleton height="64px" />
345: <div class="gap" />
^
346: <Skeleton height="280px" />
347: </div>
11:33:10 PM [vite-plugin-svelte] src/lib/components/workbench/ShotStagePanel.svelte:156:5 `<video>` elements must have a `<track kind="captions">`
https://svelte.dev/e/a11y_media_has_caption
154: {#if shot.description}<p class="vshot-desc">{shot.description}</p>{/if}
155: {#if shot.job?.status === 'done' && shot.job.output}
156: <video
^
157: class="vshot-video"
158: src={assetUrl(shot.job.output) ?? undefined}
"handleConnectionChange" and "handleAttributionWarning" are imported from external module "@xyflow/system" but never used in "node_modules/@xyflow/svelte/dist/lib/hooks/useNodeConnections.svelte.js", "node_modules/@xyflow/svelte/dist/lib/components/Handle/Handle.svelte" and "node_modules/@xyflow/svelte/dist/lib/components/Attribution/Attribution.svelte".
✓ 515 modules transformed.
rendering chunks...
vite v6.4.3 building for production...
transforming...
11:33:12 PM [vite-plugin-svelte] src/routes/workbench/[id]/+page.svelte:345:3 Self-closing HTML tags for non-void elements are ambiguous — use `<div ...></div>` rather than `<div ... />`
https://svelte.dev/e/element_invalid_self_closing_tag
343: <div class="page-pad">
344: <Skeleton height="64px" />
345: <div class="gap" />
^
346: <Skeleton height="280px" />
347: </div>
11:33:13 PM [vite-plugin-svelte] src/lib/components/workbench/ShotStagePanel.svelte:156:5 `<video>` elements must have a `<track kind="captions">`
https://svelte.dev/e/a11y_media_has_caption
154: {#if shot.description}<p class="vshot-desc">{shot.description}</p>{/if}
155: {#if shot.job?.status === 'done' && shot.job.output}
156: <video
^
157: class="vshot-video"
158: src={assetUrl(shot.job.output) ?? undefined}
✓ 757 modules transformed.
rendering chunks...
computing gzip size...
.svelte-kit/output/client/_app/version.json 0.03 kB │ gzip: 0.05 kB
.svelte-kit/output/client/.vite/manifest.json 26.73 kB │ gzip: 3.24 kB
.svelte-kit/output/client/_app/immutable/assets/Icon.BrXLPzfy.css 0.08 kB │ gzip: 0.10 kB
.svelte-kit/output/client/_app/immutable/assets/12.IyyrLGyK.css 0.22 kB │ gzip: 0.15 kB
.svelte-kit/output/client/_app/immutable/assets/6.C62bpzl4.css 0.22 kB │ gzip: 0.15 kB
.svelte-kit/output/client/_app/immutable/assets/LanguageSwitcher.BHCsXcey.css 0.33 kB │ gzip: 0.22 kB
.svelte-kit/output/client/_app/immutable/assets/Skeleton.BQeWIsLh.css 0.39 kB │ gzip: 0.22 kB
.svelte-kit/output/client/_app/immutable/assets/SafeMedia.CBqd7Keo.css 0.54 kB │ gzip: 0.34 kB
.svelte-kit/output/client/_app/immutable/assets/EmptyState.BXjpyO2u.css 0.58 kB │ gzip: 0.31 kB
.svelte-kit/output/client/_app/immutable/assets/Spinner.us8fwMZC.css 0.73 kB │ gzip: 0.37 kB
.svelte-kit/output/client/_app/immutable/assets/ConfirmDialog.DOfiQL0c.css 0.91 kB │ gzip: 0.38 kB
.svelte-kit/output/client/_app/immutable/assets/ProgressBar.DG8dH-U_.css 1.44 kB │ gzip: 0.58 kB
.svelte-kit/output/client/_app/immutable/assets/18.DG2SembM.css 1.87 kB │ gzip: 0.70 kB
.svelte-kit/output/client/_app/immutable/assets/ImageLightbox.BK2SuPZK.css 2.20 kB │ gzip: 0.72 kB
.svelte-kit/output/client/_app/immutable/assets/10.-McisAlK.css 4.49 kB │ gzip: 1.20 kB
.svelte-kit/output/client/_app/immutable/assets/15.Do7_3xO3.css 4.84 kB │ gzip: 1.32 kB
.svelte-kit/output/client/_app/immutable/assets/ComfyDynamicForm.BMZDOzpt.css 4.86 kB │ gzip: 1.14 kB
.svelte-kit/output/client/_app/immutable/assets/16.D3s-Wa9z.css 5.02 kB │ gzip: 1.31 kB
.svelte-kit/output/client/_app/immutable/assets/AgentComposer.ub9gqAax.css 6.39 kB │ gzip: 1.59 kB
.svelte-kit/output/client/_app/immutable/assets/4.D4ArXiOU.css 10.48 kB │ gzip: 2.23 kB
.svelte-kit/output/client/_app/immutable/assets/11.CkE-wdAx.css 10.49 kB │ gzip: 2.33 kB
.svelte-kit/output/client/_app/immutable/assets/3.CU9FCfPU.css 10.86 kB │ gzip: 2.39 kB
.svelte-kit/output/client/_app/immutable/assets/AgentChat.DoddhUHp.css 11.39 kB │ gzip: 2.53 kB
.svelte-kit/output/client/_app/immutable/assets/AppShell.BDTKT42t.css 12.74 kB │ gzip: 2.91 kB
.svelte-kit/output/client/_app/immutable/assets/5.CYAHcbnz.css 15.04 kB │ gzip: 3.15 kB
.svelte-kit/output/client/_app/immutable/assets/0.DtQ4sRph.css 19.40 kB │ gzip: 4.66 kB
.svelte-kit/output/client/_app/immutable/assets/StudioGenerate.DLzq3T0v.css 21.08 kB │ gzip: 4.13 kB
.svelte-kit/output/client/_app/immutable/assets/19.DdIn3xwb.css 22.49 kB │ gzip: 3.73 kB
.svelte-kit/output/client/_app/immutable/assets/8.BhhYCKp9.css 26.56 kB │ gzip: 4.82 kB
.svelte-kit/output/client/_app/immutable/assets/7._yLDI1qf.css 31.71 kB │ gzip: 5.43 kB
.svelte-kit/output/client/_app/immutable/nodes/5.DUJAhBNk.js 0.09 kB │ gzip: 0.10 kB
.svelte-kit/output/client/_app/immutable/chunks/Bzak7iHL.js 0.10 kB │ gzip: 0.10 kB
.svelte-kit/output/client/_app/immutable/chunks/DTQKaZHR.js 0.10 kB │ gzip: 0.11 kB
.svelte-kit/output/client/_app/immutable/chunks/IKQcdn9t.js 0.12 kB │ gzip: 0.12 kB
.svelte-kit/output/client/_app/immutable/entry/start.CzU6slyM.js 0.12 kB │ gzip: 0.11 kB
.svelte-kit/output/client/_app/immutable/chunks/B0XwC4Ot.js 0.15 kB │ gzip: 0.14 kB
.svelte-kit/output/client/_app/immutable/nodes/17.ugaO4hIo.js 0.16 kB │ gzip: 0.14 kB
.svelte-kit/output/client/_app/immutable/nodes/9.wrIgfV1l.js 0.16 kB │ gzip: 0.14 kB
.svelte-kit/output/client/_app/immutable/chunks/BsCRBkET.js 0.18 kB │ gzip: 0.16 kB
.svelte-kit/output/client/_app/immutable/chunks/PLPYKeY9.js 0.19 kB │ gzip: 0.17 kB
.svelte-kit/output/client/_app/immutable/chunks/CI9jXNKC.js 0.19 kB │ gzip: 0.16 kB
.svelte-kit/output/client/_app/immutable/chunks/C1ohrfWQ.js 0.19 kB │ gzip: 0.14 kB
.svelte-kit/output/client/_app/immutable/nodes/14.DzhME1wO.js 0.23 kB │ gzip: 0.21 kB
.svelte-kit/output/client/_app/immutable/chunks/DkrMbCO4.js 0.23 kB │ gzip: 0.18 kB
.svelte-kit/output/client/_app/immutable/nodes/13.d8cRwCTU.js 0.24 kB │ gzip: 0.22 kB
.svelte-kit/output/client/_app/immutable/chunks/jbvGh2cr.js 0.26 kB │ gzip: 0.22 kB
.svelte-kit/output/client/_app/immutable/chunks/DuR33ltU.js 0.28 kB │ gzip: 0.19 kB
.svelte-kit/output/client/_app/immutable/chunks/LOG_p0wv.js 0.29 kB │ gzip: 0.22 kB
.svelte-kit/output/client/_app/immutable/nodes/2.Dewh2c_5.js 0.37 kB │ gzip: 0.25 kB
.svelte-kit/output/client/_app/immutable/chunks/BqqYkBFO.js 0.37 kB │ gzip: 0.26 kB
.svelte-kit/output/client/_app/immutable/nodes/1.r9yirS9f.js 0.40 kB │ gzip: 0.28 kB
.svelte-kit/output/client/_app/immutable/chunks/C9dRlz4w.js 0.46 kB │ gzip: 0.30 kB
.svelte-kit/output/client/_app/immutable/chunks/DizlLVgY.js 0.47 kB │ gzip: 0.34 kB
.svelte-kit/output/client/_app/immutable/chunks/BkYbArkN.js 0.49 kB │ gzip: 0.34 kB
.svelte-kit/output/client/_app/immutable/chunks/-UyI9lYi.js 0.51 kB │ gzip: 0.30 kB
.svelte-kit/output/client/_app/immutable/chunks/D41pZ3kD.js 0.56 kB │ gzip: 0.32 kB
.svelte-kit/output/client/_app/immutable/chunks/O29yiedF.js 0.61 kB │ gzip: 0.36 kB
.svelte-kit/output/client/_app/immutable/chunks/D8EAmlG6.js 0.62 kB │ gzip: 0.36 kB
.svelte-kit/output/client/_app/immutable/chunks/BXakyGcx.js 0.71 kB │ gzip: 0.43 kB
.svelte-kit/output/client/_app/immutable/chunks/0jsz6oxm.js 0.75 kB │ gzip: 0.42 kB
.svelte-kit/output/client/_app/immutable/chunks/BzVl9ZHh.js 0.83 kB │ gzip: 0.42 kB
.svelte-kit/output/client/_app/immutable/chunks/f7zKxHYB.js 0.84 kB │ gzip: 0.45 kB
.svelte-kit/output/client/_app/immutable/chunks/BpPWQjN7.js 0.92 kB │ gzip: 0.44 kB
.svelte-kit/output/client/_app/immutable/chunks/B_dFkj9f.js 1.08 kB │ gzip: 0.61 kB
.svelte-kit/output/client/_app/immutable/nodes/12.BomwwnvN.js 1.18 kB │ gzip: 0.70 kB
.svelte-kit/output/client/_app/immutable/chunks/Dp1pzeXC.js 1.27 kB │ gzip: 0.73 kB
.svelte-kit/output/client/_app/immutable/nodes/6.DKwMwxKz.js 1.29 kB │ gzip: 0.76 kB
.svelte-kit/output/client/_app/immutable/chunks/wbPk3Yxo.js 1.37 kB │ gzip: 0.66 kB
.svelte-kit/output/client/_app/immutable/chunks/DroLD1Vd.js 1.48 kB │ gzip: 0.78 kB
.svelte-kit/output/client/_app/immutable/chunks/DvVbqmqL.js 1.49 kB │ gzip: 0.75 kB
.svelte-kit/output/client/_app/immutable/chunks/Oqjetgwf.js 2.00 kB │ gzip: 1.06 kB
.svelte-kit/output/client/_app/immutable/chunks/C6NPzxMU.js 2.24 kB │ gzip: 0.99 kB
.svelte-kit/output/client/_app/immutable/chunks/DBfOsHAR.js 2.77 kB │ gzip: 1.28 kB
.svelte-kit/output/client/_app/immutable/chunks/Cfp5XeNU.js 2.94 kB │ gzip: 1.24 kB
.svelte-kit/output/client/_app/immutable/chunks/tTl_1iKp.js 3.08 kB │ gzip: 1.57 kB
.svelte-kit/output/client/_app/immutable/chunks/DY6GL6X4.js 3.23 kB │ gzip: 1.55 kB
.svelte-kit/output/client/_app/immutable/nodes/18.BTOaJgIE.js 3.50 kB │ gzip: 1.69 kB
.svelte-kit/output/client/_app/immutable/chunks/-yw0ATYa.js 4.27 kB │ gzip: 1.52 kB
.svelte-kit/output/client/_app/immutable/chunks/Dwm6yq4L.js 5.19 kB │ gzip: 2.50 kB
.svelte-kit/output/client/_app/immutable/chunks/Bn-IQ-PY.js 5.29 kB │ gzip: 2.16 kB
.svelte-kit/output/client/_app/immutable/chunks/BBjz4Wjn.js 6.86 kB │ gzip: 3.09 kB
.svelte-kit/output/client/_app/immutable/chunks/B0Wlifky.js 9.22 kB │ gzip: 2.13 kB
.svelte-kit/output/client/_app/immutable/nodes/16.BzK-ADdZ.js 10.26 kB │ gzip: 3.94 kB
.svelte-kit/output/client/_app/immutable/chunks/Dq0gzhJg.js 10.98 kB │ gzip: 4.12 kB
.svelte-kit/output/client/_app/immutable/entry/app.CRSPumgK.js 11.97 kB │ gzip: 4.01 kB
.svelte-kit/output/client/_app/immutable/nodes/0.CcvzD77g.js 12.03 kB │ gzip: 4.41 kB
.svelte-kit/output/client/_app/immutable/nodes/10.B05lFhYe.js 16.63 kB │ gzip: 6.09 kB
.svelte-kit/output/client/_app/immutable/chunks/b4rAb54u.js 16.64 kB │ gzip: 6.28 kB
.svelte-kit/output/client/_app/immutable/chunks/BJXZgoNf.js 17.38 kB │ gzip: 6.37 kB
.svelte-kit/output/client/_app/immutable/chunks/kyNCFls4.js 20.28 kB │ gzip: 7.19 kB
.svelte-kit/output/client/_app/immutable/nodes/15.CFA1wJR2.js 23.02 kB │ gzip: 7.51 kB
.svelte-kit/output/client/_app/immutable/chunks/DvTTcpEu.js 23.98 kB │ gzip: 9.32 kB
.svelte-kit/output/client/_app/immutable/nodes/11.-pVU7DFB.js 24.95 kB │ gzip: 8.35 kB
.svelte-kit/output/client/_app/immutable/nodes/3.DAzaVrdn.js 25.68 kB │ gzip: 8.33 kB
.svelte-kit/output/client/_app/immutable/nodes/4.CZExOcQV.js 29.61 kB │ gzip: 9.99 kB
.svelte-kit/output/client/_app/immutable/chunks/SBMx5f2U.js 33.93 kB │ gzip: 13.15 kB
.svelte-kit/output/client/_app/immutable/chunks/Dgn_TpdG.js 38.54 kB │ gzip: 13.86 kB
.svelte-kit/output/client/_app/immutable/chunks/CPluiqx0.js 41.30 kB │ gzip: 15.68 kB
.svelte-kit/output/client/_app/immutable/nodes/19.C4-lXfsX.js 52.59 kB │ gzip: 14.33 kB
.svelte-kit/output/client/_app/immutable/nodes/8.DW6R8xkG.js 67.08 kB │ gzip: 22.02 kB
.svelte-kit/output/client/_app/immutable/chunks/BbmTSwTk.js 71.50 kB │ gzip: 26.91 kB
.svelte-kit/output/client/_app/immutable/chunks/6_gpdkEb.js 81.38 kB │ gzip: 27.49 kB
.svelte-kit/output/client/_app/immutable/nodes/7.Cz3S1WZr.js 214.51 kB │ gzip: 69.05 kB
.svelte-kit/output/client/_app/immutable/chunks/BsrzHLJP.js 684.82 kB │ gzip: 180.09 kB
.svelte-kit/output/client/_app/immutable/chunks/2QcV1iOV.js 720.35 kB │ gzip: 209.00 kB
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
✓ built in 3.67s
.svelte-kit/output/server/.vite/manifest.json 23.36 kB
.svelte-kit/output/server/_app/immutable/assets/Icon.BrXLPzfy.css 0.08 kB
.svelte-kit/output/server/_app/immutable/assets/_page.C62bpzl4.css 0.22 kB
.svelte-kit/output/server/_app/immutable/assets/_page.IyyrLGyK.css 0.22 kB
.svelte-kit/output/server/_app/immutable/assets/LanguageSwitcher.BHCsXcey.css 0.33 kB
.svelte-kit/output/server/_app/immutable/assets/Skeleton.BQeWIsLh.css 0.39 kB
.svelte-kit/output/server/_app/immutable/assets/SafeMedia.CBqd7Keo.css 0.54 kB
.svelte-kit/output/server/_app/immutable/assets/EmptyState.BXjpyO2u.css 0.58 kB
.svelte-kit/output/server/_app/immutable/assets/Spinner.us8fwMZC.css 0.73 kB
.svelte-kit/output/server/_app/immutable/assets/ConfirmDialog.C_pBcjz1.css 0.89 kB
.svelte-kit/output/server/_app/immutable/assets/ProgressBar.DG8dH-U_.css 1.44 kB
.svelte-kit/output/server/_app/immutable/assets/_page.DG2SembM.css 1.87 kB
.svelte-kit/output/server/_app/immutable/assets/ImageLightbox.Mjqtht7n.css 2.18 kB
.svelte-kit/output/server/_app/immutable/assets/_page.CxLkc7AF.css 3.49 kB
.svelte-kit/output/server/_app/immutable/assets/_page.-McisAlK.css 4.49 kB
.svelte-kit/output/server/_app/immutable/assets/RefImageGrid.Cg0JH9u1.css 4.86 kB
.svelte-kit/output/server/_app/immutable/assets/_page.D3s-Wa9z.css 5.02 kB
.svelte-kit/output/server/_app/immutable/assets/AgentComposer.BeAeVWGq.css 6.34 kB
.svelte-kit/output/server/_app/immutable/assets/_page.DH5AEk6F.css 9.35 kB
.svelte-kit/output/server/_app/immutable/assets/_page.Bra3CMMT.css 10.42 kB
.svelte-kit/output/server/_app/immutable/assets/_page.BUR6RoLt.css 10.46 kB
.svelte-kit/output/server/_app/immutable/assets/_page.0zMz792f.css 10.78 kB
.svelte-kit/output/server/_app/immutable/assets/AgentChat.C1veD2Ar.css 11.37 kB
.svelte-kit/output/server/_app/immutable/assets/AppShell.ymPBcNjP.css 12.64 kB
.svelte-kit/output/server/_app/immutable/assets/_layout.B1MJuaEd.css 19.33 kB
.svelte-kit/output/server/_app/immutable/assets/StudioGenerate.pbw6ZUUi.css 20.96 kB
.svelte-kit/output/server/_app/immutable/assets/_page.DdIn3xwb.css 22.49 kB
.svelte-kit/output/server/_app/immutable/assets/_page.C53qzaKb.css 26.52 kB
.svelte-kit/output/server/_app/immutable/assets/_page.BC6N9k4h.css 31.67 kB
.svelte-kit/output/server/entries/pages/_layout.ts.js 0.06 kB
.svelte-kit/output/server/entries/pages/agents/_page.ts.js 0.08 kB
.svelte-kit/output/server/entries/pages/library/_page.ts.js 0.08 kB
.svelte-kit/output/server/entries/pages/models/_page.ts.js 0.08 kB
.svelte-kit/output/server/entries/pages/settings/_page.ts.js 0.08 kB
.svelte-kit/output/server/entries/pages/workbench/_page.ts.js 0.08 kB
.svelte-kit/output/server/entries/pages/workbench/_id_/_page.ts.js 0.08 kB
.svelte-kit/output/server/entries/pages/_page.ts.js 0.12 kB
.svelte-kit/output/server/entries/hooks.server.js 0.13 kB
.svelte-kit/output/server/chunks/wideLayout.js 0.15 kB
.svelte-kit/output/server/entries/pages/build-scene/_page.ts.js 0.17 kB
.svelte-kit/output/server/entries/pages/canvas/_page.ts.js 0.17 kB
.svelte-kit/output/server/entries/pages/canvas/_id_/_page.ts.js 0.17 kB
.svelte-kit/output/server/entries/pages/playground/_page.ts.js 0.17 kB
.svelte-kit/output/server/entries/pages/projects/_page.ts.js 0.17 kB
.svelte-kit/output/server/entries/pages/image/_page.svelte.js 0.20 kB
.svelte-kit/output/server/entries/pages/video/_page.svelte.js 0.20 kB
.svelte-kit/output/server/entries/pages/project/_id_/_page.ts.js 0.20 kB
.svelte-kit/output/server/chunks/index-server.js 0.22 kB
.svelte-kit/output/server/env.js 0.23 kB
.svelte-kit/output/server/chunks/client.js 0.31 kB
.svelte-kit/output/server/chunks/index-server2.js 0.31 kB
.svelte-kit/output/server/internal.js 0.35 kB
.svelte-kit/output/server/entries/fallbacks/error.svelte.js 0.35 kB
.svelte-kit/output/server/chunks/state.svelte.js 0.45 kB
.svelte-kit/output/server/chunks/Skeleton.js 0.48 kB
.svelte-kit/output/server/chunks/Spinner.js 0.52 kB
.svelte-kit/output/server/entries/pages/canvas/_id_/_layout.svelte.js 0.55 kB
.svelte-kit/output/server/chunks/index2.js 0.57 kB
.svelte-kit/output/server/chunks/server.js 0.62 kB
.svelte-kit/output/server/entries/pages/canvas/_page.svelte.js 0.66 kB
.svelte-kit/output/server/entries/pages/playground/_page.svelte.js 0.67 kB
.svelte-kit/output/server/chunks/utils2.js 0.72 kB
.svelte-kit/output/server/chunks/ids.js 0.77 kB
.svelte-kit/output/server/chunks/Icon.svelte_svelte_type_style_lang.js 0.86 kB
.svelte-kit/output/server/chunks/endpointKind.js 0.95 kB
.svelte-kit/output/server/chunks/createMutation.js 1.03 kB
.svelte-kit/output/server/chunks/EmptyState.js 1.04 kB
.svelte-kit/output/server/chunks/toast.js 1.14 kB
.svelte-kit/output/server/chunks/context.js 1.20 kB
.svelte-kit/output/server/chunks/LanguageSwitcher.js 1.36 kB
.svelte-kit/output/server/chunks/AppShell.svelte_svelte_type_style_lang.js 1.46 kB
.svelte-kit/output/server/chunks/SafeMedia.js 1.65 kB
.svelte-kit/output/server/chunks/ProgressBar.js 1.88 kB
.svelte-kit/output/server/chunks/useUpload.svelte.js 2.16 kB
.svelte-kit/output/server/chunks/takeDelete.svelte.js 2.57 kB
.svelte-kit/output/server/chunks/index3.js 2.77 kB
.svelte-kit/output/server/entries/pages/_layout.svelte.js 3.08 kB
.svelte-kit/output/server/chunks/ConfirmDialog.js 3.25 kB
.svelte-kit/output/server/chunks/internal.js 3.79 kB
.svelte-kit/output/server/entries/pages/workbench/_page.svelte.js 4.93 kB
.svelte-kit/output/server/chunks/exports.js 5.54 kB
.svelte-kit/output/server/chunks/Icon.js 6.04 kB
.svelte-kit/output/server/entries/pages/agents/_page.svelte.js 7.88 kB
.svelte-kit/output/server/entries/pages/tasks/_page.svelte.js 13.44 kB
.svelte-kit/output/server/entries/pages/settings/_page.svelte.js 15.12 kB
.svelte-kit/output/server/chunks/LanguageSwitcher.svelte_svelte_type_style_lang.js 16.32 kB
.svelte-kit/output/server/entries/pages/library/_page.svelte.js 19.77 kB
.svelte-kit/output/server/entries/pages/_page.svelte.js 25.48 kB
.svelte-kit/output/server/chunks/RefImageGrid.js 25.96 kB
.svelte-kit/output/server/chunks/root.js 29.43 kB
.svelte-kit/output/server/chunks/AgentComposer.js 34.01 kB
.svelte-kit/output/server/chunks/utils.js 34.14 kB
.svelte-kit/output/server/entries/pages/models/_page.svelte.js 36.24 kB
.svelte-kit/output/server/remote-entry.js 36.81 kB
.svelte-kit/output/server/chunks/AppShell.js 39.27 kB
.svelte-kit/output/server/entries/pages/workbench/_id_/_page.svelte.js 50.91 kB
.svelte-kit/output/server/entries/pages/build-scene/_page.svelte.js 51.30 kB
.svelte-kit/output/server/chunks/StudioGenerate.js 99.49 kB
.svelte-kit/output/server/entries/pages/connections/_page.svelte.js 99.69 kB
.svelte-kit/output/server/chunks/index.js 101.49 kB
.svelte-kit/output/server/index.js 130.14 kB
.svelte-kit/output/server/entries/pages/canvas/_id_/_page.svelte.js 145.51 kB
.svelte-kit/output/server/chunks/i18n.svelte.js 786.88 kB
✓ built in 6.97s
Run npm run preview to preview your production build locally.
> Using @sveltejs/adapter-static
Wrote site to "build"
✔ done
+34
View File
@@ -0,0 +1,34 @@
> calliope-web@1.5.8 check
> svelte-kit sync && svelte-check --tsconfig ./tsconfig.json
Loading svelte-check in workspace: <workspace>/calliope-web
Getting Svelte diagnostics...
<workspace>/calliope-web/src/lib/components/video/PromptPreviewModal.svelte:125:12
Error: Expected 1-2 arguments, but got 0. (ts)
attemptedFor = key;
$preview.mutate();
});
<workspace>/calliope-web/src/lib/components/workbench/ShotStagePanel.svelte:156:6
Warn: `<video>` elements must have a `<track kind="captions">`
https://svelte.dev/e/a11y_media_has_caption (svelte)
{#if shot.job?.status === 'done' && shot.job.output}
<video
class="vshot-video"
src={assetUrl(shot.job.output) ?? undefined}
controls
preload="metadata"
></video>
{/if}
<workspace>/calliope-web/src/routes/workbench/[id]/+page.svelte:345:4
Warn: Self-closing HTML tags for non-void elements are ambiguous — use `<div ...></div>` rather than `<div ... />`
https://svelte.dev/e/element_invalid_self_closing_tag (svelte)
<Skeleton height="64px" />
<div class="gap" />
<Skeleton height="280px" />
====================================
svelte-check found 1 error and 2 warnings in 3 files
+51
View File
@@ -0,0 +1,51 @@
> calliope-web@1.5.8 test
> node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs && node scripts/check-export-frame.mjs
ok method-less localStorage (Node SSR shape) degrades to null without throwing
ok absent localStorage degrades to null
ok throwing localStorage access degrades to null
ok setItem failure is swallowed
ok working storage round-trips and removes
ok no unsafe `typeof localStorage` guards in src/
storage SSR checks passed.
ok insecure-context crypto (getRandomValues only) yields unique v4 UUIDs
ok method-less crypto degrades to unique best-effort ids without throwing
ok absent crypto degrades to unique best-effort ids without throwing
ok native randomUUID preferred when available
ok no direct `crypto.randomUUID()` calls in src/
randomId insecure-context checks passed.
ok edit load strips the frontmatter block
ok edit load keeps the body
ok strip boundary is exact
ok strip is idempotent
ok second edit-save-reload adds no layer
ok plain body unchanged
ok page imports the shared strip helper
ok no \A-anchored strip regex left behind
skill frontmatter checks passed.
ok first try is ~1 frame before the end, never the end itself
ok later tries step strictly further back
ok tries stay within ~1s of the end
ok short clip clamps to 0 without duplicates
ok unknown / zero duration falls back to t=0
ok base name from /api/file?path=…
ok base name from a Windows path
ok base name from a plain URL
ok unsafe characters are replaced
ok blob URL without a name falls back to 'video'
ok PNG name names the video, the tag and the frame time
ok spaces in the tag become dashes
ok fully transparent sample → empty
ok pure black → black
ok near-black encoder noise (16..18) → black
ok mid-grey picture → not blank
ok dim but visible picture (≈50) → not blank
ok dark night scene with a bright highlight → not blank
ok 404 → not-found
ok 403 → forbidden
ok 500 → network
ok 206 / 200 are not errors
export-frame checks passed.
+83
View File
@@ -0,0 +1,83 @@
{
"schema_version": 1,
"cases": [
{
"id": "01",
"category": "0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大",
"source_path": "example_ComfyUI_workflows/0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大/ControlNet_aux_常用样例.json",
"source_sha": "03d2fe6ae190c7cae17b89947f8975016616014a",
"export_file": "01.api.json",
"binding_file": "01.binding.json",
"compare_focus": "预处理并行分支、拼接预览、位置控件与Reroute移除",
"required_materials": "原ComfyUI前端从此SHA源图原样导出的API JSON(未经手改)及可用的相关节点定义;导出来源/前端版本/节点版本须记录",
"comparison": "节点ID/类型、全部具名inputs及其类型/现值、上游ID/输出槽;_meta单独报告;不忽略种子或文件名差异"
},
{
"id": "02",
"category": "1-语音生成",
"source_path": "example_ComfyUI_workflows/1-语音生成/Qwen3-ASR-Subtitle语音转字幕.json",
"source_sha": "08a7519a6a6c532b8a0670dce3deff70881527ef",
"export_file": "02.api.json",
"binding_file": "02.binding.json",
"compare_focus": "音频输入、术语提示连线、四个STRING出口(不能伪装文件)",
"required_materials": "原ComfyUI前端从此SHA源图原样导出的API JSON(未经手改)及可用的相关节点定义;导出来源/前端版本/节点版本须记录",
"comparison": "节点ID/类型、全部具名inputs及其类型/现值、上游ID/输出槽;_meta单独报告;不忽略种子或文件名差异"
},
{
"id": "03",
"category": "2-音乐生成",
"source_path": "example_ComfyUI_workflows/2-音乐生成/minimax_music_文生音乐.json",
"source_sha": "c248b9f72ba42a8fe2e27ab3762d2ef56869b0f8",
"export_file": "03.api.json",
"binding_file": "03.binding.json",
"compare_focus": "音乐子图展开、Caption/Lyrics、普通/分块解码开关、音频保存",
"required_materials": "原ComfyUI前端从此SHA源图原样导出的API JSON(未经手改)及可用的相关节点定义;导出来源/前端版本/节点版本须记录",
"comparison": "节点ID/类型、全部具名inputs及其类型/现值、上游ID/输出槽;_meta单独报告;不忽略种子或文件名差异"
},
{
"id": "04",
"category": "3-图像生成",
"source_path": "example_ComfyUI_workflows/3-图像生成/Qwen-Image-2.1/Qwen_image2.1_文生图_提示词增强.json",
"source_sha": "791b23c19730a5a88dbf19247bfae5c814df9741",
"export_file": "04.api.json",
"binding_file": "04.binding.json",
"compare_focus": "文生图子图、提示词增强开关、宽高连线、VAE及图像输出",
"required_materials": "原ComfyUI前端从此SHA源图原样导出的API JSON(未经手改)及可用的相关节点定义;导出来源/前端版本/节点版本须记录",
"comparison": "节点ID/类型、全部具名inputs及其类型/现值、上游ID/输出槽;_meta单独报告;不忽略种子或文件名差异"
},
{
"id": "05",
"category": "4-视频生成",
"source_path": "example_ComfyUI_workflows/4-视频生成/Minimax-h3/MiniMax_H3_导演台时间线素材_双采样潜空间放大_多分段拼接.json",
"source_sha": "ad7d38328e79df4fc9e76664148e45644fa6e4c8",
"export_file": "05.api.json",
"binding_file": "05.binding.json",
"compare_focus": "时间线JSON、分段计划、二采与模型分支、音视频合成",
"required_materials": "原ComfyUI前端从此SHA源图原样导出的API JSON(未经手改)及可用的相关节点定义;导出来源/前端版本/节点版本须记录",
"comparison": "节点ID/类型、全部具名inputs及其类型/现值、上游ID/输出槽;_meta单独报告;不忽略种子或文件名差异"
},
{
"id": "06",
"category": "5-数字人",
"source_path": "example_ComfyUI_workflows/5-数字人/Wan2.1_I2V_InfiniteTalk_rCM_数字人对口型多动态(KJ版).json",
"source_sha": "f902213a4d0368017e451676e2998b547409f9b9",
"export_file": "06.api.json",
"binding_file": "06.binding.json",
"compare_focus": "人物图和音频输入、Wav2Vec本地/下载分支、GGUF模型配套、MultiTalk条件及音视频输出",
"required_materials": "原ComfyUI前端从此SHA源图原样导出的API JSON(未经手改)及可用的相关节点定义;导出来源/前端版本/节点版本须记录",
"comparison": "节点ID/类型、全部具名inputs及其类型/现值、上游ID/输出槽;_meta单独报告;不忽略种子或文件名差异"
},
{
"id": "07",
"category": "6-动作迁移",
"source_path": "example_ComfyUI_workflows/6-动作迁移/Wan_Animate2_动作迁移_for循环长视频.json",
"source_sha": "b8708632a9c585f697aa1dae6d32d5a2996715b2",
"export_file": "07.api.json",
"binding_file": "07.binding.json",
"compare_focus": "循环/子图展开、continue_motion、video_frame_offset、尾批及音轨",
"required_materials": "原ComfyUI前端从此SHA源图原样导出的API JSON(未经手改)及可用的相关节点定义;导出来源/前端版本/节点版本须记录",
"comparison": "节点ID/类型、全部具名inputs及其类型/现值、上游ID/输出槽;_meta单独报告;不忽略种子或文件名差异"
}
],
"not_execution_evidence": true
}
+8
View File
@@ -0,0 +1,8 @@
............. [100%]
=============================== warnings summary ===============================
calliope-backend/.venv/lib/python3.11/site-packages/starlette/testclient.py:53
<workspace>/calliope-backend/.venv/lib/python3.11/site-packages/starlette/testclient.py:53: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.
_PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
13 passed, 1 warning in 3.98s
+5
View File
@@ -0,0 +1,5 @@
{
"new_findings": [],
"existing_findings_on_changed_files": 15,
"baseline_findings": 17
}
+6
View File
@@ -0,0 +1,6 @@
{
"source_url": "http://192.168.200.12:8188/object_info",
"captured_at": "2026-10-27T15:27:26.544196+00:00",
"status": "unavailable",
"error": "ConnectError: [Errno 111] Connection refused"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,929 @@
{
"dependencies": {
"check_state": "unchecked",
"checked_at": null,
"missing": [],
"model_locations": "待目标机提供",
"models": [
"minimax_h3_audio_vae_fp32.safetensors",
"minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
"minimax_h3_video_vae_int8_convrot.safetensors",
"qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors"
],
"node_packs": [],
"node_types": [
"BasicGuider",
"BasicScheduler",
"CLIPLoader",
"ComfyMathExpression",
"CreateVideo",
"H3SLAAttention",
"KSamplerSelect",
"LoadImage",
"MiniMaxH3ImageToVideo",
"MiniMaxH3SigmaShift",
"PrimitiveFloat",
"PrimitiveStringMultiline",
"RandomNoise",
"ResolutionSelector",
"SamplerCustomAdvanced",
"SaveVideo",
"UNETLoader",
"VAEDecode",
"VAEDecodeAudio",
"VAELoader"
],
"source": null
},
"draft": true,
"errors": [
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:PrimitiveStringMultiline"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:ResolutionSelector"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:ComfyMathExpression"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:VAELoader"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:VAELoader"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:UNETLoader"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:CLIPLoader"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:RandomNoise"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:PrimitiveFloat"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:KSamplerSelect"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:LoadImage"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:ComfyMathExpression"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:H3SLAAttention"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:MiniMaxH3ImageToVideo"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:MiniMaxH3SigmaShift"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:BasicScheduler"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:BasicGuider"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:SamplerCustomAdvanced"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:VAEDecodeAudio"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:VAEDecode"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:CreateVideo"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:SaveVideo"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "未得到可执行节点"
}
],
"io": {
"inputs": [],
"outputs": []
},
"materials": [],
"params": [],
"purpose": "H3 首尾帧预设含 LoadImage、ImageToVideo 与 H3SLAAttention。",
"snapshot_sha": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
"source_links": [
{
"link": [
1,
133,
0,
132,
1,
"FLOAT"
],
"scope": ""
},
{
"link": [
2,
127,
0,
900,
0,
"MODEL"
],
"scope": ""
},
{
"link": [
3,
128,
0,
131,
0,
"CLIP"
],
"scope": ""
},
{
"link": [
4,
119,
0,
131,
1,
"VAE"
],
"scope": ""
},
{
"link": [
5,
1,
0,
131,
2,
"STRING"
],
"scope": ""
},
{
"link": [
6,
115,
0,
131,
3,
"INT"
],
"scope": ""
},
{
"link": [
7,
115,
1,
131,
4,
"INT"
],
"scope": ""
},
{
"link": [
8,
132,
1,
131,
5,
"INT"
],
"scope": ""
},
{
"link": [
9,
901,
0,
131,
6,
"IMAGE"
],
"scope": ""
},
{
"link": [
10,
900,
0,
142,
0,
"MODEL"
],
"scope": ""
},
{
"link": [
11,
142,
0,
124,
0,
"MODEL"
],
"scope": ""
},
{
"link": [
12,
142,
0,
126,
0,
"MODEL"
],
"scope": ""
},
{
"link": [
13,
131,
0,
126,
1,
"CONDITIONING"
],
"scope": ""
},
{
"link": [
14,
129,
0,
125,
0,
"NOISE"
],
"scope": ""
},
{
"link": [
15,
126,
0,
125,
1,
"GUIDER"
],
"scope": ""
},
{
"link": [
16,
135,
0,
125,
2,
"SAMPLER"
],
"scope": ""
},
{
"link": [
17,
124,
0,
125,
3,
"SIGMAS"
],
"scope": ""
},
{
"link": [
18,
131,
1,
125,
4,
"LATENT"
],
"scope": ""
},
{
"link": [
19,
125,
0,
121,
0,
"LATENT"
],
"scope": ""
},
{
"link": [
20,
120,
0,
121,
1,
"VAE"
],
"scope": ""
},
{
"link": [
21,
125,
0,
122,
0,
"LATENT"
],
"scope": ""
},
{
"link": [
22,
119,
0,
122,
1,
"VAE"
],
"scope": ""
},
{
"link": [
23,
122,
0,
130,
0,
"IMAGE"
],
"scope": ""
},
{
"link": [
24,
121,
0,
130,
1,
"AUDIO"
],
"scope": ""
},
{
"link": [
25,
130,
0,
92,
0,
"VIDEO"
],
"scope": ""
}
],
"source_nodes": [
{
"class_type": "PrimitiveStringMultiline",
"inputs": [],
"mode": 0,
"node_id": "1",
"source_values": [
"PASTE YOUR PROMPT HERE. For image-to-video describe the motion from the first frame; the frame fixes the look. Use the MiniMax-H3 prompt contract (integrated_multimodal_description / overall_soundscape / non_diegetic_music)."
],
"title": "PROMPT (feeds every stage)"
},
{
"class_type": "ResolutionSelector",
"inputs": [],
"mode": 0,
"node_id": "115",
"source_values": [
"16:9 (Widescreen)",
0.7,
32
],
"title": "ResolutionSelector"
},
{
"class_type": "ComfyMathExpression",
"inputs": [
{
"link": null,
"name": "values",
"type": "COMFY_AUTOGROW_V3"
}
],
"mode": 0,
"node_id": "116",
"source_values": [
"a"
],
"title": "ComfyMathExpression"
},
{
"class_type": "VAELoader",
"inputs": [],
"mode": 0,
"node_id": "119",
"source_values": [
"minimax_h3_video_vae_int8_convrot.safetensors"
],
"title": "VAELoader"
},
{
"class_type": "VAELoader",
"inputs": [],
"mode": 0,
"node_id": "120",
"source_values": [
"minimax_h3_audio_vae_fp32.safetensors"
],
"title": "VAELoader"
},
{
"class_type": "UNETLoader",
"inputs": [],
"mode": 0,
"node_id": "127",
"source_values": [
"minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
"default"
],
"title": "UNETLoader"
},
{
"class_type": "CLIPLoader",
"inputs": [],
"mode": 0,
"node_id": "128",
"source_values": [
"qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
"minimax",
"default"
],
"title": "CLIPLoader"
},
{
"class_type": "RandomNoise",
"inputs": [],
"mode": 0,
"node_id": "129",
"source_values": [
42,
"fixed"
],
"title": "RandomNoise"
},
{
"class_type": "PrimitiveFloat",
"inputs": [],
"mode": 0,
"node_id": "133",
"source_values": [
10.0
],
"title": "PrimitiveFloat"
},
{
"class_type": "KSamplerSelect",
"inputs": [],
"mode": 0,
"node_id": "135",
"source_values": [
"res_multistep"
],
"title": "KSamplerSelect"
},
{
"class_type": "LoadImage",
"inputs": [],
"mode": 0,
"node_id": "901",
"source_values": [
"first_frame.png"
],
"title": "FIRST FRAME (add a second LoadImage into last_frame for FL2V)"
},
{
"class_type": "ComfyMathExpression",
"inputs": [
{
"link": null,
"name": "values",
"type": "COMFY_AUTOGROW_V3"
},
{
"link": 1,
"name": "values.a",
"type": "FLOAT"
}
],
"mode": 0,
"node_id": "132",
"source_values": [
"max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17"
],
"title": "ComfyMathExpression"
},
{
"class_type": "H3SLAAttention",
"inputs": [
{
"link": 2,
"name": "model",
"type": "MODEL"
}
],
"mode": 0,
"node_id": "900",
"source_values": [
0.9,
"64",
8192,
0,
true,
true
],
"title": "H3 SLA Attention 0.9/64 (their graph)"
},
{
"class_type": "MiniMaxH3ImageToVideo",
"inputs": [
{
"link": 3,
"name": "clip",
"type": "CLIP"
},
{
"link": 4,
"name": "vae",
"type": "VAE"
},
{
"link": 5,
"name": "prompt",
"type": "STRING",
"widget": {
"name": "prompt"
}
},
{
"link": 6,
"name": "width",
"type": "INT",
"widget": {
"name": "width"
}
},
{
"link": 7,
"name": "height",
"type": "INT",
"widget": {
"name": "height"
}
},
{
"link": 8,
"name": "length",
"type": "INT",
"widget": {
"name": "length"
}
},
{
"link": 9,
"name": "first_frame",
"type": "IMAGE"
},
{
"link": null,
"name": "last_frame",
"type": "IMAGE"
}
],
"mode": 0,
"node_id": "131",
"source_values": [
"",
1344,
768,
124
],
"title": "MiniMaxH3ImageToVideo"
},
{
"class_type": "MiniMaxH3SigmaShift",
"inputs": [
{
"link": 10,
"name": "model",
"type": "MODEL"
}
],
"mode": 0,
"node_id": "142",
"source_values": [
12.0,
3.0
],
"title": "MiniMaxH3SigmaShift"
},
{
"class_type": "BasicScheduler",
"inputs": [
{
"link": 11,
"name": "model",
"type": "MODEL"
}
],
"mode": 0,
"node_id": "124",
"source_values": [
"simple",
4,
1.0
],
"title": "BasicScheduler"
},
{
"class_type": "BasicGuider",
"inputs": [
{
"link": 12,
"name": "model",
"type": "MODEL"
},
{
"link": 13,
"name": "conditioning",
"type": "CONDITIONING"
}
],
"mode": 0,
"node_id": "126",
"source_values": [],
"title": "BasicGuider"
},
{
"class_type": "SamplerCustomAdvanced",
"inputs": [
{
"link": 14,
"name": "noise",
"type": "NOISE"
},
{
"link": 15,
"name": "guider",
"type": "GUIDER"
},
{
"link": 16,
"name": "sampler",
"type": "SAMPLER"
},
{
"link": 17,
"name": "sigmas",
"type": "SIGMAS"
},
{
"link": 18,
"name": "latent_image",
"type": "LATENT"
}
],
"mode": 0,
"node_id": "125",
"source_values": [],
"title": "SamplerCustomAdvanced"
},
{
"class_type": "VAEDecodeAudio",
"inputs": [
{
"link": 19,
"name": "samples",
"type": "LATENT"
},
{
"link": 20,
"name": "vae",
"type": "VAE"
}
],
"mode": 0,
"node_id": "121",
"source_values": [],
"title": "VAEDecodeAudio"
},
{
"class_type": "VAEDecode",
"inputs": [
{
"link": 21,
"name": "samples",
"type": "LATENT"
},
{
"link": 22,
"name": "vae",
"type": "VAE"
}
],
"mode": 0,
"node_id": "122",
"source_values": [],
"title": "VAEDecode"
},
{
"class_type": "CreateVideo",
"inputs": [
{
"link": 23,
"name": "images",
"type": "IMAGE"
},
{
"link": 24,
"name": "audio",
"type": "AUDIO"
}
],
"mode": 0,
"node_id": "130",
"source_values": [
24,
8,
"sRGB"
],
"title": "CreateVideo"
},
{
"class_type": "SaveVideo",
"inputs": [
{
"link": 25,
"name": "video",
"type": "VIDEO"
}
],
"mode": 0,
"node_id": "92",
"source_values": [
"video/04_i2v_fl2v_4step_sla",
"auto",
"auto"
],
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
}
],
"source_notes": [],
"source_path": "example_ComfyUI_workflows/4-视频生成/uncensored/minimax-h3首尾帧生视频无审核版.json",
"source_review": {
"complete": false,
"evidence": {
"link_count": 25,
"model_references": [
"minimax_h3_audio_vae_fp32.safetensors",
"minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
"minimax_h3_video_vae_int8_convrot.safetensors",
"qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors"
],
"named_value_count": 0,
"node_count": 22,
"note_node_ids": [],
"unnamed_widget_node_ids": [
"1",
"115",
"116",
"119",
"120",
"127",
"128",
"129",
"133",
"135",
"901",
"132",
"900",
"131",
"142",
"124",
"130",
"92"
]
},
"instruction": "以实际连线确认首尾图角色;标题不证明两帧约束的具体强度与效果。",
"purpose": "H3 首尾帧预设含 LoadImage、ImageToVideo 与 H3SLAAttention。",
"reviewed_named_fields": [],
"scope": [
"结构及用途局部审校",
"源具名参数/注释/连线/素材模型用途人工语义局部审校"
],
"semantic_review": {
"batch": "HEL-892-third",
"conclusions": [
"901只选first_frame.png,131为ImageToVideo",
"源节点清单未见第二个尾帧LoadImage,因此标题首尾帧不证明已连接尾帧。1提示也描述从首帧运动,130合成到92",
"先核对是否需要另接尾帧与实际端口定义,源无注释支持自动尾帧生成/补全。"
],
"evidence_refs": {
"nodes": [
{
"node_id": "1",
"pointer": "/source_nodes/0"
},
{
"node_id": "901",
"pointer": "/source_nodes/10"
},
{
"node_id": "131",
"pointer": "/source_nodes/13"
},
{
"node_id": "130",
"pointer": "/source_nodes/20"
},
{
"node_id": "92",
"pointer": "/source_nodes/21"
}
],
"notes": [],
"source_graph": "/source_links(按scope区分主图与子图;连线ID仅在各自scope内有效)"
},
"mechanical_inventory": {
"link_count": 25,
"named_value_count": 0,
"note_count": 0,
"position_widget_node_count": 18
},
"named_parameter_finding": "未发现可由源文件确认的具名参数",
"operation_check": "901只选first_frame.png,131为ImageToVideo;源节点清单未见第二个尾帧LoadImage,因此标题首尾帧不证明已连接尾帧。1提示也描述从首帧运动,130合成到92;先核对是否需要另接尾帧与实际端口定义,源无注释支持自动尾帧生成/补全。",
"unknowns": [
"位置数组未按位置具名,具名字段单位/合法范围/调整效果未获定义验证",
"注释中的效果、性能、版本支持为源作者陈述,未作外部或环境验证",
"静音/旁路、虚拟节点、子图、开关与连线覆盖的实际执行结果待原前端API导出",
"模型/素材引用只表示源文件出现,实际安装、位置、可用性和输出编码待真实环境核对"
]
},
"source_sha": "2a3bbe572292812522746b3429a9424e6dcdd5f3",
"unconfirmed": [
"逐参数语义、单位、范围和调整效果未定稿",
"UI 位置控件未具名,含子图和旁路的实际执行路径待黄金导出",
"模型/节点包位置、素材可用性及实际输出未在目标环境核对"
]
},
"source_review_sha": "dd63634a370085460ab34da45a3877e16066bc40698d966fc7810efaf772bfd8",
"status": {
"doc_done": false,
"exec_verified": false,
"imported": true
},
"steps": [
"以实际连线确认首尾图角色;标题不证明两帧约束的具体强度与效果。",
"901只选first_frame.png,131为ImageToVideo;源节点清单未见第二个尾帧LoadImage,因此标题首尾帧不证明已连接尾帧。1提示也描述从首帧运动,130合成到92;先核对是否需要另接尾帧与实际端口定义,源无注释支持自动尾帧生成/补全。",
"打开源工作流 example_ComfyUI_workflows/4-视频生成/uncensored/minimax-h3首尾帧生视频无审核版.json,逐项核对下列源节点与注释。",
"补齐节点定义、模型来源及所需素材;完成输入输出映射核对。",
"明确触发真实测试后核对产物;本批未提交生成任务。"
],
"title": "minimax-h3首尾帧生视频无审核版"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,410 @@
{
"dependencies": {
"check_state": "unchecked",
"checked_at": null,
"missing": [],
"model_locations": "待目标机提供",
"models": [
"birefnet.safetensors"
],
"node_packs": [],
"node_types": [
"InvertMask",
"JoinImageWithAlpha",
"LoadBackgroundRemovalModel",
"LoadImage",
"MarkdownNote",
"MaskPreview",
"PreviewImage",
"RemoveBackground"
],
"source": null
},
"draft": true,
"errors": [
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:RemoveBackground"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:InvertMask"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:JoinImageWithAlpha"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:LoadBackgroundRemovalModel"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:PreviewImage"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:MaskPreview"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:LoadImage"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "未得到可执行节点"
}
],
"io": {
"inputs": [],
"outputs": []
},
"materials": [],
"params": [],
"purpose": "BiRefNet 背景移除:LoadBackgroundRemovalModel、RemoveBackground、InvertMask 和 JoinImageWithAlpha 组成图像及遮罩预览分支。",
"snapshot_sha": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
"source_links": [
{
"link": [
17,
23,
0,
22,
1,
"BACKGROUND_REMOVAL"
],
"scope": ""
},
{
"link": [
18,
22,
0,
24,
0,
"MASK"
],
"scope": ""
},
{
"link": [
19,
24,
0,
25,
1,
"MASK"
],
"scope": ""
},
{
"link": [
20,
17,
0,
22,
0,
"IMAGE"
],
"scope": ""
},
{
"link": [
21,
17,
0,
25,
0,
"IMAGE"
],
"scope": ""
},
{
"link": [
22,
25,
0,
18,
0,
"IMAGE"
],
"scope": ""
},
{
"link": [
23,
22,
0,
20,
0,
"MASK"
],
"scope": ""
}
],
"source_nodes": [
{
"class_type": "RemoveBackground",
"inputs": [
{
"link": 20,
"localized_name": "image",
"name": "image",
"type": "IMAGE"
},
{
"link": 17,
"localized_name": "bg_removal_model",
"name": "bg_removal_model",
"type": "BACKGROUND_REMOVAL"
}
],
"mode": 0,
"node_id": "22",
"source_values": [],
"title": null
},
{
"class_type": "InvertMask",
"inputs": [
{
"link": 18,
"localized_name": "遮罩",
"name": "mask",
"type": "MASK"
}
],
"mode": 0,
"node_id": "24",
"source_values": [],
"title": null
},
{
"class_type": "JoinImageWithAlpha",
"inputs": [
{
"link": 21,
"localized_name": "图像",
"name": "image",
"type": "IMAGE"
},
{
"link": 19,
"localized_name": "阿尔法",
"name": "alpha",
"type": "MASK"
}
],
"mode": 0,
"node_id": "25",
"source_values": [],
"title": null
},
{
"class_type": "LoadBackgroundRemovalModel",
"inputs": [
{
"link": null,
"localized_name": "bg_removal_name",
"name": "bg_removal_name",
"type": "COMBO",
"widget": {
"name": "bg_removal_name"
}
}
],
"mode": 0,
"node_id": "23",
"source_values": [
"birefnet.safetensors"
],
"title": null
},
{
"class_type": "MarkdownNote",
"inputs": [],
"mode": 0,
"node_id": "21",
"source_values": [
"## Model Link (for Local Users)\n\n**background_removal**\n\n- [birefnet.safetensors](https://huggingface.co/Comfy-Org/BiRefNet/resolve/main/background_removal/birefnet.safetensors)\n\n\n## Model Storage Location\n\n```\n📂 ComfyUI/\n├── 📂 models/\n│ └── 📂 background_removal/\n│ └── birefnet.safetensors\n```\n\n## Report Issue\n\nNote: Please update ComfyUI first ([guide](https://docs.comfy.org/installation/update_comfyui)) and prepare required models. Desktop/Cloud updates follow stable releases, so some nightly-supported models may not be available yet.\n\n- Cannot run / runtime errors: [ComfyUI/issues](https://github.com/comfyanonymous/ComfyUI/issues)\n- UI / frontend issues: [ComfyUI_frontend/issues](https://github.com/Comfy-Org/ComfyUI_frontend/issues)\n- Workflow issues: [workflow_templates/issues](https://github.com/Comfy-Org/workflow_templates/issues)\n"
],
"title": "Model Link"
},
{
"class_type": "PreviewImage",
"inputs": [
{
"link": 22,
"localized_name": "图像",
"name": "images",
"type": "IMAGE"
}
],
"mode": 0,
"node_id": "18",
"source_values": [],
"title": null
},
{
"class_type": "MaskPreview",
"inputs": [
{
"label": "遮罩",
"link": 23,
"localized_name": "遮罩",
"name": "mask",
"type": "MASK"
}
],
"mode": 0,
"node_id": "20",
"source_values": [],
"title": null
},
{
"class_type": "LoadImage",
"inputs": [
{
"link": null,
"localized_name": "图像",
"name": "image",
"type": "COMBO",
"widget": {
"name": "image"
}
},
{
"link": null,
"localized_name": "选择文件上传",
"name": "upload",
"type": "IMAGEUPLOAD",
"widget": {
"name": "upload"
}
}
],
"mode": 0,
"node_id": "17",
"source_values": [
"20260425_192628_Z-Image_00001_.png",
"image"
],
"title": null
}
],
"source_notes": [
{
"content": [
"## Model Link (for Local Users)\n\n**background_removal**\n\n- [birefnet.safetensors](https://huggingface.co/Comfy-Org/BiRefNet/resolve/main/background_removal/birefnet.safetensors)\n\n\n## Model Storage Location\n\n```\n📂 ComfyUI/\n├── 📂 models/\n│ └── 📂 background_removal/\n│ └── birefnet.safetensors\n```\n\n## Report Issue\n\nNote: Please update ComfyUI first ([guide](https://docs.comfy.org/installation/update_comfyui)) and prepare required models. Desktop/Cloud updates follow stable releases, so some nightly-supported models may not be available yet.\n\n- Cannot run / runtime errors: [ComfyUI/issues](https://github.com/comfyanonymous/ComfyUI/issues)\n- UI / frontend issues: [ComfyUI_frontend/issues](https://github.com/Comfy-Org/ComfyUI_frontend/issues)\n- Workflow issues: [workflow_templates/issues](https://github.com/Comfy-Org/workflow_templates/issues)\n"
],
"node_id": "21"
}
],
"source_path": "example_ComfyUI_workflows/0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大/birefnet_图像移除背景.json",
"source_review": {
"complete": false,
"evidence": {
"link_count": 7,
"model_references": [
"birefnet.safetensors"
],
"named_value_count": 0,
"node_count": 8,
"note_node_ids": [
"21"
],
"unnamed_widget_node_ids": [
"23",
"17"
]
},
"instruction": "替换 LoadImage 后分别检查 MaskPreview 与带透明通道的 PreviewImage;模型引用 birefnet.safetensors 仍需核对实际位置。",
"purpose": "BiRefNet 背景移除:LoadBackgroundRemovalModel、RemoveBackground、InvertMask 和 JoinImageWithAlpha 组成图像及遮罩预览分支。",
"reviewed_named_fields": [],
"scope": [
"结构及用途局部审校",
"源具名参数/注释/连线/素材模型用途人工语义局部审校"
],
"semantic_review": {
"batch": "HEL-892-third",
"conclusions": [
"17图像同时送22分割与25合成",
"22输出MASK经24反相后进入25的alpha通道,20直接预览原遮罩。21注释列background_removal/birefnet.safetensors,23实际选择同名文件",
"先检查透明边缘与遮罩极性,注释目录不等于本机安装事实。"
],
"evidence_refs": {
"nodes": [
{
"node_id": "22",
"pointer": "/source_nodes/0"
},
{
"node_id": "24",
"pointer": "/source_nodes/1"
},
{
"node_id": "25",
"pointer": "/source_nodes/2"
},
{
"node_id": "23",
"pointer": "/source_nodes/3"
},
{
"node_id": "21",
"pointer": "/source_nodes/4"
},
{
"node_id": "20",
"pointer": "/source_nodes/6"
},
{
"node_id": "17",
"pointer": "/source_nodes/7"
}
],
"notes": [
{
"node_id": "21",
"pointer": "/source_notes/0"
}
],
"source_graph": "/source_links(按scope区分主图与子图;连线ID仅在各自scope内有效)"
},
"mechanical_inventory": {
"link_count": 7,
"named_value_count": 0,
"note_count": 1,
"position_widget_node_count": 2
},
"named_parameter_finding": "未发现可由源文件确认的具名参数",
"operation_check": "17图像同时送22分割与25合成;22输出MASK经24反相后进入25的alpha通道,20直接预览原遮罩。21注释列background_removal/birefnet.safetensors,23实际选择同名文件;先检查透明边缘与遮罩极性,注释目录不等于本机安装事实。",
"unknowns": [
"位置数组未按位置具名,具名字段单位/合法范围/调整效果未获定义验证",
"注释中的效果、性能、版本支持为源作者陈述,未作外部或环境验证",
"静音/旁路、虚拟节点、子图、开关与连线覆盖的实际执行结果待原前端API导出",
"模型/素材引用只表示源文件出现,实际安装、位置、可用性和输出编码待真实环境核对"
]
},
"source_sha": "9d99de736806f8eeffed114cc1ba075c16276b69",
"unconfirmed": [
"逐参数语义、单位、范围和调整效果未定稿",
"UI 位置控件未具名,含子图和旁路的实际执行路径待黄金导出",
"模型/节点包位置、素材可用性及实际输出未在目标环境核对"
]
},
"source_review_sha": "14c52b279526b771ef10be1af7aef5301602b26d2ff393fb89d5c1837466ae46",
"status": {
"doc_done": false,
"exec_verified": false,
"imported": true
},
"steps": [
"替换 LoadImage 后分别检查 MaskPreview 与带透明通道的 PreviewImage;模型引用 birefnet.safetensors 仍需核对实际位置。",
"17图像同时送22分割与25合成;22输出MASK经24反相后进入25的alpha通道,20直接预览原遮罩。21注释列background_removal/birefnet.safetensors,23实际选择同名文件;先检查透明边缘与遮罩极性,注释目录不等于本机安装事实。",
"打开源工作流 example_ComfyUI_workflows/0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大/birefnet_图像移除背景.json,逐项核对下列源节点与注释。",
"补齐节点定义、模型来源及所需素材;完成输入输出映射核对。",
"明确触发真实测试后核对产物;本批未提交生成任务。"
],
"title": "birefnet_图像移除背景"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,518 @@
{
"dependencies": {
"check_state": "unchecked",
"checked_at": null,
"missing": [],
"model_locations": "待目标机提供",
"models": [],
"node_packs": [],
"node_types": [
"AILab_Qwen3ASRSubtitle",
"LoadAudio",
"PreviewAny",
"PrimitiveNode"
],
"source": null
},
"draft": true,
"errors": [
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:PreviewAny"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:PreviewAny"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:PreviewAny"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:PreviewAny"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:PrimitiveNode"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:LoadAudio"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:AILab_Qwen3ASRSubtitle"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "未得到可执行节点"
}
],
"io": {
"inputs": [],
"outputs": []
},
"materials": [],
"params": [],
"purpose": "Qwen3 ASR 字幕图以 LoadAudio 接 AILab_Qwen3ASRSubtitle,含 PrimitiveNode 与 PreviewAny。",
"snapshot_sha": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
"source_links": [
{
"link": [
13,
6,
0,
9,
0,
"AUDIO"
],
"scope": ""
},
{
"link": [
14,
7,
0,
9,
6,
"STRING"
],
"scope": ""
},
{
"link": [
15,
9,
0,
4,
0,
"STRING"
],
"scope": ""
},
{
"link": [
16,
9,
1,
2,
0,
"STRING"
],
"scope": ""
},
{
"link": [
17,
9,
2,
3,
0,
"STRING"
],
"scope": ""
},
{
"link": [
18,
9,
3,
1,
0,
"STRING"
],
"scope": ""
}
],
"source_nodes": [
{
"class_type": "PreviewAny",
"inputs": [
{
"link": 16,
"localized_name": "源",
"name": "source",
"type": "*"
}
],
"mode": 0,
"node_id": "2",
"source_values": [
null,
null,
null
],
"title": null
},
{
"class_type": "PreviewAny",
"inputs": [
{
"link": 15,
"localized_name": "源",
"name": "source",
"type": "*"
}
],
"mode": 0,
"node_id": "4",
"source_values": [
null,
null,
null
],
"title": null
},
{
"class_type": "PreviewAny",
"inputs": [
{
"link": 17,
"localized_name": "源",
"name": "source",
"type": "*"
}
],
"mode": 0,
"node_id": "3",
"source_values": [
null,
null,
null
],
"title": null
},
{
"class_type": "PreviewAny",
"inputs": [
{
"link": 18,
"localized_name": "源",
"name": "source",
"type": "*"
}
],
"mode": 0,
"node_id": "1",
"source_values": [
null,
null,
null
],
"title": null
},
{
"class_type": "PrimitiveNode",
"inputs": [],
"mode": 0,
"node_id": "7",
"source_values": [
"Important terms: ComfyUI, Qwen3-ASR\nUse exact casing for these terms."
],
"title": null
},
{
"class_type": "LoadAudio",
"inputs": [
{
"link": null,
"localized_name": "音频",
"name": "audio",
"type": "COMBO",
"widget": {
"name": "audio"
}
},
{
"link": null,
"localized_name": "音频UI",
"name": "audioUI",
"type": "AUDIO_UI",
"widget": {
"name": "audioUI"
}
},
{
"link": null,
"localized_name": "选择文件上传",
"name": "upload",
"type": "AUDIOUPLOAD",
"widget": {
"name": "upload"
}
}
],
"mode": 0,
"node_id": "6",
"source_values": [
"ComfyUI-qwenasr_00003_.mp3",
null,
null
],
"title": null
},
{
"class_type": "AILab_Qwen3ASRSubtitle",
"inputs": [
{
"link": 13,
"localized_name": "audio",
"name": "audio",
"type": "AUDIO"
},
{
"link": null,
"localized_name": "model",
"name": "model",
"shape": 7,
"type": "COMBO",
"widget": {
"name": "model"
}
},
{
"link": null,
"localized_name": "precision",
"name": "precision",
"shape": 7,
"type": "COMBO",
"widget": {
"name": "precision"
}
},
{
"link": null,
"localized_name": "attention",
"name": "attention",
"shape": 7,
"type": "COMBO",
"widget": {
"name": "attention"
}
},
{
"link": null,
"localized_name": "forced_aligner",
"name": "forced_aligner",
"shape": 7,
"type": "COMBO",
"widget": {
"name": "forced_aligner"
}
},
{
"link": null,
"localized_name": "language",
"name": "language",
"shape": 7,
"type": "COMBO",
"widget": {
"name": "language"
}
},
{
"link": 14,
"localized_name": "hints",
"name": "hints",
"shape": 7,
"type": "STRING",
"widget": {
"name": "hints"
}
},
{
"link": null,
"localized_name": "output_format",
"name": "output_format",
"shape": 7,
"type": "COMBO",
"widget": {
"name": "output_format"
}
},
{
"link": null,
"localized_name": "output_path",
"name": "output_path",
"shape": 7,
"type": "STRING",
"widget": {
"name": "output_path"
}
},
{
"link": null,
"localized_name": "split_mode",
"name": "split_mode",
"shape": 7,
"type": "COMBO",
"widget": {
"name": "split_mode"
}
},
{
"link": null,
"localized_name": "max_gap_sec",
"name": "max_gap_sec",
"shape": 7,
"type": "FLOAT",
"widget": {
"name": "max_gap_sec"
}
},
{
"link": null,
"localized_name": "max_chars",
"name": "max_chars",
"shape": 7,
"type": "INT",
"widget": {
"name": "max_chars"
}
},
{
"link": null,
"localized_name": "max_inference_batch_size",
"name": "max_inference_batch_size",
"shape": 7,
"type": "INT",
"widget": {
"name": "max_inference_batch_size"
}
},
{
"link": null,
"localized_name": "max_new_tokens",
"name": "max_new_tokens",
"shape": 7,
"type": "INT",
"widget": {
"name": "max_new_tokens"
}
},
{
"link": null,
"localized_name": "unload_models",
"name": "unload_models",
"shape": 7,
"type": "BOOLEAN",
"widget": {
"name": "unload_models"
}
}
],
"mode": 0,
"node_id": "9",
"source_values": [
"Qwen/Qwen3-ASR-0.6B",
"bf16",
"auto",
"Qwen/Qwen3-ForcedAligner-0.6B",
"auto",
"Important terms: ComfyUI, Qwen3-ASR\nUse exact casing for these terms.",
"none",
"",
"split_by_punctuation_or_pause_or_length",
0.6,
40,
32,
256,
true
],
"title": null
}
],
"source_notes": [],
"source_path": "example_ComfyUI_workflows/1-语音生成/Qwen3-ASR-Subtitle语音转字幕.json",
"source_review": {
"complete": false,
"evidence": {
"link_count": 6,
"model_references": [],
"named_value_count": 0,
"node_count": 7,
"note_node_ids": [],
"unnamed_widget_node_ids": [
"2",
"4",
"3",
"1",
"7",
"6",
"9"
]
},
"instruction": "先核对字幕节点各输出接线;预览文字不能当作已支持的 SRT 文件下载,实际历史响应待核对。",
"purpose": "Qwen3 ASR 字幕图以 LoadAudio 接 AILab_Qwen3ASRSubtitle,含 PrimitiveNode 与 PreviewAny。",
"reviewed_named_fields": [],
"scope": [
"结构及用途局部审校",
"源具名参数/注释/连线/素材模型用途人工语义局部审校"
],
"semantic_review": {
"batch": "HEL-892-third",
"conclusions": [
"6音频送9,7的术语提示经连线14进入9",
"9的四个STRING出口分别预览,未见文件保存。Qwen3-ASR及ForcedAligner是控件中的仓库标识而非可证明已安装的路径",
"先分清转写/字幕文本出口,不能把STRING视为可下载SRT文件,源无注释。"
],
"evidence_refs": {
"nodes": [
{
"node_id": "3",
"pointer": "/source_nodes/2"
},
{
"node_id": "7",
"pointer": "/source_nodes/4"
},
{
"node_id": "6",
"pointer": "/source_nodes/5"
},
{
"node_id": "9",
"pointer": "/source_nodes/6"
}
],
"notes": [],
"source_graph": "/source_links(按scope区分主图与子图;连线ID仅在各自scope内有效)"
},
"mechanical_inventory": {
"link_count": 6,
"named_value_count": 0,
"note_count": 0,
"position_widget_node_count": 7
},
"named_parameter_finding": "未发现可由源文件确认的具名参数",
"operation_check": "6音频送9,7的术语提示经连线14进入9;9的四个STRING出口分别预览,未见文件保存。Qwen3-ASR及ForcedAligner是控件中的仓库标识而非可证明已安装的路径;先分清转写/字幕文本出口,不能把STRING视为可下载SRT文件,源无注释。",
"unknowns": [
"位置数组未按位置具名,具名字段单位/合法范围/调整效果未获定义验证",
"注释中的效果、性能、版本支持为源作者陈述,未作外部或环境验证",
"静音/旁路、虚拟节点、子图、开关与连线覆盖的实际执行结果待原前端API导出",
"模型/素材引用只表示源文件出现,实际安装、位置、可用性和输出编码待真实环境核对"
]
},
"source_sha": "08a7519a6a6c532b8a0670dce3deff70881527ef",
"unconfirmed": [
"逐参数语义、单位、范围和调整效果未定稿",
"UI 位置控件未具名,含子图和旁路的实际执行路径待黄金导出",
"模型/节点包位置、素材可用性及实际输出未在目标环境核对"
]
},
"source_review_sha": "08dcd6d4418888f510c8738ad2a171663f1c547b01f5f4602da50061bcfc7e9b",
"status": {
"doc_done": false,
"exec_verified": false,
"imported": true
},
"steps": [
"先核对字幕节点各输出接线;预览文字不能当作已支持的 SRT 文件下载,实际历史响应待核对。",
"6音频送9,7的术语提示经连线14进入9;9的四个STRING出口分别预览,未见文件保存。Qwen3-ASR及ForcedAligner是控件中的仓库标识而非可证明已安装的路径;先分清转写/字幕文本出口,不能把STRING视为可下载SRT文件,源无注释。",
"打开源工作流 example_ComfyUI_workflows/1-语音生成/Qwen3-ASR-Subtitle语音转字幕.json,逐项核对下列源节点与注释。",
"补齐节点定义、模型来源及所需素材;完成输入输出映射核对。",
"明确触发真实测试后核对产物;本批未提交生成任务。"
],
"title": "Qwen3-ASR-Subtitle语音转字幕"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,385 @@
{
"dependencies": {
"check_state": "unchecked",
"checked_at": null,
"missing": [],
"model_locations": "待目标机提供",
"models": [],
"node_packs": [],
"node_types": [
"CreateVideo",
"GetVideoComponents",
"LoadVideo",
"RTXVideoSuperResolution",
"SaveVideo"
],
"source": null
},
"draft": true,
"errors": [
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:GetVideoComponents"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:SaveVideo"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:CreateVideo"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:RTXVideoSuperResolution"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:LoadVideo"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "未得到可执行节点"
}
],
"io": {
"inputs": [],
"outputs": []
},
"materials": [],
"params": [],
"purpose": "RTX 视频放大通过 GetVideoComponents 拆分视频,处理图像后重新 CreateVideo/SaveVideo。",
"snapshot_sha": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
"source_links": [
{
"link": [
5,
6,
0,
7,
0,
"VIDEO"
],
"scope": ""
},
{
"link": [
7,
1,
0,
8,
0,
"IMAGE"
],
"scope": ""
},
{
"link": [
8,
7,
1,
8,
1,
"AUDIO"
],
"scope": ""
},
{
"link": [
9,
7,
2,
8,
2,
"FLOAT"
],
"scope": ""
},
{
"link": [
10,
8,
0,
9,
0,
"VIDEO"
],
"scope": ""
},
{
"link": [
19,
7,
0,
1,
0,
"IMAGE"
],
"scope": ""
}
],
"source_nodes": [
{
"class_type": "GetVideoComponents",
"inputs": [
{
"link": 5,
"localized_name": "视频",
"name": "video",
"type": "VIDEO"
}
],
"mode": 0,
"node_id": "7",
"source_values": [],
"title": null
},
{
"class_type": "SaveVideo",
"inputs": [
{
"link": 10,
"localized_name": "视频",
"name": "video",
"type": "VIDEO"
},
{
"link": null,
"localized_name": "文件名前缀",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
}
},
{
"link": null,
"localized_name": "格式",
"name": "format",
"type": "COMBO",
"widget": {
"name": "format"
}
},
{
"link": null,
"localized_name": "编码器",
"name": "codec",
"type": "COMBO",
"widget": {
"name": "codec"
}
}
],
"mode": 0,
"node_id": "9",
"source_values": [
"video/ComfyUI",
"auto",
"auto"
],
"title": null
},
{
"class_type": "CreateVideo",
"inputs": [
{
"link": 7,
"localized_name": "图像",
"name": "images",
"type": "IMAGE"
},
{
"link": 8,
"localized_name": "音频",
"name": "audio",
"shape": 7,
"type": "AUDIO"
},
{
"link": 9,
"localized_name": "帧率",
"name": "fps",
"type": "FLOAT",
"widget": {
"name": "fps"
}
}
],
"mode": 0,
"node_id": "8",
"source_values": [
30
],
"title": null
},
{
"class_type": "RTXVideoSuperResolution",
"inputs": [
{
"link": 19,
"localized_name": "images",
"name": "images",
"type": "IMAGE"
},
{
"link": null,
"localized_name": "resize_type",
"name": "resize_type",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": {
"name": "resize_type"
}
},
{
"link": null,
"localized_name": "resize_type.scale",
"name": "resize_type.scale",
"type": "FLOAT",
"widget": {
"name": "resize_type.scale"
}
},
{
"link": null,
"localized_name": "quality",
"name": "quality",
"type": "COMBO",
"widget": {
"name": "quality"
}
}
],
"mode": 0,
"node_id": "1",
"source_values": [
"scale by multiplier",
1,
"ULTRA"
],
"title": null
},
{
"class_type": "LoadVideo",
"inputs": [
{
"link": null,
"localized_name": "文件",
"name": "file",
"type": "COMBO",
"widget": {
"name": "file"
}
},
{
"link": null,
"localized_name": "选择要上传的文件",
"name": "upload",
"type": "IMAGEUPLOAD",
"widget": {
"name": "upload"
}
}
],
"mode": 0,
"node_id": "6",
"source_values": [
"WanVideo_00003-audio.mp4",
"image"
],
"title": null
}
],
"source_notes": [],
"source_path": "example_ComfyUI_workflows/0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大/高清放大/RTX_video_upscale.json",
"source_review": {
"complete": false,
"evidence": {
"link_count": 6,
"model_references": [],
"named_value_count": 0,
"node_count": 5,
"note_node_ids": [],
"unnamed_widget_node_ids": [
"9",
"8",
"1",
"6"
]
},
"instruction": "检查音轨是否接回 CreateVideo,避免只依据放大图像分支判断已保留音频。",
"purpose": "RTX 视频放大通过 GetVideoComponents 拆分视频,处理图像后重新 CreateVideo/SaveVideo。",
"reviewed_named_fields": [],
"scope": [
"结构及用途局部审校",
"源具名参数/注释/连线/素材模型用途人工语义局部审校"
],
"semantic_review": {
"batch": "HEL-892-third",
"conclusions": [
"6视频经7拆分,7图像到1处理,音频和FLOAT分别接8的1/2槽,8输出到9保存。1控件倍率现值为1而不是文件名暗示的必然放大",
"先核对处理目标及原帧率连线,无注释、不能自行推断RTX硬件要求。"
],
"evidence_refs": {
"nodes": [
{
"node_id": "7",
"pointer": "/source_nodes/0"
},
{
"node_id": "9",
"pointer": "/source_nodes/1"
},
{
"node_id": "8",
"pointer": "/source_nodes/2"
},
{
"node_id": "1",
"pointer": "/source_nodes/3"
},
{
"node_id": "6",
"pointer": "/source_nodes/4"
}
],
"notes": [],
"source_graph": "/source_links(按scope区分主图与子图;连线ID仅在各自scope内有效)"
},
"mechanical_inventory": {
"link_count": 6,
"named_value_count": 0,
"note_count": 0,
"position_widget_node_count": 4
},
"named_parameter_finding": "未发现可由源文件确认的具名参数",
"operation_check": "6视频经7拆分,7图像到1处理,音频和FLOAT分别接8的1/2槽,8输出到9保存。1控件倍率现值为1而不是文件名暗示的必然放大;先核对处理目标及原帧率连线,无注释、不能自行推断RTX硬件要求。",
"unknowns": [
"位置数组未按位置具名,具名字段单位/合法范围/调整效果未获定义验证",
"注释中的效果、性能、版本支持为源作者陈述,未作外部或环境验证",
"静音/旁路、虚拟节点、子图、开关与连线覆盖的实际执行结果待原前端API导出",
"模型/素材引用只表示源文件出现,实际安装、位置、可用性和输出编码待真实环境核对"
]
},
"source_sha": "3a3b17a5bb55e250ed0430b9c2aeef396b7992c7",
"unconfirmed": [
"逐参数语义、单位、范围和调整效果未定稿",
"UI 位置控件未具名,含子图和旁路的实际执行路径待黄金导出",
"模型/节点包位置、素材可用性及实际输出未在目标环境核对"
]
},
"source_review_sha": "a8920cc36d75a29f87d2f818064d786f29fccda95efc995ede0ba91a6093d420",
"status": {
"doc_done": false,
"exec_verified": false,
"imported": true
},
"steps": [
"检查音轨是否接回 CreateVideo,避免只依据放大图像分支判断已保留音频。",
"6视频经7拆分,7图像到1处理,音频和FLOAT分别接8的1/2槽,8输出到9保存。1控件倍率现值为1而不是文件名暗示的必然放大;先核对处理目标及原帧率连线,无注释、不能自行推断RTX硬件要求。",
"打开源工作流 example_ComfyUI_workflows/0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大/高清放大/RTX_video_upscale.json,逐项核对下列源节点与注释。",
"补齐节点定义、模型来源及所需素材;完成输入输出映射核对。",
"明确触发真实测试后核对产物;本批未提交生成任务。"
],
"title": "RTX_video_upscale"
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,725 @@
{
"dependencies": {
"check_state": "unchecked",
"checked_at": null,
"missing": [],
"model_locations": "待目标机提供",
"models": [],
"node_packs": [],
"node_types": [
"ImageStitch",
"LoadImage",
"MarkdownNote",
"ResizeImageMaskNode",
"SaveImage"
],
"source": null
},
"draft": true,
"errors": [
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:LoadImage"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:LoadImage"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:ImageStitch"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:ResizeImageMaskNode"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:ImageStitch"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:ImageStitch"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:SaveImage"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:LoadImage"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "待提供目标机节点定义:LoadImage"
},
{
"fix": "按具体节点补材料或提供原 ComfyUI API 导出。",
"symptom": "未得到可执行节点"
}
],
"io": {
"inputs": [],
"outputs": []
},
"materials": [],
"params": [],
"purpose": "四图拼接由两次横向 ImageStitch 和一次纵向 ImageStitch 组成,再缩放并保存。",
"snapshot_sha": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
"source_links": [
{
"link": [
11,
12,
0,
16,
0,
"IMAGE"
],
"scope": ""
},
{
"link": [
12,
13,
0,
16,
1,
"IMAGE"
],
"scope": ""
},
{
"link": [
13,
14,
0,
17,
0,
"IMAGE"
],
"scope": ""
},
{
"link": [
14,
15,
0,
17,
1,
"IMAGE"
],
"scope": ""
},
{
"link": [
15,
16,
0,
18,
0,
"IMAGE"
],
"scope": ""
},
{
"link": [
16,
17,
0,
18,
1,
"IMAGE"
],
"scope": ""
},
{
"link": [
18,
21,
0,
19,
0,
"IMAGE"
],
"scope": ""
},
{
"link": [
19,
18,
0,
21,
0,
"IMAGE"
],
"scope": ""
}
],
"source_nodes": [
{
"class_type": "LoadImage",
"inputs": [
{
"label": "image1",
"link": null,
"localized_name": "图像",
"name": "image",
"type": "COMBO",
"widget": {
"name": "image"
}
},
{
"link": null,
"localized_name": "选择文件上传",
"name": "upload",
"type": "IMAGEUPLOAD",
"widget": {
"name": "upload"
}
}
],
"mode": 0,
"node_id": "12",
"source_values": [
"angle_1.png",
"image"
],
"title": null
},
{
"class_type": "LoadImage",
"inputs": [
{
"label": "image2",
"link": null,
"localized_name": "图像",
"name": "image",
"type": "COMBO",
"widget": {
"name": "image"
}
},
{
"link": null,
"localized_name": "选择文件上传",
"name": "upload",
"type": "IMAGEUPLOAD",
"widget": {
"name": "upload"
}
}
],
"mode": 0,
"node_id": "13",
"source_values": [
"angle_2.png",
"image"
],
"title": null
},
{
"class_type": "ImageStitch",
"inputs": [
{
"label": "图像1",
"link": 11,
"localized_name": "图像1",
"name": "image1",
"type": "IMAGE"
},
{
"label": "图像2",
"link": 12,
"localized_name": "图像2",
"name": "image2",
"shape": 7,
"type": "IMAGE"
},
{
"label": "方向",
"link": null,
"localized_name": "方向",
"name": "direction",
"type": "COMBO",
"widget": {
"name": "direction"
}
},
{
"label": "匹配图像尺寸",
"link": null,
"localized_name": "匹配图像尺寸",
"name": "match_image_size",
"type": "BOOLEAN",
"widget": {
"name": "match_image_size"
}
},
{
"label": "间距宽度",
"link": null,
"localized_name": "间距宽度",
"name": "spacing_width",
"type": "INT",
"widget": {
"name": "spacing_width"
}
},
{
"label": "间距颜色",
"link": null,
"localized_name": "间距颜色",
"name": "spacing_color",
"type": "COMBO",
"widget": {
"name": "spacing_color"
}
}
],
"mode": 0,
"node_id": "16",
"source_values": [
"right",
true,
0,
"white"
],
"title": null
},
{
"class_type": "ResizeImageMaskNode",
"inputs": [
{
"link": 19,
"localized_name": "输入",
"name": "input",
"type": "IMAGE,MASK"
},
{
"link": null,
"localized_name": "调整类型",
"name": "resize_type",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": {
"name": "resize_type"
}
},
{
"link": null,
"localized_name": "宽度",
"name": "resize_type.width",
"type": "INT",
"widget": {
"name": "resize_type.width"
}
},
{
"link": null,
"localized_name": "高度",
"name": "resize_type.height",
"type": "INT",
"widget": {
"name": "resize_type.height"
}
},
{
"link": null,
"localized_name": "裁剪",
"name": "resize_type.crop",
"type": "COMBO",
"widget": {
"name": "resize_type.crop"
}
},
{
"link": null,
"localized_name": "缩放方法",
"name": "scale_method",
"type": "COMBO",
"widget": {
"name": "scale_method"
}
}
],
"mode": 0,
"node_id": "21",
"source_values": [
"scale dimensions",
2048,
2048,
"center",
"lanczos"
],
"title": null
},
{
"class_type": "ImageStitch",
"inputs": [
{
"label": "图像1",
"link": 15,
"localized_name": "图像1",
"name": "image1",
"type": "IMAGE"
},
{
"label": "图像2",
"link": 16,
"localized_name": "图像2",
"name": "image2",
"shape": 7,
"type": "IMAGE"
},
{
"label": "方向",
"link": null,
"localized_name": "方向",
"name": "direction",
"type": "COMBO",
"widget": {
"name": "direction"
}
},
{
"label": "匹配图像尺寸",
"link": null,
"localized_name": "匹配图像尺寸",
"name": "match_image_size",
"type": "BOOLEAN",
"widget": {
"name": "match_image_size"
}
},
{
"label": "间距宽度",
"link": null,
"localized_name": "间距宽度",
"name": "spacing_width",
"type": "INT",
"widget": {
"name": "spacing_width"
}
},
{
"label": "间距颜色",
"link": null,
"localized_name": "间距颜色",
"name": "spacing_color",
"type": "COMBO",
"widget": {
"name": "spacing_color"
}
}
],
"mode": 0,
"node_id": "18",
"source_values": [
"down",
true,
0,
"white"
],
"title": null
},
{
"class_type": "ImageStitch",
"inputs": [
{
"label": "图像1",
"link": 13,
"localized_name": "图像1",
"name": "image1",
"type": "IMAGE"
},
{
"label": "图像2",
"link": 14,
"localized_name": "图像2",
"name": "image2",
"shape": 7,
"type": "IMAGE"
},
{
"label": "方向",
"link": null,
"localized_name": "方向",
"name": "direction",
"type": "COMBO",
"widget": {
"name": "direction"
}
},
{
"label": "匹配图像尺寸",
"link": null,
"localized_name": "匹配图像尺寸",
"name": "match_image_size",
"type": "BOOLEAN",
"widget": {
"name": "match_image_size"
}
},
{
"label": "间距宽度",
"link": null,
"localized_name": "间距宽度",
"name": "spacing_width",
"type": "INT",
"widget": {
"name": "spacing_width"
}
},
{
"label": "间距颜色",
"link": null,
"localized_name": "间距颜色",
"name": "spacing_color",
"type": "COMBO",
"widget": {
"name": "spacing_color"
}
}
],
"mode": 0,
"node_id": "17",
"source_values": [
"right",
true,
0,
"white"
],
"title": null
},
{
"class_type": "SaveImage",
"inputs": [
{
"link": 18,
"localized_name": "图片",
"name": "images",
"type": "IMAGE"
},
{
"link": null,
"localized_name": "文件名前缀",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
}
}
],
"mode": 0,
"node_id": "19",
"source_values": [
"ComfyUI"
],
"title": null
},
{
"class_type": "LoadImage",
"inputs": [
{
"label": "image3",
"link": null,
"localized_name": "图像",
"name": "image",
"type": "COMBO",
"widget": {
"name": "image"
}
},
{
"link": null,
"localized_name": "选择文件上传",
"name": "upload",
"type": "IMAGEUPLOAD",
"widget": {
"name": "upload"
}
}
],
"mode": 0,
"node_id": "14",
"source_values": [
"angle_3.png",
"image"
],
"title": null
},
{
"class_type": "MarkdownNote",
"inputs": [],
"mode": 0,
"node_id": "24",
"source_values": [
"# Image Stitch Grid Workflow\n\n4 images → 2×2 grid → saved at 2048×2048.\n\n**How it chains:**\n1. `Image Stitch (right)` — joins top-left + top-right into a row\n2. `Image Stitch (right)` — joins bottom-left + bottom-right into a row\n3. `Image Stitch (down)` — stacks both rows into the final grid\n\n**Key settings:**\n- `direction` — `right` for horizontal, `down` for vertical\n- `match_image_size: ON` — auto-resizes mismatched inputs"
],
"title": null
},
{
"class_type": "LoadImage",
"inputs": [
{
"label": "image4",
"link": null,
"localized_name": "图像",
"name": "image",
"type": "COMBO",
"widget": {
"name": "image"
}
},
{
"link": null,
"localized_name": "选择文件上传",
"name": "upload",
"type": "IMAGEUPLOAD",
"widget": {
"name": "upload"
}
}
],
"mode": 0,
"node_id": "15",
"source_values": [
"angle_4.png",
"image"
],
"title": null
}
],
"source_notes": [
{
"content": [
"# Image Stitch Grid Workflow\n\n4 images → 2×2 grid → saved at 2048×2048.\n\n**How it chains:**\n1. `Image Stitch (right)` — joins top-left + top-right into a row\n2. `Image Stitch (right)` — joins bottom-left + bottom-right into a row\n3. `Image Stitch (down)` — stacks both rows into the final grid\n\n**Key settings:**\n- `direction` — `right` for horizontal, `down` for vertical\n- `match_image_size: ON` — auto-resizes mismatched inputs"
],
"node_id": "24"
}
],
"source_path": "example_ComfyUI_workflows/0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大/图像合并成四宫格.json",
"source_review": {
"complete": false,
"evidence": {
"link_count": 8,
"model_references": [],
"named_value_count": 0,
"node_count": 10,
"note_node_ids": [
"24"
],
"unnamed_widget_node_ids": [
"12",
"13",
"16",
"21",
"18",
"17",
"19",
"14",
"15"
]
},
"instruction": "按 angle_1.png 至 angle_4.png 替换四张素材;源注释明确描述 2×2 拼接并保存 2048×2048,仍须实测结果确认。",
"purpose": "四图拼接由两次横向 ImageStitch 和一次纵向 ImageStitch 组成,再缩放并保存。",
"reviewed_named_fields": [],
"scope": [
"结构及用途局部审校",
"源具名参数/注释/连线/素材模型用途人工语义局部审校"
],
"semantic_review": {
"batch": "HEL-892-third",
"conclusions": [
"12/13图像到16拼第一排,14/15到17拼第二排,再由18合并送21缩放、19保存。24注释明确写2×2和2048×2048并解释right/down",
"这是注释依据,21位置值不能当作已验证尺寸字段。先核对四张angle素材的顺序。"
],
"evidence_refs": {
"nodes": [
{
"node_id": "12",
"pointer": "/source_nodes/0"
},
{
"node_id": "13",
"pointer": "/source_nodes/1"
},
{
"node_id": "16",
"pointer": "/source_nodes/2"
},
{
"node_id": "21",
"pointer": "/source_nodes/3"
},
{
"node_id": "18",
"pointer": "/source_nodes/4"
},
{
"node_id": "17",
"pointer": "/source_nodes/5"
},
{
"node_id": "19",
"pointer": "/source_nodes/6"
},
{
"node_id": "14",
"pointer": "/source_nodes/7"
},
{
"node_id": "24",
"pointer": "/source_nodes/8"
},
{
"node_id": "15",
"pointer": "/source_nodes/9"
}
],
"notes": [
{
"node_id": "24",
"pointer": "/source_notes/0"
}
],
"source_graph": "/source_links(按scope区分主图与子图;连线ID仅在各自scope内有效)"
},
"mechanical_inventory": {
"link_count": 8,
"named_value_count": 0,
"note_count": 1,
"position_widget_node_count": 9
},
"named_parameter_finding": "未发现可由源文件确认的具名参数",
"operation_check": "12/13图像到16拼第一排,14/15到17拼第二排,再由18合并送21缩放、19保存。24注释明确写2×2和2048×2048并解释right/down;这是注释依据,21位置值不能当作已验证尺寸字段。先核对四张angle素材的顺序。",
"unknowns": [
"位置数组未按位置具名,具名字段单位/合法范围/调整效果未获定义验证",
"注释中的效果、性能、版本支持为源作者陈述,未作外部或环境验证",
"静音/旁路、虚拟节点、子图、开关与连线覆盖的实际执行结果待原前端API导出",
"模型/素材引用只表示源文件出现,实际安装、位置、可用性和输出编码待真实环境核对"
]
},
"source_sha": "209bf15254bb18d736c75c844de8f8381d953fe5",
"unconfirmed": [
"逐参数语义、单位、范围和调整效果未定稿",
"UI 位置控件未具名,含子图和旁路的实际执行路径待黄金导出",
"模型/节点包位置、素材可用性及实际输出未在目标环境核对"
]
},
"source_review_sha": "3091f9b17fa3a0d4cbe593e90e33fe77358b32d1c0083699dfb48579adf18a4a",
"status": {
"doc_done": false,
"exec_verified": false,
"imported": true
},
"steps": [
"按 angle_1.png 至 angle_4.png 替换四张素材;源注释明确描述 2×2 拼接并保存 2048×2048,仍须实测结果确认。",
"12/13图像到16拼第一排,14/15到17拼第二排,再由18合并送21缩放、19保存。24注释明确写2×2和2048×2048并解释right/down;这是注释依据,21位置值不能当作已验证尺寸字段。先核对四张angle素材的顺序。",
"打开源工作流 example_ComfyUI_workflows/0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大/图像合并成四宫格.json,逐项核对下列源节点与注释。",
"补齐节点定义、模型来源及所需素材;完成输入输出映射核对。",
"明确触发真实测试后核对产物;本批未提交生成任务。"
],
"title": "图像合并成四宫格"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More