Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f3328958 | ||
|
|
d927925000 | ||
|
|
1390489289 | ||
|
|
8c666d2220 | ||
|
|
122c51e47e | ||
|
|
3cf6ae4241 | ||
|
|
1fc8bdb8bb | ||
|
|
2c3c05277b | ||
|
|
2689f5354a | ||
|
|
7bd8c29faa | ||
|
|
4f3293fc02 | ||
|
|
4c87c6dea0 | ||
|
|
d882fd62d8 | ||
|
|
b4c7334f45 | ||
|
|
6064bd805d | ||
|
|
c3deaf90bb | ||
|
|
949cd2ecad | ||
|
|
0c620e61fe | ||
|
|
6f4d50f4f0 | ||
|
|
8b03291327 | ||
|
|
225fcb49ea | ||
|
|
70a860ce2a | ||
|
|
e42212a16f | ||
|
|
f5f19f0366 | ||
|
|
bfe7b20b73 | ||
|
|
2ed9d25c24 | ||
|
|
48e16ae443 | ||
|
|
319c7b049b | ||
|
|
e3bfe771af | ||
|
|
e6f761f8c8 | ||
|
|
30e8e85766 | ||
|
|
60c043db7c |
@@ -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/**
|
||||
|
||||
@@ -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())
|
||||
@@ -2,17 +2,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
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")
|
||||
|
||||
# Reachability probe budget (HEL-619): matches the connections-page test
|
||||
# probe (5s). Without it health() inherits the 120s client default, so a
|
||||
# bad endpoint hangs every queued job for two minutes before failing.
|
||||
HEALTH_TIMEOUT_SEC = 5.0
|
||||
|
||||
# /object_info fetch is a few MB on big instances; the preflight check
|
||||
# (HEL-642/643) caches it per endpoint for this long. Manual refresh
|
||||
# (`force_refresh=True`) bypasses the TTL.
|
||||
OBJECT_INFO_TTL_SEC = 300.0
|
||||
_object_info_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def clear_object_info_cache() -> None:
|
||||
"""Drop cached /object_info payloads (tests swap fake endpoints in)."""
|
||||
_object_info_cache.clear()
|
||||
|
||||
|
||||
def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
|
||||
"""Raise with ComfyUI's own error body instead of an opaque status line.
|
||||
@@ -41,12 +63,24 @@ def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
|
||||
if not isinstance(node_err, dict):
|
||||
continue
|
||||
errors = node_err.get("errors")
|
||||
node = f"{node_id}({node_err['class_type']})" if node_err.get("class_type") else node_id
|
||||
if isinstance(errors, list):
|
||||
for e in errors:
|
||||
if isinstance(e, dict):
|
||||
msg = e.get("message") or e.get("details")
|
||||
if msg:
|
||||
parts.append(f"节点 {node_id}:{msg}")
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
if e.get("type") == "value_not_in_list":
|
||||
extra = e.get("extra_info") or {}
|
||||
parts.append(
|
||||
f"节点 {node}:ComfyUI 上没有「{extra.get('received_value', '?')}」"
|
||||
f"({extra.get('input_name', '参数')} 不在可选列表中,"
|
||||
"多为模型文件未安装)"
|
||||
)
|
||||
continue
|
||||
msg = e.get("message") or e.get("details")
|
||||
if msg and e.get("details") and e["details"] != msg:
|
||||
msg = f"{msg}({e['details']})"
|
||||
if msg:
|
||||
parts.append(f"节点 {node}:{msg}")
|
||||
detail = ";".join(parts)
|
||||
suffix = f":{detail}" if detail else ""
|
||||
return RuntimeError(f"{prefix}(HTTP {resp.status_code}){suffix}")
|
||||
@@ -64,11 +98,40 @@ class ComfyUIClient:
|
||||
|
||||
async def health(self) -> bool:
|
||||
try:
|
||||
resp = await self._http.get(f"{self.base_url}/system_stats")
|
||||
resp = await self._http.get(
|
||||
f"{self.base_url}/system_stats", timeout=HEALTH_TIMEOUT_SEC
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _cache_key(self) -> str:
|
||||
auth = self._headers.get("Authorization") or next(
|
||||
(v for k, v in self._headers.items() if k.lower() not in ("authorization",)), ""
|
||||
)
|
||||
return f"{self.base_url}|{auth}"
|
||||
|
||||
async def object_info(self, force_refresh: bool = False) -> dict[str, Any]:
|
||||
"""The server's full ``/object_info`` node catalogue, cached per endpoint.
|
||||
|
||||
Cached for OBJECT_INFO_TTL_SEC; ``force_refresh`` re-fetches (the
|
||||
connections page "重新检查" button after the user installs nodes).
|
||||
Raises on transport/HTTP errors so callers can report honestly.
|
||||
"""
|
||||
key = self._cache_key()
|
||||
now = time.monotonic()
|
||||
if not force_refresh:
|
||||
hit = _object_info_cache.get(key)
|
||||
if hit and now - hit[0] < OBJECT_INFO_TTL_SEC:
|
||||
return hit[1]
|
||||
resp = await self._http.get(f"{self.base_url}/object_info", timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("ComfyUI 返回的 /object_info 不是对象")
|
||||
_object_info_cache[key] = (now, data)
|
||||
return data
|
||||
|
||||
async def upload_image(self, path: Path, subfolder: str = "calliope") -> str:
|
||||
data = path.read_bytes()
|
||||
files = {"image": (path.name, data, "application/octet-stream")}
|
||||
@@ -217,6 +280,37 @@ class ComfyUIClient:
|
||||
data = resp.json()
|
||||
return data.get(prompt_id)
|
||||
|
||||
async def locate_prompt(self, prompt_id: str) -> tuple[str, int]:
|
||||
"""Where ``prompt_id`` sits in ComfyUI's ``/queue``.
|
||||
|
||||
Returns ``("running", 0)``, ``("pending", <prompts ahead>)``,
|
||||
``("missing", 0)`` when the queue does not list it, or
|
||||
``("unknown", 0)`` when this server has no ``/queue`` route.
|
||||
"""
|
||||
resp = await self._http.get(f"{self.base_url}/queue")
|
||||
if resp.status_code == 404:
|
||||
return "unknown", 0
|
||||
resp.raise_for_status()
|
||||
data = resp.json() if resp.content else {}
|
||||
running = data.get("queue_running") or []
|
||||
pending = data.get("queue_pending") or []
|
||||
|
||||
def pid(item: Any) -> Any:
|
||||
return item[1] if isinstance(item, list) and len(item) > 1 else None
|
||||
|
||||
if any(pid(item) == prompt_id for item in running):
|
||||
return "running", 0
|
||||
mine = next((item for item in pending if pid(item) == prompt_id), None)
|
||||
if mine is None:
|
||||
return "missing", 0
|
||||
ahead = sum(
|
||||
1
|
||||
for item in pending
|
||||
if isinstance(item, list) and item and isinstance(item[0], (int, float))
|
||||
and isinstance(mine[0], (int, float)) and item[0] < mine[0]
|
||||
)
|
||||
return "pending", len(running) + ahead
|
||||
|
||||
async def download_image(
|
||||
self,
|
||||
filename: str,
|
||||
@@ -226,7 +320,10 @@ class ComfyUIClient:
|
||||
) -> Path:
|
||||
params = {"filename": filename, "subfolder": subfolder, "type": folder_type}
|
||||
resp = await self._http.get(f"{self.base_url}/view", params=params)
|
||||
resp.raise_for_status()
|
||||
if resp.status_code >= 400:
|
||||
raise _surface_error(f"从 ComfyUI 下载结果文件 {filename} 失败", resp)
|
||||
if not resp.content:
|
||||
raise RuntimeError(f"从 ComfyUI 下载的结果文件 {filename} 是空文件")
|
||||
if dest is None:
|
||||
dest = settings.assets_dir / "comfy_downloads" / filename
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -239,16 +336,60 @@ class ComfyUIClient:
|
||||
except Exception as exc:
|
||||
logger.warning("ComfyUI interrupt failed: %s", exc)
|
||||
|
||||
def extract_outputs(self, history: dict[str, Any]) -> list[dict[str, str]]:
|
||||
# History items carry no explicit media type — infer from the file
|
||||
# extension first, then the history key the node reported under.
|
||||
_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()
|
||||
if ext in cls._VIDEO_EXTS:
|
||||
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
|
||||
|
||||
def extract_outputs(
|
||||
self, history: dict[str, Any], node_kinds: dict[str, str] | None = None
|
||||
) -> list[dict[str, str]]:
|
||||
"""Media items in a finished prompt's history.
|
||||
|
||||
``node_kinds`` (HEL-638) scopes the harvest to the workflow's
|
||||
confirmed output nodes — {nodeId: image|video} — so results from
|
||||
nodes the user never designated are ignored, and each item carries
|
||||
the node id plus its inferred media kind for mismatch checks.
|
||||
"""
|
||||
outputs: list[dict[str, str]] = []
|
||||
for _node_id, node_out in (history.get("outputs") or {}).items():
|
||||
for key in ("images", "gifs", "videos"):
|
||||
for item in node_out.get(key) or []:
|
||||
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", "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": item.get("filename", ""),
|
||||
"filename": filename,
|
||||
"subfolder": item.get("subfolder", ""),
|
||||
"type": item.get("type", "output"),
|
||||
"node_id": str(node_id),
|
||||
"media_kind": self._infer_media_kind(filename, key) or "",
|
||||
}
|
||||
)
|
||||
return outputs
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Structural checks on an imported ComfyUI API workflow.
|
||||
|
||||
``error`` findings mean ComfyUI will reject the prompt as exported, so the
|
||||
studio blocks generation; ``warning`` findings only explain odd parameters.
|
||||
Nothing here repairs a workflow — a broken export has to be re-exported from
|
||||
a ComfyUI that has the missing custom nodes installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
REEXPORT_HINT = "请在已安装对应自定义节点的 ComfyUI 中打开该工作流,重新导出 API 格式 JSON 后再导入"
|
||||
|
||||
# Nodes that cannot run without a model link.
|
||||
_NEEDS_MODEL = frozenset(
|
||||
{"KSampler", "KSamplerAdvanced", "BasicGuider", "CFGGuider", "BasicScheduler"}
|
||||
)
|
||||
|
||||
|
||||
def _title(node_id: str, node: dict[str, Any]) -> str:
|
||||
title = str((node.get("_meta") or {}).get("title") or "").strip()
|
||||
return f"节点 {node_id}「{title}」" if title else f"节点 {node_id}"
|
||||
|
||||
|
||||
def workflow_warnings(workflow: dict[str, Any]) -> list[dict[str, str]]:
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
found: list[dict[str, str]] = []
|
||||
nodes = {str(k): v for k, v in workflow.items() if isinstance(v, dict)}
|
||||
|
||||
untyped = [nid for nid, node in nodes.items() if not str(node.get("class_type") or "").strip()]
|
||||
for nid in untyped:
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "missing_class_type",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, nodes[nid])}缺少节点类型(class_type),"
|
||||
"导出时 ComfyUI 未加载该自定义节点",
|
||||
}
|
||||
)
|
||||
|
||||
consumed: set[str] = set()
|
||||
for nid, node in nodes.items():
|
||||
inputs = node.get("inputs") or {}
|
||||
for name, val in inputs.items():
|
||||
if isinstance(val, list) and len(val) == 2 and isinstance(val[0], (str, int)):
|
||||
src = str(val[0])
|
||||
consumed.add(src)
|
||||
if src not in nodes:
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "dangling_link",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, node)}的输入「{name}」"
|
||||
f"连到了不存在的节点 {src}",
|
||||
}
|
||||
)
|
||||
ct = str(node.get("class_type") or "")
|
||||
if ct in _NEEDS_MODEL and not isinstance(inputs.get("model"), list):
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "missing_model_link",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, node)}({ct})没有连接模型(model),"
|
||||
"模型加载链路在导出时丢失",
|
||||
}
|
||||
)
|
||||
|
||||
for nid, node in nodes.items():
|
||||
ct = str(node.get("class_type") or "")
|
||||
if ct.startswith("Empty") and "Latent" in ct and nid not in consumed:
|
||||
found.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "orphan_latent",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, nodes[nid])}没有接到任何节点,"
|
||||
"它的宽高不会生效,已不在表单中显示",
|
||||
}
|
||||
)
|
||||
|
||||
# HEL-636/638: a workflow without any recognizable output node can never
|
||||
# return a result — block it with a readable reason instead of letting
|
||||
# ComfyUI run blind. Unknown candidates (needs_mapping) don't trigger
|
||||
# this: the user designates them on the import/connections page, and
|
||||
# until then the row stays disabled.
|
||||
from calliope.comfyui.parser import detect_output_nodes
|
||||
|
||||
outputs = detect_output_nodes(workflow)
|
||||
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(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "no_output",
|
||||
"node_id": "",
|
||||
"message": "未在图中找到输出节点(SaveImage / 视频合成等),"
|
||||
"请确认导出的是 API 格式且包含保存节点",
|
||||
}
|
||||
)
|
||||
else:
|
||||
names = "、".join(f"节点 {o['nodeId']}" for o in unknown[:4])
|
||||
found.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "unknown_output_node",
|
||||
"node_id": names,
|
||||
"message": f"存在无法识别的输出节点({names}),"
|
||||
"需人工指定实际输出节点及其媒体类型(图片/视频)后才能生成",
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def blocking_problem(workflow: dict[str, Any]) -> str | None:
|
||||
"""One Chinese sentence explaining why this workflow cannot be submitted."""
|
||||
errors = [w["message"] for w in workflow_warnings(workflow) if w["level"] == "error"]
|
||||
if not errors:
|
||||
return None
|
||||
shown = ";".join(errors[:4]) + (f";等 {len(errors)} 处" if len(errors) > 4 else "")
|
||||
return f"该工作流文件不完整,ComfyUI 无法执行:{shown}。{REEXPORT_HINT}。"
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Stored output-node mapping for imported workflows (HEL-638).
|
||||
|
||||
Unknown custom output nodes are never auto-typed. The import confirm page
|
||||
(or the connections table) stores the user's explicit designation — which
|
||||
node ids are the real outputs and each one's media type — in
|
||||
``workflows.output_map`` with ``output_map_source='user'``. Everything that
|
||||
re-derives schemas (reanalyze, migration, plain field edits) must keep that
|
||||
decision; only a fresh explicit payload replaces it.
|
||||
|
||||
Rows without unknown candidates carry an ``auto`` map derived from the
|
||||
trusted registry/tagged outputs so the generation path always knows which
|
||||
history nodes to harvest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_outputs
|
||||
from calliope.comfyui.registry import (
|
||||
NUMBER_CLASSES,
|
||||
OUTPUT_CLASSES,
|
||||
TEXT_AREA_CLASSES,
|
||||
)
|
||||
|
||||
VALID_OUTPUT_KINDS = ("image", "video", "audio", "file")
|
||||
|
||||
|
||||
def _wiring_class(class_type: str) -> bool:
|
||||
"""Parameter / wiring classes can never be designated as outputs."""
|
||||
return (
|
||||
not class_type
|
||||
or class_type in TEXT_AREA_CLASSES
|
||||
or class_type in NUMBER_CLASSES
|
||||
or class_type.startswith("Empty")
|
||||
or class_type.startswith("Primitive")
|
||||
)
|
||||
|
||||
|
||||
def _entry_public(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
out = {
|
||||
"nodeId": str(entry.get("nodeId") or ""),
|
||||
"kind": entry.get("kind"),
|
||||
}
|
||||
if entry.get("label"):
|
||||
out["label"] = entry["label"]
|
||||
if entry.get("class_type"):
|
||||
out["class_type"] = entry["class_type"]
|
||||
return out
|
||||
|
||||
|
||||
def auto_output_map(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Trusted outputs only (tagged titles / registry classes)."""
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
return [
|
||||
_entry_public(o)
|
||||
for o in parse_dynamic_outputs(workflow)
|
||||
if o.get("kind") in VALID_OUTPUT_KINDS
|
||||
]
|
||||
|
||||
|
||||
def needs_user_output_map(workflow: dict[str, Any]) -> bool:
|
||||
"""True when the graph has unknown output candidates (HEL-638)."""
|
||||
if not isinstance(workflow, dict):
|
||||
return False
|
||||
return any(
|
||||
o.get("evidence") == "unknown_node" for o in parse_dynamic_outputs(workflow)
|
||||
)
|
||||
|
||||
|
||||
def validate_output_map(
|
||||
workflow: dict[str, Any], entries: Any
|
||||
) -> list[str]:
|
||||
"""Chinese problems with a user-supplied output map; empty = valid."""
|
||||
if not isinstance(entries, list) or not entries:
|
||||
return ["输出节点映射不能为空:请至少指定一个输出节点及其媒体类型"]
|
||||
seen: set[str] = set()
|
||||
problems: list[str] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
problems.append("输出节点映射格式有误(应为对象数组)")
|
||||
continue
|
||||
nid = str(entry.get("nodeId") or "")
|
||||
node = workflow.get(nid) if isinstance(workflow, dict) else None
|
||||
if not nid or not isinstance(node, dict):
|
||||
problems.append(f"输出节点 {nid or '?'} 不存在于该工作流中")
|
||||
continue
|
||||
ct = str(node.get("class_type") or "").strip()
|
||||
if not ct:
|
||||
problems.append(f"节点 {nid} 缺少节点类型,不能作为输出节点")
|
||||
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} 的媒体类型必须是图片、视频、音频或文件")
|
||||
if nid in seen:
|
||||
problems.append(f"输出节点 {nid} 重复指定")
|
||||
seen.add(nid)
|
||||
return problems
|
||||
|
||||
|
||||
def load_output_map(raw: str | None) -> list[dict[str, Any]]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def resolve_output_map(
|
||||
output_map_raw: str | None, source: str | None, workflow: dict[str, Any]
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""The map generation harvests, plus whose decision it is.
|
||||
|
||||
A user map wins as long as its node ids still exist in the stored JSON
|
||||
(defensive pruning — PATCH cannot edit the JSON, but keep it safe);
|
||||
otherwise the trusted auto map applies. Empty list back means "not
|
||||
confirmed yet".
|
||||
"""
|
||||
if source == "user":
|
||||
entries = [
|
||||
e for e in load_output_map(output_map_raw) if str(e.get("nodeId") or "") in
|
||||
(workflow if isinstance(workflow, dict) else {})
|
||||
]
|
||||
if entries:
|
||||
return entries, "user"
|
||||
return [], None
|
||||
auto = auto_output_map(workflow)
|
||||
return (auto, "auto") if auto else ([], None)
|
||||
|
||||
|
||||
def mapping_problem(
|
||||
workflow: dict[str, Any], output_map_raw: str | None, source: str | None
|
||||
) -> str | None:
|
||||
"""Chinese reason this workflow's outputs are not confirmed, else None."""
|
||||
if not needs_user_output_map(workflow):
|
||||
return None
|
||||
entries, resolved = resolve_output_map(output_map_raw, source, workflow)
|
||||
if resolved == "user" and entries:
|
||||
return None
|
||||
return (
|
||||
"该工作流含无法识别的自定义输出节点,需在「ComfyUI 连接」页指定"
|
||||
"实际输出节点及其媒体类型(图片/视频)后才能启用或生成"
|
||||
)
|
||||
@@ -2,18 +2,27 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.patcher import FIELD_KEY_SEP, resolve_patch_field
|
||||
from calliope.comfyui.registry import (
|
||||
AUDIO_CLASSES,
|
||||
IMAGE_CLASSES,
|
||||
IMAGE_URL_CLASSES,
|
||||
NUMBER_CLASSES,
|
||||
OPTIONAL_FRAME_INPUTS,
|
||||
OUTPUT_CLASSES,
|
||||
TEXT_AREA_CLASSES,
|
||||
VIDEO_CLASSES,
|
||||
ComfyOutputKind,
|
||||
class_to_input_kind,
|
||||
class_to_output_kind,
|
||||
optional_frame_sibling,
|
||||
)
|
||||
from calliope.comfyui.roles import (
|
||||
USAGE_VALUES,
|
||||
normalize_input_role,
|
||||
normalize_output_role,
|
||||
parse_title_tag,
|
||||
@@ -67,6 +76,28 @@ def parse_dynamic_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return results
|
||||
|
||||
|
||||
def effective_input_schema(
|
||||
stored: list[dict[str, Any]] | None, workflow: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""The schema a stored workflow exposes now.
|
||||
|
||||
Auto-detected (and empty) schemas are re-derived from the workflow JSON
|
||||
on every read, so parser upgrades reach workflows imported earlier
|
||||
without a re-import. Tagged ``(Input:…)`` schemas are kept as stored.
|
||||
Entries the user corrected on the import confirm page carry
|
||||
``manual: true`` — their role/label survive re-derivation.
|
||||
"""
|
||||
if not stored:
|
||||
return parse_dynamic_inputs(workflow)
|
||||
manual_by_id = {
|
||||
str(e.get("nodeId")): e for e in stored if isinstance(e, dict) and e.get("manual")
|
||||
}
|
||||
if any(isinstance(e, dict) and e.get("auto") for e in stored):
|
||||
fresh = parse_dynamic_inputs(workflow)
|
||||
return [manual_by_id.get(str(e.get("nodeId")), e) for e in fresh]
|
||||
return stored
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Untagged detection
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -185,9 +216,15 @@ def _duration_hint(
|
||||
|
||||
|
||||
def _entry(
|
||||
key: str, label: str, role: str | None, kind: str, default: Any, required: bool
|
||||
key: str,
|
||||
label: str,
|
||||
role: str | None,
|
||||
kind: str,
|
||||
default: Any,
|
||||
required: bool,
|
||||
**extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
entry = {
|
||||
"nodeId": key,
|
||||
"label": label,
|
||||
"role": role,
|
||||
@@ -196,6 +233,260 @@ def _entry(
|
||||
"required": required,
|
||||
"auto": True,
|
||||
}
|
||||
entry.update({k: v for k, v in extra.items() if v is not None})
|
||||
return entry
|
||||
|
||||
|
||||
# ComfyUI's own widget limits (nodes.py / comfy_extras) for the widgets we
|
||||
# expose; the studio validates against them before a job is queued. Seeds
|
||||
# stop at 2**53-1 (ComfyUI allows 2**64-1): larger values lose precision in
|
||||
# the browser's JSON numbers.
|
||||
_NUMBER_LIMITS: dict[str, dict[str, Any]] = {
|
||||
"seed": {"min": 0, "max": 2**53 - 1, "step": 1, "integer": True},
|
||||
"steps": {"min": 1, "max": 10000, "step": 1, "integer": True},
|
||||
"cfg": {"min": 0.0, "max": 100.0, "step": 0.1},
|
||||
"denoise": {"min": 0.0, "max": 1.0, "step": 0.01},
|
||||
"width": {"min": 16, "max": 16384, "step": 8, "integer": True},
|
||||
"height": {"min": 16, "max": 16384, "step": 8, "integer": True},
|
||||
"batch": {"min": 1, "max": 4096, "step": 1, "integer": True},
|
||||
"megapixels": {"min": 0.1, "max": 16.0, "step": 0.1},
|
||||
}
|
||||
|
||||
# Text encoders whose ``resolution`` widget sizes the generated image when an
|
||||
# input image is wired (edit mode); in text-to-image it only sizes references.
|
||||
_RESOLUTION_ENCODERS: dict[str, dict[str, Any]] = {
|
||||
"TextEncodeQwenImage21": {"min": 0, "max": 4096, "step": 32, "integer": True},
|
||||
}
|
||||
|
||||
# comfy_extras/nodes_resolution.py — option strings must match exactly.
|
||||
RESOLUTION_ASPECT_RATIOS: dict[str, tuple[int, int]] = {
|
||||
"1:1 (Square)": (1, 1),
|
||||
"2:3 (Portrait Photo)": (2, 3),
|
||||
"3:2 (Photo)": (3, 2),
|
||||
"3:4 (Portrait Standard)": (3, 4),
|
||||
"4:3 (Standard)": (4, 3),
|
||||
"9:16 (Portrait Widescreen)": (9, 16),
|
||||
"16:9 (Widescreen)": (16, 9),
|
||||
"21:9 (Ultrawide)": (21, 9),
|
||||
}
|
||||
|
||||
|
||||
def resolution_selector_size(aspect_ratio: str, megapixels: float, multiple: int) -> tuple[int, int]:
|
||||
"""Width/height exactly as ComfyUI's ResolutionSelector computes them."""
|
||||
w_ratio, h_ratio = RESOLUTION_ASPECT_RATIOS[aspect_ratio]
|
||||
scale = math.sqrt(megapixels * 1024 * 1024 / (w_ratio * h_ratio))
|
||||
multiple = max(1, int(multiple))
|
||||
return (
|
||||
round(w_ratio * scale / multiple) * multiple,
|
||||
round(h_ratio * scale / multiple) * multiple,
|
||||
)
|
||||
|
||||
|
||||
# ComfyUI Autogrow media inputs a studio form fills as one 1…max list
|
||||
# (HEL-643 generalizes the original image-only registry to three media
|
||||
# kinds). The workflow wires ``<group>.<prefix>N``; extra items are cloned
|
||||
# loader nodes added at patch time (patcher.py). This registry only
|
||||
# beautifies known classes (label + real max + hint key); detection itself
|
||||
# is generic — any ``<group>.<prefix>N`` run of same-family media-loader
|
||||
# links folds into a group, image / video / audio alike.
|
||||
MEDIA_GROUPS: dict[str, dict[str, dict[str, Any]]] = {
|
||||
"MiniMaxH3ReferenceToVideo": {
|
||||
"ref_images": {
|
||||
"prefix": "ref_image_",
|
||||
"max": 9,
|
||||
"label": "参考图",
|
||||
"kind": "image",
|
||||
},
|
||||
"ref_videos": {
|
||||
"prefix": "ref_video_",
|
||||
"max": 3,
|
||||
"label": "参考视频",
|
||||
"kind": "video",
|
||||
},
|
||||
"ref_audios": {
|
||||
"prefix": "ref_audio_",
|
||||
"max": 3,
|
||||
"label": "参考音频",
|
||||
"kind": "audio",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Backwards-compatible alias for the original image-only view.
|
||||
IMAGE_GROUPS = MEDIA_GROUPS
|
||||
|
||||
# Consuming-node classes whose groups carry official usage limits worth a
|
||||
# hint in the UI. Purely cosmetic — detection never depends on it.
|
||||
_MEDIA_HINTS: dict[str, str] = {
|
||||
"MiniMaxH3ReferenceToVideo": "minimax_h3_ref2va",
|
||||
}
|
||||
|
||||
# ``ref_images.ref_image_0`` / ``images.image_1`` style Autogrow input names.
|
||||
_GROUP_INPUT_RE = re.compile(
|
||||
r"^(?P<group>[A-Za-z0-9_+-]+)\.(?P<rest>[A-Za-z0-9_+-]+?)(?P<n>\d+)$"
|
||||
)
|
||||
|
||||
# Consumer input name → (role, label) of the LoadImage wired into it.
|
||||
_FRAME_SLOT_ROLES: dict[str, tuple[str, str]] = {
|
||||
"first_frame": ("first_frame", "首帧"),
|
||||
"start_image": ("first_frame", "首帧"),
|
||||
"last_frame": ("last_frame", "尾帧"),
|
||||
"end_image": ("last_frame", "尾帧"),
|
||||
}
|
||||
|
||||
# Group media kind → (schema kind, role, default label, default max).
|
||||
_GROUP_KIND_META: dict[str, tuple[str, str, str, int]] = {
|
||||
"image": ("image_list", "reference_image", "参考图", 9),
|
||||
"video": ("video_list", "reference_video", "参考视频", 9),
|
||||
"audio": ("audio_list", "reference_audio", "参考音频", 9),
|
||||
}
|
||||
|
||||
# Loader class family for each media kind (registry.py sets).
|
||||
_GROUP_SOURCE_CLASSES: dict[str, frozenset[str]] = {
|
||||
"image": IMAGE_CLASSES,
|
||||
"video": VIDEO_CLASSES,
|
||||
"audio": AUDIO_CLASSES,
|
||||
}
|
||||
|
||||
|
||||
def media_group_spec(class_type: str, group: str) -> dict[str, Any] | None:
|
||||
return MEDIA_GROUPS.get(class_type, {}).get(group)
|
||||
|
||||
|
||||
def image_group_spec(class_type: str, group: str) -> dict[str, Any] | None:
|
||||
return media_group_spec(class_type, group)
|
||||
|
||||
|
||||
def image_group_prefix(node: dict[str, Any], group: str) -> str | None:
|
||||
"""The ``<prefix>`` this node numbers its ``<group>.<prefix>N`` inputs with.
|
||||
|
||||
Known classes read it from the registry; anything else derives it from
|
||||
the node's own wired input names (generic Autogrow detection).
|
||||
"""
|
||||
spec = image_group_spec(str(node.get("class_type") or ""), group)
|
||||
if spec:
|
||||
return str(spec["prefix"])
|
||||
head = f"{group}."
|
||||
prefixes = set()
|
||||
for name in (node.get("inputs") or {}):
|
||||
if not name.startswith(head):
|
||||
continue
|
||||
m = _GROUP_INPUT_RE.match(name)
|
||||
if m and m.group("group") == group:
|
||||
prefixes.add(m.group("rest"))
|
||||
if len(prefixes) == 1:
|
||||
return prefixes.pop()
|
||||
return None
|
||||
|
||||
|
||||
def image_group_slots(
|
||||
node: dict[str, Any], group: str, prefix: str
|
||||
) -> list[tuple[int, str, Any]]:
|
||||
"""``[(N, input name, link), …]`` wired into ``<group>.<prefix>N``, by N."""
|
||||
head = f"{group}.{prefix}"
|
||||
out: list[tuple[int, str, Any]] = []
|
||||
for name, val in (node.get("inputs") or {}).items():
|
||||
if name.startswith(head) and name[len(head):].isdigit():
|
||||
out.append((int(name[len(head):]), name, val))
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def _detected_group_runs(
|
||||
workflow: dict[str, Any], node: dict[str, Any]
|
||||
) -> list[tuple[str, str, list[str], str]]:
|
||||
"""``[(group, prefix, source loader ids, media kind)]`` for Autogrow inputs.
|
||||
|
||||
A run qualifies when the numbers are consecutive, every slot links to a
|
||||
loader of the SAME media family (image / video / audio), and the run
|
||||
either starts at 0 (single-slot Autogrow exports like H3's
|
||||
``ref_image_0``) or has ≥2 slots. Single runs numbered from 1 (e.g.
|
||||
TextEncodeQwenImage21's ``images.image_1``) stay ordinary image slots.
|
||||
"""
|
||||
runs: dict[tuple[str, str], dict[int, Any]] = {}
|
||||
for name, val in (node.get("inputs") or {}).items():
|
||||
m = _GROUP_INPUT_RE.match(name)
|
||||
if not m or not (isinstance(val, list) and len(val) == 2):
|
||||
continue
|
||||
runs.setdefault((m.group("group"), m.group("rest")), {})[int(m.group("n"))] = val
|
||||
out: list[tuple[str, str, list[str], str]] = []
|
||||
for (group, prefix), slots in sorted(runs.items()):
|
||||
ns = sorted(slots)
|
||||
if any(b - a != 1 for a, b in zip(ns, ns[1:])):
|
||||
continue
|
||||
if not (ns[0] == 0 or len(ns) >= 2):
|
||||
continue
|
||||
sources: list[str] = []
|
||||
family: str | None = None
|
||||
for n in ns:
|
||||
link = slots[n]
|
||||
src = workflow.get(str(link[0]))
|
||||
if not isinstance(src, dict):
|
||||
family = None
|
||||
break
|
||||
ct = str(src.get("class_type") or "")
|
||||
kinds = [
|
||||
kind
|
||||
for kind, classes in _GROUP_SOURCE_CLASSES.items()
|
||||
if ct in classes
|
||||
]
|
||||
if len(kinds) != 1 or (family is not None and kinds[0] != family):
|
||||
family = None
|
||||
break
|
||||
family = kinds[0]
|
||||
sources.append(str(link[0]))
|
||||
if family and sources:
|
||||
out.append((group, prefix, sources, family))
|
||||
return out
|
||||
|
||||
|
||||
def _media_groups(
|
||||
workflow: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], set[str]]:
|
||||
"""Group entries for Autogrow reference inputs (image/video/audio) plus
|
||||
the loader node ids they absorb."""
|
||||
entries: list[dict[str, Any]] = []
|
||||
absorbed: set[str] = set()
|
||||
for nid, node in sorted(
|
||||
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
|
||||
key=lambda kv: _node_sort_key(kv[0]),
|
||||
):
|
||||
class_type = str(node.get("class_type") or "")
|
||||
for group, _prefix, sources, media_kind in _detected_group_runs(workflow, node):
|
||||
spec = media_group_spec(class_type, group) or {}
|
||||
kind, role, default_label, default_max = _GROUP_KIND_META[media_kind]
|
||||
absorbed.update(sources)
|
||||
hint = _MEDIA_HINTS.get(class_type)
|
||||
entries.append(
|
||||
_entry(
|
||||
f"{nid}{FIELD_KEY_SEP}{group}",
|
||||
str(spec.get("label") or default_label),
|
||||
role,
|
||||
kind,
|
||||
None,
|
||||
True,
|
||||
min=1,
|
||||
max=spec.get("max", default_max),
|
||||
media_hint=hint,
|
||||
)
|
||||
)
|
||||
return entries, absorbed
|
||||
|
||||
|
||||
def _image_slot(
|
||||
nid: str, workflow: dict[str, Any], consumers: dict[str, list[tuple[str, str]]]
|
||||
) -> tuple[str, str] | None:
|
||||
"""(role, label) of a LoadImage from what consumes it; None when unwired."""
|
||||
uses = consumers.get(nid, [])
|
||||
if not uses:
|
||||
return None
|
||||
for _cid, name in uses:
|
||||
if name in _FRAME_SLOT_ROLES:
|
||||
return _FRAME_SLOT_ROLES[name]
|
||||
for cid, name in uses:
|
||||
ct = str((workflow.get(cid) or {}).get("class_type") or "")
|
||||
if "TextEncode" in ct and name.startswith("image"):
|
||||
return "image", "输入图"
|
||||
return "image", ""
|
||||
|
||||
|
||||
def _key(node_id: str, node: dict[str, Any], field: str) -> str:
|
||||
@@ -221,6 +512,7 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
)
|
||||
|
||||
texts: list[tuple[str, dict[str, Any], str, bool]] = [] # (id, node, field, negative)
|
||||
groups, grouped_loaders = _media_groups(workflow)
|
||||
media: list[dict[str, Any]] = []
|
||||
numbers: list[dict[str, Any]] = []
|
||||
taken_roles: set[str] = set()
|
||||
@@ -232,7 +524,12 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if not _is_scalar(value) or isinstance(value, str):
|
||||
return
|
||||
taken_roles.add(role)
|
||||
numbers.append(_entry(_key(nid, node, field), label, role, "number", value, False))
|
||||
numbers.append(
|
||||
_entry(
|
||||
_key(nid, node, field), label, role, "number", value, False,
|
||||
**_NUMBER_LIMITS.get(role, {}),
|
||||
)
|
||||
)
|
||||
|
||||
for nid, node in nodes:
|
||||
ct = str(node.get("class_type") or "")
|
||||
@@ -241,23 +538,39 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
lowered = title.lower()
|
||||
|
||||
if ct in IMAGE_CLASSES or ct in _MASK_IMAGE_CLASSES:
|
||||
if nid in grouped_loaders:
|
||||
continue
|
||||
is_mask = ct in _MASK_IMAGE_CLASSES or "mask" in lowered or "遮罩" in lowered
|
||||
label = title if title and title != ct else ("遮罩" if is_mask else "参考图")
|
||||
if is_mask and "mask" not in label.lower() and "遮罩" not in label:
|
||||
label = f"遮罩 · {label}"
|
||||
has_default = isinstance(inputs.get("image"), str) and bool(inputs.get("image"))
|
||||
slot = _image_slot(nid, workflow, consumers)
|
||||
if is_mask:
|
||||
label = title if title and title != ct else "遮罩"
|
||||
if "mask" not in label.lower() and "遮罩" not in label:
|
||||
label = f"遮罩 · {label}"
|
||||
has_default = isinstance(inputs.get("image"), str) and bool(inputs.get("image"))
|
||||
media.append(
|
||||
_entry(_key(nid, node, "image"), label, "mask", "image", None, not has_default)
|
||||
)
|
||||
continue
|
||||
if slot is None:
|
||||
# Nothing consumes it — ComfyUI never runs it, so no upload slot.
|
||||
continue
|
||||
role, label = slot
|
||||
media.append(
|
||||
_entry(
|
||||
_key(nid, node, "image"),
|
||||
label,
|
||||
"mask" if is_mask else "image",
|
||||
label or (title if title and title != ct else "参考图"),
|
||||
role,
|
||||
"image",
|
||||
None,
|
||||
not has_default,
|
||||
True,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if ct in VIDEO_CLASSES or ct in AUDIO_CLASSES:
|
||||
if nid in grouped_loaders:
|
||||
# Absorbed into an Autogrow media group (HEL-643) — the
|
||||
# group entry owns its upload slots.
|
||||
continue
|
||||
kind = "video" if ct in VIDEO_CLASSES else "audio"
|
||||
field = resolve_patch_field(node)
|
||||
has_default = isinstance(inputs.get(field), str) and bool(inputs.get(field))
|
||||
@@ -279,23 +592,72 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
texts.append((nid, node, neg_field, True))
|
||||
elif field:
|
||||
texts.append((nid, node, field, _is_negative(nid, lowered, consumers)))
|
||||
edits_image = any(
|
||||
k.startswith("images.") and isinstance(v, list) for k, v in inputs.items()
|
||||
)
|
||||
if ct in _RESOLUTION_ENCODERS and edits_image and "resolution" not in taken_roles:
|
||||
value = inputs.get("resolution")
|
||||
if _is_scalar(value) and not isinstance(value, str):
|
||||
taken_roles.add("resolution")
|
||||
numbers.append(
|
||||
_entry(
|
||||
_key(nid, node, "resolution"),
|
||||
"输出分辨率",
|
||||
"resolution",
|
||||
"number",
|
||||
value,
|
||||
False,
|
||||
**_RESOLUTION_ENCODERS[ct],
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if ct in _SAMPLER_CLASSES:
|
||||
add_number(nid, node, "seed" if "seed" in inputs else "noise_seed", "seed", "随机种子")
|
||||
add_number(nid, node, "steps", "steps", "采样步数")
|
||||
add_number(nid, node, "cfg", "cfg", "CFG")
|
||||
add_number(nid, node, "denoise", "denoise", "降噪强度")
|
||||
continue
|
||||
if ct == "RandomNoise":
|
||||
add_number(nid, node, "noise_seed", "seed", "随机种子")
|
||||
continue
|
||||
if ct == "BasicScheduler":
|
||||
add_number(nid, node, "steps", "steps", "采样步数")
|
||||
add_number(nid, node, "denoise", "denoise", "降噪强度")
|
||||
continue
|
||||
if ct == "ResolutionSelector" and nid in consumers:
|
||||
ratio = inputs.get("aspect_ratio")
|
||||
if (
|
||||
"aspect_ratio" not in taken_roles
|
||||
and isinstance(ratio, str)
|
||||
and ratio in RESOLUTION_ASPECT_RATIOS
|
||||
):
|
||||
taken_roles.add("aspect_ratio")
|
||||
multiple = inputs.get("multiple")
|
||||
numbers.append(
|
||||
_entry(
|
||||
_key(nid, node, "aspect_ratio"),
|
||||
"画面比例",
|
||||
"aspect_ratio",
|
||||
"select",
|
||||
ratio,
|
||||
False,
|
||||
options=list(RESOLUTION_ASPECT_RATIOS),
|
||||
resolution={
|
||||
"megapixels": _key(nid, node, "megapixels"),
|
||||
"multiple": multiple if isinstance(multiple, int) else 8,
|
||||
},
|
||||
)
|
||||
)
|
||||
add_number(nid, node, "megapixels", "megapixels", "总像素(百万)")
|
||||
continue
|
||||
if ct == "CFGGuider":
|
||||
add_number(nid, node, "cfg", "cfg", "CFG")
|
||||
continue
|
||||
if ct.startswith("Empty") and "Latent" in ct:
|
||||
if nid not in consumers:
|
||||
# Orphan latent (Qwen edit sizes from its encoder): editing it changes nothing.
|
||||
continue
|
||||
add_number(nid, node, "width", "width", "宽度")
|
||||
add_number(nid, node, "height", "height", "高度")
|
||||
add_number(nid, node, "batch_size", "batch", "批次数量")
|
||||
@@ -316,10 +678,20 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
role = None
|
||||
if role:
|
||||
taken_roles.add(role)
|
||||
limits = dict(_NUMBER_LIMITS.get(role or "", {}))
|
||||
if ct in ("PrimitiveInt", "INT"):
|
||||
limits["integer"] = True
|
||||
if role == "duration":
|
||||
limits.setdefault("min", 0.1)
|
||||
numbers.append(
|
||||
_entry(_key(nid, node, "value"), label, role, "number", inputs["value"], False)
|
||||
_entry(
|
||||
_key(nid, node, "value"), label, role, "number", inputs["value"], False,
|
||||
**limits,
|
||||
)
|
||||
)
|
||||
|
||||
media = _with_optional_frames(workflow, media)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
positives = [t for t in texts if not t[3]]
|
||||
negatives = [t for t in texts if t[3]]
|
||||
@@ -359,9 +731,46 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
)
|
||||
results.extend(numbers)
|
||||
results.extend(media)
|
||||
results.extend(groups)
|
||||
return results
|
||||
|
||||
|
||||
def _with_optional_frames(
|
||||
workflow: dict[str, Any], media: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Insert an optional slot after each frame slot whose node also accepts
|
||||
an unwired optional frame (H3 FL2VA: 首帧 → 尾帧)."""
|
||||
out = list(media)
|
||||
for nid, node in sorted(
|
||||
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
|
||||
key=lambda kv: _node_sort_key(kv[0]),
|
||||
):
|
||||
for field in OPTIONAL_FRAME_INPUTS.get(str(node.get("class_type") or ""), {}):
|
||||
sibling = optional_frame_sibling(node, field)
|
||||
if sibling is None:
|
||||
continue
|
||||
loader_id = str(node["inputs"][sibling][0])
|
||||
loader = workflow.get(loader_id)
|
||||
if not isinstance(loader, dict) or loader.get("class_type") not in IMAGE_CLASSES:
|
||||
continue
|
||||
at = next(
|
||||
(
|
||||
i
|
||||
for i, e in enumerate(out)
|
||||
if e["nodeId"] == _key(loader_id, loader, "image")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if at is None:
|
||||
continue
|
||||
role, label = _FRAME_SLOT_ROLES[field]
|
||||
out.insert(
|
||||
at + 1,
|
||||
_entry(f"{nid}{FIELD_KEY_SEP}{field}", label, role, "image", None, False),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _is_negative(
|
||||
node_id: str, lowered_title: str, consumers: dict[str, list[tuple[str, str]]]
|
||||
) -> bool:
|
||||
@@ -381,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),
|
||||
@@ -393,4 +800,344 @@ def parse_dynamic_outputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"kind": out_kind,
|
||||
}
|
||||
)
|
||||
if not results:
|
||||
results = detect_output_nodes(workflow)
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output (sink) detection + five-usage classification (HEL-636)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Nodes whose output another node consumes are wiring, not results. A node
|
||||
# nothing consumes is a sink; media sinks are the workflow's outputs.
|
||||
_INPUT_ONLY_CLASSES = IMAGE_CLASSES | IMAGE_URL_CLASSES | AUDIO_CLASSES | VIDEO_CLASSES
|
||||
# Latent/source shapers (EmptyLatentImage…) are wiring even when nothing
|
||||
# consumes them — they never carry results out of the graph.
|
||||
_SOURCE_ONLY_CLASSES = _INPUT_ONLY_CLASSES | TEXT_AREA_CLASSES | NUMBER_CLASSES
|
||||
|
||||
# Classes that are known-internal tails: standard decode steps and cache
|
||||
# cleaners consume media but never emit history outputs, so they can never
|
||||
# be output candidates (HEL-638: a graph ending in these has no output).
|
||||
_NON_OUTPUT_CLASSES = frozenset(
|
||||
{"VAEDecode", "VAEDecodeTiled", "VAEDecodeAudio", "easy cleanGpuUsed"}
|
||||
)
|
||||
|
||||
# Widget names save-style custom nodes carry, and link input names that mark
|
||||
# "consumes rendered media" — used to flag unknown sinks that need mapping.
|
||||
_SAVE_WIDGET_NAMES = frozenset(
|
||||
{"filename_prefix", "filename", "prefix", "path", "output_path", "save_output"}
|
||||
)
|
||||
_MEDIA_LINK_NAMES = frozenset(
|
||||
{"images", "image", "video", "frames", "latent", "latents", "audio", "audios"}
|
||||
)
|
||||
|
||||
|
||||
def _sinks(
|
||||
workflow: dict[str, Any], consumers: dict[str, list[tuple[str, str]]]
|
||||
) -> list[tuple[str, dict[str, Any]]]:
|
||||
out: list[tuple[str, dict[str, Any]]] = []
|
||||
for nid, node in sorted(
|
||||
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
|
||||
key=lambda kv: _node_sort_key(kv[0]),
|
||||
):
|
||||
if not consumers.get(nid):
|
||||
out.append((nid, node))
|
||||
return out
|
||||
|
||||
|
||||
def _unknown_sink_looks_like_save(node: dict[str, Any], workflow: dict[str, Any]) -> bool:
|
||||
inputs = node.get("inputs") or {}
|
||||
for name, val in inputs.items():
|
||||
if name in _SAVE_WIDGET_NAMES and isinstance(val, (str, int, float, bool)):
|
||||
return True
|
||||
if name in _MEDIA_LINK_NAMES and isinstance(val, list) and len(val) == 2:
|
||||
src = workflow.get(str(val[0]))
|
||||
if isinstance(src, dict) and "decode" in str(src.get("class_type") or "").lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _node_has_save_widget(node: dict[str, Any]) -> bool:
|
||||
"""A concrete save widget (filename/prefix/path) — the only signal that
|
||||
counts for unknown nodes that something else consumes; media links from
|
||||
decode steps are meaningless there (every transform has them)."""
|
||||
inputs = node.get("inputs") or {}
|
||||
return any(
|
||||
name in _SAVE_WIDGET_NAMES and isinstance(val, (str, int, float, bool))
|
||||
for name, val in inputs.items()
|
||||
)
|
||||
|
||||
|
||||
def _video_semantics(workflow: dict[str, Any]) -> bool:
|
||||
"""Auxiliary evidence the graph renders video (frame counts, video nodes)."""
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
ct = str(node.get("class_type") or "").lower()
|
||||
if "video" in ct or "animatediff" in ct:
|
||||
return True
|
||||
for name in (node.get("inputs") or {}):
|
||||
if name in _FRAME_INPUTS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _suggest_output_kind(class_type: str, videoish: bool) -> str | None:
|
||||
"""Best-guess media type for an unknown candidate — UI default only."""
|
||||
lower = class_type.lower()
|
||||
if "video" in lower or "animatediff" in lower:
|
||||
return "video"
|
||||
if "image" in lower or "save" in lower:
|
||||
return "image"
|
||||
return "video" if videoish else None
|
||||
|
||||
|
||||
def _unknown_output_entry(
|
||||
nid: str, node: dict[str, Any], workflow: dict[str, Any], videoish: bool
|
||||
) -> dict[str, Any]:
|
||||
ct = str(node.get("class_type") or "")
|
||||
return {
|
||||
"nodeId": nid,
|
||||
"label": _title_of(node) or ct,
|
||||
"role": None,
|
||||
# HEL-638: unknown custom nodes are never auto-typed — the user
|
||||
# picks the media type; `suggested_kind` is only a form default.
|
||||
"kind": None,
|
||||
"class_type": ct,
|
||||
"evidence": "unknown_node",
|
||||
"suggested_kind": _suggest_output_kind(ct, videoish),
|
||||
}
|
||||
|
||||
|
||||
def detect_output_nodes(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Infer output nodes of an untagged workflow from its sinks.
|
||||
|
||||
Exact registry classes (SaveImage, VHS_VideoCombine…) count whether or
|
||||
not something downstream consumes them (a SaveVideo feeding a
|
||||
cache-cleanup tail is still the output). Every OTHER class at an output
|
||||
position is an *unknown candidate*: kind stays null, the import page
|
||||
asks the user to designate the real output node(s) and their media
|
||||
type, and the workflow stays pending until then (HEL-638). Sinks with
|
||||
no save features only become candidates when the graph has no trusted
|
||||
output at all — next to a real saver they are utility tails.
|
||||
"""
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
consumers = _consumers(workflow)
|
||||
videoish = _video_semantics(workflow)
|
||||
registry_outputs: list[dict[str, Any]] = []
|
||||
candidates: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _is_wiring(ct: str) -> bool:
|
||||
return (
|
||||
not ct
|
||||
or ct in _SOURCE_ONLY_CLASSES
|
||||
or ct in _NON_OUTPUT_CLASSES
|
||||
or ct.startswith("Empty")
|
||||
)
|
||||
|
||||
# 1) Trusted registry outputs at sink positions.
|
||||
for nid, node in _sinks(workflow, consumers):
|
||||
ct = str(node.get("class_type") or "")
|
||||
if ct in OUTPUT_CLASSES:
|
||||
registry_outputs.append(
|
||||
{
|
||||
"nodeId": nid,
|
||||
"label": _title_of(node) or ct,
|
||||
"role": None,
|
||||
"kind": class_to_output_kind(ct),
|
||||
"class_type": ct,
|
||||
"evidence": "registry",
|
||||
}
|
||||
)
|
||||
seen.add(nid)
|
||||
|
||||
# 2) Registry output classes consumed by other nodes are outputs too.
|
||||
for nid, node in sorted(
|
||||
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
|
||||
key=lambda kv: _node_sort_key(kv[0]),
|
||||
):
|
||||
ct = str(node.get("class_type") or "")
|
||||
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": class_to_output_kind(ct),
|
||||
"class_type": ct,
|
||||
"evidence": "registry",
|
||||
}
|
||||
)
|
||||
seen.add(nid)
|
||||
|
||||
# 3) Unknown sink candidates. Save-looking sinks always need mapping;
|
||||
# feature-less sinks only when nothing trusted exists (utility tails
|
||||
# next to a real saver stay invisible).
|
||||
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 OUTPUT_CLASSES:
|
||||
continue
|
||||
if _unknown_sink_looks_like_save(node, workflow) or not has_trusted:
|
||||
candidates.append(_unknown_output_entry(nid, node, workflow, videoish))
|
||||
seen.add(nid)
|
||||
|
||||
# 4) Unknown save-looking nodes that something consumes (a custom saver
|
||||
# feeding a cleanup tail is still an output candidate). Only concrete
|
||||
# save widgets count here — decode-fed media links are ubiquitous among
|
||||
# mid-graph transforms (CreateVideo & co.) and never mark an output.
|
||||
for nid, node in sorted(
|
||||
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
|
||||
key=lambda kv: _node_sort_key(kv[0]),
|
||||
):
|
||||
ct = str(node.get("class_type") or "")
|
||||
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))
|
||||
seen.add(nid)
|
||||
|
||||
results = registry_outputs + candidates
|
||||
results.sort(key=lambda r: _node_sort_key(r["nodeId"]))
|
||||
return results
|
||||
|
||||
|
||||
# Title heuristics the studio form applies to tagged (Input:…) slots; the
|
||||
# classifier must judge a workflow the same way the form renders it.
|
||||
_TAGGED_FIRST_RE = re.compile(r"first|start|首帧", re.IGNORECASE)
|
||||
_TAGGED_LAST_RE = re.compile(r"last|end|尾帧", re.IGNORECASE)
|
||||
_TAGGED_MASK_RE = re.compile(r"mask|遮罩", re.IGNORECASE)
|
||||
|
||||
|
||||
def _slot_effective_role(entry: dict[str, Any]) -> str | None:
|
||||
"""The role the generate form will act on for this schema entry."""
|
||||
kind = str(entry.get("kind") or "")
|
||||
if kind == "image_list":
|
||||
return "reference_image"
|
||||
role = normalize_input_role(entry.get("role"))
|
||||
if entry.get("auto") or kind not in ("image", "image_url"):
|
||||
return role
|
||||
label = str(entry.get("label") or "")
|
||||
if _TAGGED_MASK_RE.search(label):
|
||||
return "mask"
|
||||
if _TAGGED_FIRST_RE.search(label):
|
||||
return "first_frame"
|
||||
if _TAGGED_LAST_RE.search(label):
|
||||
return "last_frame"
|
||||
return role
|
||||
|
||||
|
||||
def classify_usage(workflow: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Judge a workflow's usage from graph evidence (HEL-636 §2).
|
||||
|
||||
Returns ``usage`` plus everything the import confirm page shows:
|
||||
``ambiguous`` (needs a human decision), ``needs_mapping`` (unknown
|
||||
output nodes), ``problem`` (Chinese reason when usage is None) and
|
||||
``evidence`` (output nodes, image/video slots, summary lines). Never
|
||||
looks at file names, model names or fixed node ids.
|
||||
"""
|
||||
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", "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})
|
||||
|
||||
image_slots: list[dict[str, Any]] = []
|
||||
video_slots: list[dict[str, Any]] = []
|
||||
for entry in inputs:
|
||||
kind = str(entry.get("kind") or "")
|
||||
if kind in ("image", "image_list", "image_url"):
|
||||
image_slots.append(
|
||||
{
|
||||
"nodeId": entry.get("nodeId"),
|
||||
"label": entry.get("label"),
|
||||
"kind": kind,
|
||||
"role": _slot_effective_role(entry),
|
||||
}
|
||||
)
|
||||
elif kind == "video":
|
||||
video_slots.append(
|
||||
{
|
||||
"nodeId": entry.get("nodeId"),
|
||||
"label": entry.get("label"),
|
||||
"kind": kind,
|
||||
"role": normalize_input_role(entry.get("role")),
|
||||
}
|
||||
)
|
||||
|
||||
effective = [s["role"] or "image" for s in image_slots if s["role"] != "mask"]
|
||||
bulk = [r for r in effective if r in (None, "image")]
|
||||
has_frame = any(r in ("first_frame", "last_frame") for r in effective)
|
||||
has_ref_group = any(s["kind"] == "image_list" for s in image_slots)
|
||||
|
||||
summary: list[str] = []
|
||||
for o in media_outputs:
|
||||
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(
|
||||
f"节点 {o['nodeId']}({o.get('class_type') or '?'})" for o in unknown_outputs[:4]
|
||||
)
|
||||
summary.append(f"存在无法识别的输出节点:{names},需人工指定输出节点及媒体类型")
|
||||
for s in image_slots:
|
||||
if s["role"] != "mask":
|
||||
summary.append(f"图片输入:节点 {s['nodeId']}({s['label']})")
|
||||
if video_slots:
|
||||
summary.append("含视频输入槽(本版本不支持参考视频上传)")
|
||||
|
||||
usage: str | None = None
|
||||
ambiguous = False
|
||||
problem: str | None = None
|
||||
if not out_kinds and not unknown_outputs:
|
||||
problem = (
|
||||
"未在图中找到输出节点(SaveImage / 视频合成等),"
|
||||
"请确认导出的是 API 格式且包含保存节点"
|
||||
)
|
||||
elif unknown_outputs:
|
||||
# HEL-638: any unknown custom output node keeps the workflow
|
||||
# pending — no name-based guessing, and picking a usage alone must
|
||||
# not bypass the output-node designation.
|
||||
names = "、".join(
|
||||
f"节点 {o['nodeId']}({o.get('class_type') or '?'})" for o in unknown_outputs[:4]
|
||||
)
|
||||
ambiguous = True
|
||||
problem = (
|
||||
f"存在无法识别的输出节点({names}),"
|
||||
"需人工指定实际输出节点及其媒体类型(图片/视频)后才能保存启用"
|
||||
)
|
||||
elif len(out_kinds) > 1:
|
||||
ambiguous = True
|
||||
problem = "同一工作流同时输出图片和视频,需人工选择主用途"
|
||||
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:
|
||||
usage = "image2video"
|
||||
elif not effective:
|
||||
usage = "text2video"
|
||||
else:
|
||||
ambiguous = True
|
||||
problem = "视频工作流中的单张图片输入无法判定是首帧还是参考图,需人工确认"
|
||||
|
||||
return {
|
||||
"usage": usage,
|
||||
"ambiguous": ambiguous,
|
||||
"needs_mapping": bool(unknown_outputs),
|
||||
"problem": problem,
|
||||
"evidence": {
|
||||
"outputs": outputs,
|
||||
"image_slots": image_slots,
|
||||
"video_slots": video_slots,
|
||||
"summary": summary,
|
||||
},
|
||||
"usable_usage": list(USAGE_VALUES),
|
||||
}
|
||||
|
||||
@@ -5,12 +5,45 @@ from __future__ import annotations
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.registry import class_to_patch_field
|
||||
from calliope.comfyui.registry import (
|
||||
AUDIO_CLASSES,
|
||||
IMAGE_CLASSES,
|
||||
VIDEO_CLASSES,
|
||||
VIDEO_FILE_CLASSES,
|
||||
class_to_patch_field,
|
||||
optional_frame_sibling,
|
||||
)
|
||||
|
||||
# Input keys of the form "<nodeId>@<widget>" target one named widget on a
|
||||
# node (auto-detected inputs: KSampler seed/steps/cfg, latent width/height…).
|
||||
FIELD_KEY_SEP = "@"
|
||||
|
||||
# Media-loader families and the Chinese slot label used on cloned nodes.
|
||||
_GROUP_LABELS: tuple[tuple[frozenset[str], str], ...] = (
|
||||
(IMAGE_CLASSES, "参考图"),
|
||||
(VIDEO_CLASSES, "参考视频"),
|
||||
(AUDIO_CLASSES, "参考音频"),
|
||||
)
|
||||
|
||||
|
||||
def _loader_label(class_type: str) -> str:
|
||||
for classes, label in _GROUP_LABELS:
|
||||
if class_type in classes:
|
||||
return label
|
||||
return "参考素材"
|
||||
|
||||
|
||||
def _loader_field(class_type: str, inputs: dict[str, Any]) -> str:
|
||||
"""The widget a loader node of this class stores its file value in."""
|
||||
if class_type in IMAGE_CLASSES:
|
||||
return "image"
|
||||
if class_type in VIDEO_CLASSES:
|
||||
return "file" if class_type in VIDEO_FILE_CLASSES else "video"
|
||||
if class_type in AUDIO_CLASSES:
|
||||
# VHS_LoadAudio names its widget "audio:" (with the colon).
|
||||
return "audio" if "audio" in inputs else "audio:"
|
||||
return "image"
|
||||
|
||||
|
||||
def _resolve_field(field: str, inputs: dict[str, Any]) -> str:
|
||||
"""Map the computed patch field onto a key that exists on this node.
|
||||
@@ -38,6 +71,98 @@ def resolve_patch_field(node: dict[str, Any]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _next_node_id(workflow: dict[str, Any]) -> str:
|
||||
numeric = [int(k) for k in workflow if str(k).isdigit()]
|
||||
return str(max(numeric, default=0) + 1)
|
||||
|
||||
|
||||
def apply_media_group(
|
||||
workflow: dict[str, Any], node_id: str, group: str, paths: list[str]
|
||||
) -> None:
|
||||
"""Rewire an Autogrow media group (image/video/audio) to exactly ``paths``.
|
||||
|
||||
``<group>.<prefix>0 … N-1`` each get their own loader node: the
|
||||
workflow's existing ones are reused in slot order, extra ones are clones
|
||||
of the first, and slots/loaders left over from the export are removed so
|
||||
no stale media reaches ComfyUI. File values are local paths —
|
||||
``prepare_media_inputs`` uploads them right before queueing.
|
||||
"""
|
||||
from calliope.comfyui.parser import image_group_prefix, image_group_slots
|
||||
|
||||
node = workflow.get(node_id)
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
prefix = image_group_prefix(node, group)
|
||||
if prefix is None:
|
||||
return
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
slots = image_group_slots(node, group, prefix)
|
||||
loaders: list[str] = []
|
||||
for _n, name, link in slots:
|
||||
inputs.pop(name, None)
|
||||
if isinstance(link, list) and len(link) == 2:
|
||||
src = workflow.get(str(link[0]))
|
||||
if isinstance(src, dict) and str(src.get("class_type") or "") in (
|
||||
IMAGE_CLASSES | VIDEO_CLASSES | AUDIO_CLASSES
|
||||
):
|
||||
loaders.append(str(link[0]))
|
||||
if not loaders:
|
||||
return
|
||||
template = copy.deepcopy(workflow[loaders[0]])
|
||||
label = _loader_label(str(template.get("class_type") or ""))
|
||||
|
||||
still_used: set[str] = set()
|
||||
for nid, other in workflow.items():
|
||||
if nid == node_id or not isinstance(other, dict):
|
||||
continue
|
||||
for val in (other.get("inputs") or {}).values():
|
||||
if isinstance(val, list) and len(val) == 2:
|
||||
still_used.add(str(val[0]))
|
||||
for nid in loaders[len(paths):]:
|
||||
if nid not in still_used and nid not in loaders[: len(paths)]:
|
||||
workflow.pop(nid, None)
|
||||
|
||||
for i, path in enumerate(paths):
|
||||
if i < len(loaders):
|
||||
nid = loaders[i]
|
||||
loader = workflow[nid]
|
||||
else:
|
||||
nid = _next_node_id(workflow)
|
||||
loader = copy.deepcopy(template)
|
||||
loader_inputs = dict(loader.get("inputs") or {})
|
||||
field = _loader_field(str(loader.get("class_type") or ""), loader_inputs)
|
||||
loader_inputs[field] = path
|
||||
loader["inputs"] = loader_inputs
|
||||
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{label} {i + 1}"}
|
||||
workflow[nid] = loader
|
||||
inputs[f"{group}.{prefix}{i}"] = [nid, 0]
|
||||
node["inputs"] = inputs
|
||||
|
||||
|
||||
# Original name kept: image groups were the first (and most common) family.
|
||||
apply_image_group = apply_media_group
|
||||
|
||||
|
||||
def apply_optional_frame(
|
||||
workflow: dict[str, Any], node_id: str, field: str, sibling: str, path: str
|
||||
) -> None:
|
||||
"""Wire ``path`` into an unwired optional frame input (H3 ``last_frame``)
|
||||
through a new LoadImage cloned from the ``sibling`` frame's loader."""
|
||||
node = workflow[node_id]
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
src = workflow.get(str(inputs[sibling][0]))
|
||||
if isinstance(src, dict) and str(src.get("class_type") or "") in IMAGE_CLASSES:
|
||||
loader = copy.deepcopy(src)
|
||||
else:
|
||||
loader = {"class_type": "LoadImage", "inputs": {}}
|
||||
loader["inputs"] = {**(loader.get("inputs") or {}), "image": path}
|
||||
loader["_meta"] = {"title": "尾帧" if field == "last_frame" else field}
|
||||
nid = _next_node_id(workflow)
|
||||
workflow[nid] = loader
|
||||
inputs[field] = [nid, 0]
|
||||
node["inputs"] = inputs
|
||||
|
||||
|
||||
def patch_workflow(
|
||||
base: dict[str, Any],
|
||||
values_by_node_id: dict[str, Any],
|
||||
@@ -54,6 +179,19 @@ def patch_workflow(
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
if field is not None and field not in inputs and any(
|
||||
k.startswith(f"{field}.") for k in inputs
|
||||
):
|
||||
paths = [value] if isinstance(value, str) else value
|
||||
if isinstance(paths, list):
|
||||
clean = [p for p in paths if isinstance(p, str) and p.strip()]
|
||||
if clean:
|
||||
apply_media_group(patched, key, field, clean)
|
||||
continue
|
||||
if field is not None and (sibling := optional_frame_sibling(node, field)):
|
||||
if isinstance(value, str) and value.strip():
|
||||
apply_optional_frame(patched, key, field, sibling, value)
|
||||
continue
|
||||
if field is None:
|
||||
field = resolve_patch_field(node)
|
||||
elif isinstance(inputs.get(field), list):
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""L2 connection preflight: check a workflow against a live /object_info (HEL-642/643).
|
||||
|
||||
Layer 1 (lint.py) judges the JSON on its own; this module judges the JSON
|
||||
against what the target ComfyUI actually has installed. Two generic rules,
|
||||
zero hard-coding for any specific workflow or model name:
|
||||
|
||||
1. Node existence — every ``class_type`` in the JSON must exist as a key of
|
||||
the server's /object_info catalogue; missing ones are custom nodes the
|
||||
user has not installed.
|
||||
2. Combo value legality — for every combo input the server declares (an
|
||||
input whose spec carries a candidate list — unet_name/clip_name/
|
||||
sampler/scheduler/…), the JSON's current widget value must be one of the
|
||||
candidates; a miss usually means a model file or component is missing.
|
||||
|
||||
File-upload widgets (LoadImage.image and friends) are skipped: their combo
|
||||
lists are the server's input directory at export time and the value is
|
||||
replaced by an upload before every submit, so a miss there says nothing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.registry import AUDIO_CLASSES, IMAGE_CLASSES, VIDEO_CLASSES
|
||||
|
||||
# Media-loader classes and the widget names that hold their file values —
|
||||
# upload-backed combos that cannot be judged before an upload happens.
|
||||
_MEDIA_LOADER_FIELDS: tuple[frozenset[str], frozenset[str]] = (
|
||||
(IMAGE_CLASSES, frozenset({"image"})),
|
||||
(AUDIO_CLASSES, frozenset({"audio", "audio:"})),
|
||||
(VIDEO_CLASSES, frozenset({"file", "video"})),
|
||||
)
|
||||
|
||||
# object_info widget-config keys that mark an input as file-upload backed.
|
||||
_UPLOAD_CONFIG_KEYS = ("image_upload", "audio_upload", "video_upload", "upload")
|
||||
|
||||
_MAX_CANDIDATES_SHOWN = 6
|
||||
|
||||
|
||||
def _node_title(node: dict[str, Any]) -> str:
|
||||
title = (node.get("_meta") or {}).get("title")
|
||||
return str(title).strip() if title else ""
|
||||
|
||||
|
||||
def _is_upload_backed(class_type: str, input_name: str, config: Any) -> bool:
|
||||
if isinstance(config, dict) and any(config.get(k) for k in _UPLOAD_CONFIG_KEYS):
|
||||
return True
|
||||
for classes, fields in _MEDIA_LOADER_FIELDS:
|
||||
if class_type in classes and input_name in fields:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _combo_candidates(spec: Any) -> list[str] | None:
|
||||
"""The candidate list when ``spec`` is a combo input, else None.
|
||||
|
||||
A combo spec is ``[["a", "b"], {config}]`` — first element a list of
|
||||
plain strings. Typed inputs (``["FLOAT", {…}]``) return None.
|
||||
"""
|
||||
if not (isinstance(spec, list) and len(spec) >= 1):
|
||||
return None
|
||||
values = spec[0]
|
||||
if not isinstance(values, list) or not values:
|
||||
return None
|
||||
if not all(isinstance(v, str) for v in values):
|
||||
return None
|
||||
return values
|
||||
|
||||
|
||||
def check_workflow_against(
|
||||
object_info: dict[str, Any], workflow: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Findings for every problem the target server's catalogue reveals.
|
||||
|
||||
Each finding is ``{level, code, node_id, node_title, message, hint}``
|
||||
(``level`` is always ``"error"`` for now — both rules are hard misses).
|
||||
"""
|
||||
findings: list[dict[str, Any]] = []
|
||||
if not isinstance(object_info, dict) or not isinstance(workflow, dict):
|
||||
return findings
|
||||
for node_id, node in workflow.items():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
class_type = str(node.get("class_type") or "")
|
||||
title = _node_title(node)
|
||||
label = f"节点 {node_id}「{title}」" if title else f"节点 {node_id}"
|
||||
spec = object_info.get(class_type)
|
||||
if not isinstance(spec, dict):
|
||||
findings.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "missing_node",
|
||||
"node_id": str(node_id),
|
||||
"node_title": title,
|
||||
"message": (
|
||||
f"缺少自定义节点 {class_type or '(未知类名)'}({label})——"
|
||||
"该 ComfyUI 未安装此节点,需先安装对应的节点包"
|
||||
),
|
||||
"hint": "安装节点包后回到本页点「重新检查」即可复查,无需重新导入。",
|
||||
}
|
||||
)
|
||||
continue
|
||||
declared = (spec.get("input") or {}) if isinstance(spec.get("input"), dict) else {}
|
||||
inputs = node.get("inputs") or {}
|
||||
for section in ("required", "optional"):
|
||||
entries = declared.get(section)
|
||||
if not isinstance(entries, dict):
|
||||
continue
|
||||
for input_name, in_spec in entries.items():
|
||||
value = inputs.get(input_name)
|
||||
# Wired inputs (links) and absent widgets are not combos to judge.
|
||||
if isinstance(value, (list, dict)) or value is None:
|
||||
continue
|
||||
config = in_spec[1] if isinstance(in_spec, list) and len(in_spec) > 1 else None
|
||||
if _is_upload_backed(class_type, str(input_name), config):
|
||||
continue
|
||||
candidates = _combo_candidates(in_spec)
|
||||
if candidates is None:
|
||||
continue
|
||||
if value in candidates:
|
||||
continue
|
||||
shown = "、".join(candidates[:_MAX_CANDIDATES_SHOWN])
|
||||
if len(candidates) > _MAX_CANDIDATES_SHOWN:
|
||||
shown += f" 等 {len(candidates)} 项"
|
||||
findings.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "value_not_in_list",
|
||||
"node_id": str(node_id),
|
||||
"node_title": title,
|
||||
"message": (
|
||||
f"{label}:值「{value}」不在 ComfyUI 的可选列表"
|
||||
f"(参数 {input_name})——多为对应的模型文件或组件未安装"
|
||||
),
|
||||
"hint": f"该 ComfyUI 上可选:{shown}" if shown else None,
|
||||
}
|
||||
)
|
||||
return findings
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Unified class_type → kind + patch-field registry (MuseAgent port)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
ComfyInputKind = Literal["text", "textarea", "number", "image", "image_url", "audio", "video"]
|
||||
ComfyOutputKind = Literal["image", "video", "other"]
|
||||
ComfyOutputKind = Literal["image", "video", "audio", "file", "other"]
|
||||
PatchField = Literal["image", "url", "audio", "video", "file", "text", "value", "int", "float"]
|
||||
|
||||
TEXT_AREA_CLASSES = frozenset(
|
||||
@@ -30,8 +30,30 @@ VIDEO_OUTPUT_CLASSES = frozenset(
|
||||
{"VHS_VideoCombine", "SaveVideo", "VideoOutput", "AnimateDiffCombine"}
|
||||
)
|
||||
IMAGE_OUTPUT_CLASSES = frozenset(
|
||||
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket"}
|
||||
{"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
|
||||
# the LoadImage only when the user supplies that frame.
|
||||
OPTIONAL_FRAME_INPUTS: dict[str, dict[str, str]] = {
|
||||
"MiniMaxH3ImageToVideo": {"last_frame": "first_frame"},
|
||||
}
|
||||
|
||||
|
||||
def optional_frame_sibling(node: dict[str, Any], field: str) -> str | None:
|
||||
"""The wired frame input an unwired optional ``field`` mirrors
|
||||
(``last_frame`` → ``first_frame``); None when it does not apply."""
|
||||
inputs = node.get("inputs") or {}
|
||||
sibling = OPTIONAL_FRAME_INPUTS.get(str(node.get("class_type") or ""), {}).get(field)
|
||||
if sibling is None or field in inputs:
|
||||
return None
|
||||
link = inputs.get(sibling)
|
||||
return sibling if isinstance(link, list) and len(link) == 2 else None
|
||||
|
||||
|
||||
def class_to_input_kind(class_type: str) -> ComfyInputKind:
|
||||
@@ -91,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,8 +28,36 @@ 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
|
||||
# graph evidence only (output media + consumed image inputs) — never from
|
||||
# file names, model names or fixed node ids.
|
||||
WorkflowUsage = str
|
||||
USAGE_VALUES: tuple[str, ...] = (
|
||||
"text2image",
|
||||
"image2image",
|
||||
"text2video",
|
||||
"image2video",
|
||||
"ref2video",
|
||||
"text2speech", "speech2text", "text2music", "utility", "audio2video", "pose2video",
|
||||
)
|
||||
|
||||
|
||||
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 ("text2speech", "text2music"):
|
||||
return "audio"
|
||||
if usage == "speech2text":
|
||||
return "file"
|
||||
if usage in ("text2video", "image2video", "ref2video", "audio2video", "pose2video"):
|
||||
return "video"
|
||||
return None
|
||||
|
||||
|
||||
def parse_title_tag(title: str) -> tuple[str | None, str | None, str]:
|
||||
"""Return (kind, role, label) from a Comfy node title.
|
||||
|
||||
@@ -158,8 +158,12 @@ def smart_fill_inputs(
|
||||
break
|
||||
|
||||
if ref_images:
|
||||
for inp, path in zip(ref_image_slots(inputs), ref_images):
|
||||
values[str(inp["nodeId"])] = path
|
||||
group = next((inp for inp in inputs if inp.get("kind") == "image_list"), None)
|
||||
if group is not None:
|
||||
values[str(group["nodeId"])] = list(ref_images[: group.get("max") or len(ref_images)])
|
||||
else:
|
||||
for inp, path in zip(ref_image_slots(inputs), ref_images):
|
||||
values[str(inp["nodeId"])] = path
|
||||
|
||||
if ref_videos:
|
||||
for inp, path in zip(ref_video_slots(inputs), ref_videos):
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Check and coerce studio form values against a workflow's input schema."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _fmt(n: Any) -> str:
|
||||
return f"{n:g}" if isinstance(n, float) else str(n)
|
||||
|
||||
|
||||
def validate_input_values(
|
||||
schema: list[dict[str, Any]], values: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Return ``(clean_values, errors)``.
|
||||
|
||||
Numbers are coerced to int/float per the schema entry (``integer``,
|
||||
``min``/``max``); select values must be one of ``options``. Blank values
|
||||
are dropped so the workflow keeps its own value. Keys not in the schema
|
||||
pass through untouched (other callers patch extra keys, e.g. uploads).
|
||||
Auto-detected required entries (positive prompt, wired image slots,
|
||||
reference-image lists) must be present — tagged workflows mark every
|
||||
input required, and blank ones fall back to the workflow's value.
|
||||
"""
|
||||
by_key = {str(e.get("nodeId")): e for e in schema if isinstance(e, dict)}
|
||||
clean: dict[str, Any] = {}
|
||||
errors: list[str] = []
|
||||
for key, raw in (values or {}).items():
|
||||
entry = by_key.get(str(key))
|
||||
if entry is None:
|
||||
clean[key] = raw
|
||||
continue
|
||||
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
||||
continue
|
||||
label = str(entry.get("label") or key)
|
||||
kind = entry.get("kind")
|
||||
if kind == "number":
|
||||
if isinstance(raw, bool):
|
||||
errors.append(f"「{label}」必须是数字")
|
||||
continue
|
||||
try:
|
||||
num = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"「{label}」必须是数字")
|
||||
continue
|
||||
if not math.isfinite(num):
|
||||
errors.append(f"「{label}」必须是有限数字")
|
||||
continue
|
||||
if entry.get("integer"):
|
||||
if num != int(num):
|
||||
errors.append(f"「{label}」必须是整数")
|
||||
continue
|
||||
value: int | float = int(num)
|
||||
else:
|
||||
value = int(num) if isinstance(raw, int) else num
|
||||
lo, hi = entry.get("min"), entry.get("max")
|
||||
if lo is not None and value < lo:
|
||||
errors.append(f"「{label}」不能小于 {_fmt(lo)}")
|
||||
continue
|
||||
if hi is not None and value > hi:
|
||||
errors.append(f"「{label}」不能大于 {_fmt(hi)}")
|
||||
continue
|
||||
clean[key] = value
|
||||
elif kind in ("image_list", "video_list", "audio_list"):
|
||||
paths = [raw] if isinstance(raw, str) else raw
|
||||
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
|
||||
errors.append(f"「{label}」的取值格式不对,请重新上传")
|
||||
continue
|
||||
paths = [p for p in paths if p.strip()]
|
||||
unit = _LIST_UNITS[kind]
|
||||
noun = _LIST_NOUNS[kind]
|
||||
hi = entry.get("max")
|
||||
if hi is not None and len(paths) > hi:
|
||||
errors.append(
|
||||
f"{label}最多 {hi} {unit}(当前 {len(paths)} {unit}),请删除多余的{noun}"
|
||||
)
|
||||
continue
|
||||
bad = [
|
||||
f"{label} {i}:{msg}"
|
||||
for i, p in enumerate(paths, 1)
|
||||
if (msg := _media_file_problem(p, kind))
|
||||
]
|
||||
if bad:
|
||||
errors.extend(bad)
|
||||
continue
|
||||
if paths:
|
||||
clean[key] = paths
|
||||
elif kind == "select":
|
||||
options = entry.get("options") or []
|
||||
if options and raw not in options:
|
||||
errors.append(f"「{label}」的取值「{raw}」不在工作流支持的选项中")
|
||||
continue
|
||||
clean[key] = raw
|
||||
else:
|
||||
clean[key] = raw
|
||||
for key, entry in by_key.items():
|
||||
required = entry.get("auto") and entry.get("required")
|
||||
label = entry.get("label") or key
|
||||
if required and entry.get("role") == "prompt":
|
||||
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
|
||||
if key not in clean and not has_fallback:
|
||||
errors.append(f"请填写「{label}」")
|
||||
elif required and entry.get("kind") in ("image_list", "video_list", "audio_list"):
|
||||
lo = entry.get("min") or 1
|
||||
unit = _LIST_UNITS[entry.get("kind")]
|
||||
if len(clean.get(key) or []) < lo and not any(label in e for e in errors):
|
||||
errors.append(f"需要至少 {lo} {unit}{label}")
|
||||
elif required and entry.get("kind") == "image" and entry.get("role") != "mask":
|
||||
if key not in clean:
|
||||
errors.append(f"请上传{label}")
|
||||
return clean, errors
|
||||
|
||||
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp"})
|
||||
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mov"})
|
||||
_AUDIO_EXTS = frozenset({".mp3", ".wav", ".m4a"})
|
||||
_LIST_UNITS = {"image_list": "张", "video_list": "段", "audio_list": "段"}
|
||||
_LIST_NOUNS = {"image_list": "图片", "video_list": "视频", "audio_list": "音频"}
|
||||
_LIST_EXTS = {"image_list": _IMAGE_EXTS, "video_list": _VIDEO_EXTS, "audio_list": _AUDIO_EXTS}
|
||||
_LIST_FORMAT_HINT = {
|
||||
"image_list": "格式不支持,请使用 PNG / JPG / WebP",
|
||||
"video_list": "格式不支持,请使用 MP4 / WebM / MOV",
|
||||
"audio_list": "格式不支持,请使用 MP3 / WAV / M4A",
|
||||
}
|
||||
|
||||
|
||||
def _media_file_problem(path: str, kind: str) -> str | None:
|
||||
"""Why ``path`` cannot fill a media-list slot of this kind (None = fine)."""
|
||||
from calliope import config
|
||||
|
||||
target = Path(path)
|
||||
if target.suffix.lower() not in _LIST_EXTS[kind]:
|
||||
return _LIST_FORMAT_HINT[kind]
|
||||
try:
|
||||
target.resolve().relative_to(config.settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return f"只能使用在本页上传或从生成结果选入的{_LIST_NOUNS[kind]}"
|
||||
if not target.is_file():
|
||||
return "文件已不存在,请重新上传"
|
||||
return None
|
||||
|
||||
|
||||
def _image_file_problem(path: str) -> str | None:
|
||||
return _media_file_problem(path, "image_list")
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -112,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,
|
||||
@@ -121,7 +122,11 @@ CREATE TABLE IF NOT EXISTS workflows (
|
||||
description TEXT,
|
||||
prompt_profile TEXT NOT NULL DEFAULT 'prose',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
usage TEXT,
|
||||
usage_source TEXT,
|
||||
output_map TEXT,
|
||||
output_map_source TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
@@ -138,7 +143,9 @@ CREATE TABLE IF NOT EXISTS jobs (
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
comfy_prompt_id TEXT,
|
||||
phase TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
@@ -336,10 +343,120 @@ def _sqlite_connect(path: Path) -> sqlite3.Connection:
|
||||
return sqlite3.connect(path)
|
||||
|
||||
|
||||
def _backfill_workflow_usage(conn: sqlite3.Connection) -> None:
|
||||
"""HEL-636: settle `workflows.usage` for rows that still have none.
|
||||
|
||||
Determinate graphs get ``usage='auto'``; indeterminate ones keep NULL
|
||||
and are disabled (they must be confirmed on the connections page before
|
||||
they can generate). Rows the user already confirmed (usage_source='user')
|
||||
never pass through here again — their usage is set, so the WHERE clause
|
||||
skips them. Old jobs / history / `kind` semantics are untouched.
|
||||
"""
|
||||
from calliope.comfyui.parser import classify_usage
|
||||
|
||||
rows = conn.execute(
|
||||
"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:
|
||||
workflow = json.loads(row[1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
workflow = None
|
||||
judgment = classify_usage(workflow) if isinstance(workflow, dict) else {}
|
||||
if judgment.get("usage"):
|
||||
conn.execute(
|
||||
"UPDATE workflows SET usage = ?, usage_source = 'auto' WHERE id = ?",
|
||||
(judgment["usage"], row[0]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE workflows SET is_enabled = 0 WHERE id = ? AND usage IS NULL",
|
||||
(row[0],),
|
||||
)
|
||||
|
||||
|
||||
def _backfill_workflow_output_map(conn: sqlite3.Connection) -> None:
|
||||
"""HEL-638: settle the output-node mapping for existing rows.
|
||||
|
||||
Rows the user already mapped (output_map_source='user') are never
|
||||
touched. Determinate graphs get the trusted ``auto`` map. Rows whose
|
||||
graph now contains unknown custom output candidates get no auto map and
|
||||
are parked disabled — the usage decision survives, but until the user
|
||||
designates the output nodes the workflow must not generate.
|
||||
"""
|
||||
from calliope.comfyui.output_map import auto_output_map, needs_user_output_map
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, workflow_json FROM workflows"
|
||||
" WHERE source_repo_path IS NULL"
|
||||
" AND (output_map_source IS NULL OR output_map_source != 'user')"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
try:
|
||||
workflow = json.loads(row[1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
workflow = None
|
||||
if not isinstance(workflow, dict):
|
||||
continue
|
||||
if needs_user_output_map(workflow):
|
||||
conn.execute(
|
||||
"UPDATE workflows SET output_map = NULL, output_map_source = NULL,"
|
||||
" is_enabled = 0 WHERE id = ?",
|
||||
(row[0],),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE workflows SET output_map = ?, output_map_source = 'auto'"
|
||||
" WHERE id = ?",
|
||||
(json.dumps(auto_output_map(workflow)), row[0]),
|
||||
)
|
||||
|
||||
|
||||
# HEL-671: the user's H3 import went in under a first-frame-only name
|
||||
# (“H3图生视频·首帧…”) although its graph is the FL2VA node that also takes
|
||||
# a last frame. A row is renamed only when BOTH hold: its graph carries a
|
||||
# wired MiniMaxH3ImageToVideo node, and its name still reads like a
|
||||
# first-frame-only workflow — a row the user renamed, or any other graph,
|
||||
# is left alone. Idempotent: the new name no longer matches.
|
||||
_FIRST_FRAME_NAME_RE = re.compile(r"图生视频\s*[·\-—~]?\s*首帧")
|
||||
_FL2VA_DESCRIPTION = "首帧必填,尾帧可选:只传首帧为图生视频,同时传尾帧为首尾帧生视频"
|
||||
|
||||
|
||||
def _rename_workflows(conn: sqlite3.Connection) -> None:
|
||||
for wid, name, wf_json in conn.execute(
|
||||
"SELECT id, name, workflow_json FROM workflows WHERE source_repo_path IS NULL"
|
||||
).fetchall():
|
||||
if not name or "尾帧" in name or not _FIRST_FRAME_NAME_RE.search(name):
|
||||
continue
|
||||
try:
|
||||
workflow = json.loads(wf_json) if wf_json else None
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if not isinstance(workflow, dict):
|
||||
continue
|
||||
fl2va = any(
|
||||
isinstance(node, dict)
|
||||
and str(node.get("class_type") or "") == "MiniMaxH3ImageToVideo"
|
||||
and isinstance((node.get("inputs") or {}).get("first_frame"), list)
|
||||
for node in workflow.values()
|
||||
)
|
||||
if not fl2va:
|
||||
continue
|
||||
conn.execute(
|
||||
"UPDATE workflows SET name = ?,"
|
||||
" description = COALESCE(NULLIF(description, ''), ?) WHERE id = ?",
|
||||
(_FIRST_FRAME_NAME_RE.sub("首尾帧生视频", name), _FL2VA_DESCRIPTION, wid),
|
||||
)
|
||||
|
||||
|
||||
async def migrate_db(db_path: Path) -> None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
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
|
||||
@@ -381,6 +498,24 @@ async def migrate_db(db_path: Path) -> None:
|
||||
# comfyui_endpoints id). Dangling ids are tolerated — enqueue falls
|
||||
# back to the active connection.
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN endpoint_id TEXT")
|
||||
# HEL-636: five-way usage (text2image…ref2video). NULL = pending human
|
||||
# confirmation; `kind` keeps meaning output media for history/paths.
|
||||
if "usage" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN usage TEXT")
|
||||
if "usage_source" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN usage_source TEXT")
|
||||
# HEL-638: explicit output-node designation for unknown custom output
|
||||
# nodes ({nodeId, kind} JSON + 'user'|'auto'). Idempotent backfill
|
||||
# below parks unmapped unknown-output rows (disabled) like usage does.
|
||||
if "output_map" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN output_map TEXT")
|
||||
if "output_map_source" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN output_map_source TEXT")
|
||||
# Idempotent backfill: pending rows are re-judged on every boot (parser
|
||||
# upgrades can settle them); confirmed rows are never touched.
|
||||
_backfill_workflow_usage(conn)
|
||||
_backfill_workflow_output_map(conn)
|
||||
_rename_workflows(conn)
|
||||
scene_cols = {r[1] for r in conn.execute("PRAGMA table_info(scenes)").fetchall()}
|
||||
if "location_id" not in scene_cols:
|
||||
conn.execute("ALTER TABLE scenes ADD COLUMN location_id INTEGER")
|
||||
@@ -420,6 +555,18 @@ async def migrate_db(db_path: Path) -> None:
|
||||
WHERE kind = 'video' AND scene_id IS NOT NULL AND clip_id IS NULL
|
||||
"""
|
||||
)
|
||||
# HEL-627: the ComfyUI prompt id a job was accepted under, and where the
|
||||
# job really is (connecting / comfy_queued / comfy_running / saving;
|
||||
# finished = real results saved; dry_run = placeholder, ComfyUI never called).
|
||||
if "comfy_prompt_id" not in job_cols:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN comfy_prompt_id TEXT")
|
||||
if "phase" not in job_cols:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN phase TEXT")
|
||||
# HEL-643: where a job came from — 'user' (every normal generation) or
|
||||
# 'workflow_test' (L3 试运行). Existing rows default to 'user', semantics
|
||||
# unchanged; test jobs get isolated artifacts and no entity write-back.
|
||||
if "origin" not in job_cols:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'user'")
|
||||
# Build Scene's per-scene sessions: origin stamps WHERE a blind session was
|
||||
# born ('chat' = user-created in AI Canvas, 'scene' = auto-created by the
|
||||
# Build Scene rail). The session list excludes origin='scene' so scene
|
||||
|
||||
@@ -198,27 +198,73 @@ class ExpandClipsRequest(BaseModel):
|
||||
clip_cap: int | None = None
|
||||
|
||||
|
||||
WorkflowUsageKind = Literal[
|
||||
"text2image",
|
||||
"image2image",
|
||||
"text2video",
|
||||
"image2video",
|
||||
"ref2video",
|
||||
"text2speech", "speech2text", "text2music", "utility", "audio2video", "pose2video",
|
||||
]
|
||||
|
||||
|
||||
class WorkflowCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1)
|
||||
kind: Literal["image", "video"] = "image"
|
||||
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
|
||||
# Optional binding to a ComfyUI connection id (settings.comfyui_endpoints).
|
||||
endpoint_id: str | None = None
|
||||
# HEL-636: five-way usage. Given by the import confirm page (possibly a
|
||||
# user correction of the auto judgment); when absent it is classified
|
||||
# from the graph, and indeterminate workflows stay pending (disabled).
|
||||
usage: WorkflowUsageKind | None = None
|
||||
usage_source: Literal["auto", "user"] | None = None
|
||||
# Schema corrected on the confirm page (manual role overrides included).
|
||||
input_schema: list[dict[str, Any]] | None = None
|
||||
# HEL-638: explicit output-node designation for unknown custom output
|
||||
# nodes — [{nodeId, kind: image|video}]. Required before a workflow
|
||||
# with unknown outputs can be enabled or generated.
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
output_map_source: Literal["auto", "user"] | None = None
|
||||
|
||||
|
||||
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
|
||||
endpoint_id: str | None = None
|
||||
usage: WorkflowUsageKind | None = None
|
||||
usage_source: Literal["auto", "user"] | None = None
|
||||
input_schema: list[dict[str, Any]] | None = None
|
||||
# HEL-638: set/replace the user output mapping; an explicit empty list
|
||||
# resets the workflow to pending confirmation.
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
output_map_source: Literal["auto", "user"] | None = None
|
||||
|
||||
|
||||
class WorkflowAnalyze(BaseModel):
|
||||
workflow_json: dict[str, Any]
|
||||
# HEL-643: optional target connection — given, the analyze response also
|
||||
# carries an L2 connection check (node/model availability via /object_info).
|
||||
endpoint_id: str | None = None
|
||||
# Bypass the /object_info cache (user re-checks after installing nodes).
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class WorkflowCheckRequest(BaseModel):
|
||||
"""L2 re-check of a saved workflow against a live ComfyUI (HEL-643)."""
|
||||
endpoint_id: str | None = None
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class WorkflowTestRequest(BaseModel):
|
||||
"""Launch one real test run of a workflow (HEL-643 L3)."""
|
||||
endpoint_id: str | None = None
|
||||
input_values: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class JobCreate(BaseModel):
|
||||
|
||||
@@ -19,7 +19,8 @@ class QueueManager:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"UPDATE jobs SET status = 'pending', started_at = NULL WHERE status = 'running'"
|
||||
"UPDATE jobs SET status = 'pending', started_at = NULL, phase = NULL, "
|
||||
"comfy_prompt_id = NULL WHERE status = 'running'"
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
@@ -35,16 +36,18 @@ class QueueManager:
|
||||
scene_id: int | None = None,
|
||||
clip_id: int | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
origin: str = "user",
|
||||
) -> dict[str, Any]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
payload = self._stamp_endpoint(conn, workflow_id, dict(payload or {}))
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id, status, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?)
|
||||
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id,
|
||||
status, payload_json, origin)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||
""",
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload)),
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload), origin),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
@@ -105,6 +108,20 @@ class QueueManager:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def set_phase(self, job_id: int, phase: str, comfy_prompt_id: str | None = None) -> None:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
if comfy_prompt_id:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET phase = ?, comfy_prompt_id = ? WHERE id = ?",
|
||||
(phase, comfy_prompt_id, job_id),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE jobs SET phase = ? WHERE id = ?", (phase, job_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def mark_done(self, job_id: int, output_paths: list[str]) -> None:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
@@ -142,7 +159,7 @@ class QueueManager:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE jobs SET status = 'pending', error = NULL, started_at = NULL,
|
||||
completed_at = NULL WHERE id = ?
|
||||
completed_at = NULL, phase = NULL, comfy_prompt_id = NULL WHERE id = ?
|
||||
""",
|
||||
(job_id,),
|
||||
)
|
||||
|
||||
@@ -8,9 +8,12 @@ from typing import Any
|
||||
|
||||
from pathlib import Path as _fs_path
|
||||
|
||||
import httpx
|
||||
|
||||
from calliope import config
|
||||
from calliope.comfyui.client import ComfyUIClient
|
||||
from calliope.comfyui.dry_run import write_placeholder_mp4, write_placeholder_png
|
||||
from calliope.comfyui.lint import blocking_problem
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.roles import input_has_role
|
||||
@@ -18,6 +21,11 @@ from calliope.db import get_db
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.export.runner import run_export
|
||||
from calliope.queue.manager import queue_manager
|
||||
from calliope.queue.workflow_test import (
|
||||
TEST_ORIGIN,
|
||||
prune_workflow_tests,
|
||||
workflow_test_dir,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("calliope.worker")
|
||||
|
||||
@@ -26,12 +34,22 @@ def _comfy_messages(messages: list[Any]) -> str:
|
||||
"""Pull the readable part out of ComfyUI's history status messages."""
|
||||
parts: list[str] = []
|
||||
for msg in messages:
|
||||
if isinstance(msg, (list, tuple)) and len(msg) == 2 and msg[0] == "execution_error":
|
||||
body = msg[1] if isinstance(msg[1], dict) else {}
|
||||
node = body.get("node_type") or body.get("node_id") or "?"
|
||||
if not (isinstance(msg, (list, tuple)) and len(msg) == 2):
|
||||
continue
|
||||
body = msg[1] if isinstance(msg[1], dict) else {}
|
||||
node = body.get("node_type") or body.get("node_id") or "?"
|
||||
if msg[0] == "execution_error":
|
||||
reason = body.get("exception_message") or body.get("exception_type") or "未知错误"
|
||||
parts.append(f"节点 {node}:{str(reason).strip()}")
|
||||
return ";".join(parts) or str(messages)[:500]
|
||||
elif msg[0] == "execution_interrupted":
|
||||
parts.append(f"任务在节点 {node} 处被中断(在 ComfyUI 端被取消,或 ComfyUI 已重启)")
|
||||
return ";".join(parts) or "ComfyUI 未给出原因"
|
||||
|
||||
|
||||
# /queue lookups that must agree before a prompt counts as lost, and /history
|
||||
# or /queue request failures in a row before the job gives up.
|
||||
_MISSING_POLLS_LIMIT = 3
|
||||
_POLL_ERRORS_LIMIT = 5
|
||||
|
||||
|
||||
class _CancelledByUser(RuntimeError):
|
||||
@@ -91,6 +109,7 @@ class QueueWorker:
|
||||
job_payload = json.loads(job["payload_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
job_payload = {}
|
||||
row = queue_manager.get_job(job["id"]) or {}
|
||||
await event_bus.publish(
|
||||
"job.completed",
|
||||
{
|
||||
@@ -102,6 +121,9 @@ class QueueWorker:
|
||||
# Source stamp lets the UI attribute sandbox jobs
|
||||
# to their enqueuer without racing the jobs poll.
|
||||
"source": job_payload.get("source"),
|
||||
"comfy_prompt_id": row.get("comfy_prompt_id"),
|
||||
# Placeholder output — ComfyUI was never called.
|
||||
"dry_run": row.get("phase") == "dry_run",
|
||||
"message": f"{self._job_label(job)} · {len(outputs)} file(s)",
|
||||
},
|
||||
)
|
||||
@@ -147,6 +169,16 @@ class QueueWorker:
|
||||
"message": f"{self._job_label(job)} failed",
|
||||
},
|
||||
)
|
||||
if str(job.get("origin") or "") == TEST_ORIGIN and job.get("workflow_id"):
|
||||
# Retention: keep only the newest records (and their
|
||||
# artifacts) per workflow (HEL-643).
|
||||
try:
|
||||
prune_workflow_tests(int(job["workflow_id"]))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Pruning workflow-test records for workflow %s failed",
|
||||
job["workflow_id"],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Worker loop error")
|
||||
await asyncio.sleep(config.settings.queue_poll_interval_sec)
|
||||
@@ -156,6 +188,7 @@ class QueueWorker:
|
||||
payload = json.loads(job["payload_json"] or "{}")
|
||||
project_id = job["project_id"]
|
||||
kind = job["kind"]
|
||||
is_test = str(job.get("origin") or "") == TEST_ORIGIN
|
||||
use_dry = bool(config.settings.dry_run)
|
||||
|
||||
if kind == "export":
|
||||
@@ -166,9 +199,18 @@ class QueueWorker:
|
||||
base_url, headers, endpoint_label = self._resolve_endpoint(payload)
|
||||
client = ComfyUIClient(base_url, headers=headers)
|
||||
try:
|
||||
# A workflow test must be a REAL run — a dry-run placeholder that
|
||||
# passes as "测试通过" is exactly the false positive HEL-643 bans.
|
||||
if use_dry and is_test:
|
||||
raise RuntimeError(
|
||||
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
|
||||
"请到「设置」关闭 Dry-run 后重新测试"
|
||||
)
|
||||
if use_dry:
|
||||
queue_manager.set_phase(job["id"], "dry_run")
|
||||
return await self._dry_run(job, payload)
|
||||
|
||||
queue_manager.set_phase(job["id"], "connecting")
|
||||
healthy = await client.health()
|
||||
if not healthy:
|
||||
raise RuntimeError(
|
||||
@@ -177,11 +219,28 @@ class QueueWorker:
|
||||
)
|
||||
|
||||
workflow_id = job.get("workflow_id") or payload.get("workflow_id")
|
||||
workflow = self._load_workflow(workflow_id)
|
||||
if not workflow:
|
||||
workflow_row = self._load_workflow_row(workflow_id)
|
||||
if not workflow_row:
|
||||
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:
|
||||
raise RuntimeError(broken)
|
||||
|
||||
# HEL-638: harvest only the confirmed output nodes. A workflow
|
||||
# with unknown custom outputs and no user mapping must not run.
|
||||
node_kinds = self._output_node_kinds(workflow_row)
|
||||
if node_kinds is None:
|
||||
raise RuntimeError(
|
||||
"该工作流含无法识别的自定义输出节点,尚未指定输出节点及媒体类型,"
|
||||
"请到「ComfyUI 连接」页确认后再生成"
|
||||
)
|
||||
|
||||
input_values = payload.get("input_values") or {}
|
||||
if payload.get("continue_source") and kind == "video":
|
||||
@@ -191,22 +250,67 @@ class QueueWorker:
|
||||
patched = patch_workflow(workflow, input_values)
|
||||
patched = await client.prepare_media_inputs(patched)
|
||||
prompt_id = await client.queue_prompt(patched)
|
||||
queue_manager.set_phase(job["id"], "comfy_queued", prompt_id)
|
||||
await event_bus.publish(
|
||||
"job.progress",
|
||||
{
|
||||
"job_id": job["id"],
|
||||
"prompt_id": prompt_id,
|
||||
"phase": "comfy_queued",
|
||||
"message": f"ComfyUI 已接收任务(ID {prompt_id})",
|
||||
},
|
||||
)
|
||||
|
||||
history = await self._poll_history(client, prompt_id, job["id"])
|
||||
if not history:
|
||||
if queue_manager.is_cancelled(job["id"]):
|
||||
raise _CancelledByUser()
|
||||
raise RuntimeError(
|
||||
"等待 ComfyUI 出结果超时:可在「设置 → 队列」调大超时,或到 ComfyUI 检查该任务"
|
||||
f"等待 ComfyUI 出结果超时(任务 ID {prompt_id}):可在「设置 → 队列」调大超时,"
|
||||
"或到 ComfyUI 检查该任务"
|
||||
)
|
||||
|
||||
status = history.get("status") or {}
|
||||
if status.get("status_str") == "error" or status.get("completed") is False:
|
||||
messages = status.get("messages") or []
|
||||
raise RuntimeError(f"ComfyUI 执行出错:{_comfy_messages(messages)}")
|
||||
raise RuntimeError(
|
||||
f"ComfyUI 执行出错(任务 ID {prompt_id}):{_comfy_messages(messages)}"
|
||||
)
|
||||
|
||||
outputs_meta = client.extract_outputs(history)
|
||||
dest_dir = config.settings.assets_dir / str(project_id) / kind
|
||||
outputs_meta = client.extract_outputs(history, node_kinds=node_kinds)
|
||||
saved = [m for m in outputs_meta if m.get("type") == "output"]
|
||||
outputs_meta = saved or outputs_meta
|
||||
if not outputs_meta:
|
||||
checked = "、".join(sorted(node_kinds))
|
||||
raise RuntimeError(
|
||||
f"ComfyUI 显示任务已结束(任务 ID {prompt_id}),"
|
||||
"但没有返回任何图片、视频、音频或文件,"
|
||||
"不算生成完成。已按输出映射检查节点:" f"{checked};"
|
||||
"请到 ComfyUI 历史中核对该任务,并确认所指定的输出节点正确。"
|
||||
)
|
||||
mismatch = next(
|
||||
(
|
||||
m
|
||||
for m in outputs_meta
|
||||
if m.get("media_kind") and m["media_kind"] != node_kinds.get(m["node_id"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if mismatch:
|
||||
want = node_kinds.get(mismatch["node_id"])
|
||||
raise RuntimeError(
|
||||
f"输出节点 {mismatch['node_id']} 的媒体类型与映射不符"
|
||||
f"(映射为{'视频' if want == 'video' else '图片'},"
|
||||
f"实际返回 {mismatch['filename']}),任务不算生成完成。"
|
||||
"请到「ComfyUI 连接」页修正该节点的媒体类型。"
|
||||
)
|
||||
queue_manager.set_phase(job["id"], "saving")
|
||||
if is_test:
|
||||
# Test artifacts live in a scratch dir, isolated from project
|
||||
# media and never written back to entities (HEL-643).
|
||||
dest_dir = workflow_test_dir(int(workflow_id))
|
||||
else:
|
||||
dest_dir = config.settings.assets_dir / str(project_id) / kind
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
paths: list[str] = []
|
||||
for meta in outputs_meta:
|
||||
@@ -220,7 +324,9 @@ class QueueWorker:
|
||||
)
|
||||
paths.append(str(dest))
|
||||
|
||||
self._apply_outputs_to_entities(job, payload, paths)
|
||||
if not is_test:
|
||||
self._apply_outputs_to_entities(job, payload, paths)
|
||||
queue_manager.set_phase(job["id"], "finished")
|
||||
return paths
|
||||
finally:
|
||||
await client.close()
|
||||
@@ -360,6 +466,9 @@ class QueueWorker:
|
||||
# configurable (default 30 min) instead of a hardcoded 600 seconds.
|
||||
attempts = None if timeout <= 0 else int(timeout / interval)
|
||||
n = 0
|
||||
phase = "comfy_queued"
|
||||
missing = 0
|
||||
errors = 0
|
||||
while attempts is None or n < attempts:
|
||||
n += 1
|
||||
if self._stop.is_set():
|
||||
@@ -370,17 +479,51 @@ class QueueWorker:
|
||||
if queue_manager.is_cancelled(job_id):
|
||||
await client.interrupt()
|
||||
return None
|
||||
history = await client.get_history(prompt_id)
|
||||
if history:
|
||||
return history
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
history = await client.get_history(prompt_id)
|
||||
if history:
|
||||
return history
|
||||
where, ahead = await client.locate_prompt(prompt_id)
|
||||
except httpx.HTTPError as exc:
|
||||
errors += 1
|
||||
if errors >= _POLL_ERRORS_LIMIT:
|
||||
raise RuntimeError(
|
||||
f"连续 {errors} 次无法从 ComfyUI 查询任务状态(任务 ID {prompt_id}):"
|
||||
f"{exc or type(exc).__name__}"
|
||||
) from exc
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
errors = 0
|
||||
if where == "missing":
|
||||
missing += 1
|
||||
if missing >= _MISSING_POLLS_LIMIT:
|
||||
raise RuntimeError(
|
||||
f"ComfyUI 的队列和历史里都找不到该任务(任务 ID {prompt_id}),"
|
||||
"可能 ComfyUI 已重启或任务被清除,没有产出结果"
|
||||
)
|
||||
else:
|
||||
missing = 0
|
||||
if where == "running":
|
||||
phase = "comfy_running"
|
||||
message = f"ComfyUI 正在执行(任务 ID {prompt_id})"
|
||||
elif where == "pending":
|
||||
phase = "comfy_queued"
|
||||
message = f"ComfyUI 排队中,前面还有 {ahead} 个任务(任务 ID {prompt_id})"
|
||||
else:
|
||||
message = f"等待 ComfyUI 返回结果(任务 ID {prompt_id})"
|
||||
if job_id is not None:
|
||||
queue_manager.set_phase(job_id, phase)
|
||||
await event_bus.publish(
|
||||
"job.progress",
|
||||
{
|
||||
"job_id": job_id,
|
||||
"prompt_id": prompt_id,
|
||||
"message": f"Waiting on ComfyUI ({prompt_id[:8]}…)",
|
||||
"phase": phase,
|
||||
"queue_ahead": ahead if where == "pending" else None,
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(interval)
|
||||
return None
|
||||
|
||||
def _mark_project_completed(self, project_id: int) -> None:
|
||||
@@ -395,20 +538,46 @@ class QueueWorker:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _load_workflow(self, workflow_id: int | None) -> dict[str, Any] | None:
|
||||
def _load_workflow_row(self, workflow_id: int | None) -> dict[str, Any] | None:
|
||||
if not workflow_id:
|
||||
return None
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT workflow_json FROM workflows WHERE id = ?", (workflow_id,)
|
||||
"SELECT workflow_json, output_map, output_map_source, import_state FROM workflows"
|
||||
" WHERE id = ?",
|
||||
(workflow_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return json.loads(row["workflow_json"])
|
||||
return {
|
||||
"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()
|
||||
|
||||
@staticmethod
|
||||
def _output_node_kinds(workflow_row: dict[str, Any]) -> dict[str, str] | None:
|
||||
"""Confirmed {nodeId: image|video} for this workflow, or None when
|
||||
the outputs are not confirmed (unknown nodes, no user mapping)."""
|
||||
from calliope.comfyui.output_map import mapping_problem, resolve_output_map
|
||||
|
||||
workflow = workflow_row["workflow_json"]
|
||||
problem = mapping_problem(
|
||||
workflow, workflow_row.get("output_map"), workflow_row.get("output_map_source")
|
||||
)
|
||||
if problem:
|
||||
return None
|
||||
entries, _source = resolve_output_map(
|
||||
workflow_row.get("output_map"),
|
||||
workflow_row.get("output_map_source"),
|
||||
workflow,
|
||||
)
|
||||
return {str(e["nodeId"]): str(e["kind"]) for e in entries} or None
|
||||
|
||||
def _job_label(self, job: dict[str, Any]) -> str:
|
||||
payload = json.loads(job.get("payload_json") or "{}")
|
||||
kind = job.get("kind") or "job"
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Workflow-test job helpers (HEL-643).
|
||||
|
||||
A test job (jobs.origin = 'workflow_test') runs the FULL real pipeline —
|
||||
queue → ComfyUI → outputs — but its artifacts are isolated from project
|
||||
entities: files land under ``assets/_workflow_test/{workflow_id}/`` and are
|
||||
never written back to characters/locations/scenes/clips.
|
||||
|
||||
This module owns the artifact directory, the retention policy (keep the
|
||||
newest N records per workflow) and the purge paths used by "清除测试记录"
|
||||
and workflow deletion.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from calliope import config
|
||||
from calliope.db import get_db
|
||||
|
||||
logger = logging.getLogger("calliope.queue")
|
||||
|
||||
TEST_ORIGIN = "workflow_test"
|
||||
TEST_SOURCE = "workflow_test"
|
||||
# Records (and artifacts) kept per workflow before older ones are pruned.
|
||||
TEST_KEEP = 5
|
||||
|
||||
|
||||
def workflow_test_dir(workflow_id: int) -> Path:
|
||||
return config.settings.assets_dir / "_workflow_test" / str(workflow_id)
|
||||
|
||||
|
||||
def _safe_unlink(path_str: str) -> bool:
|
||||
"""Delete a file if it resolves under assets_dir (missing = already gone)."""
|
||||
try:
|
||||
target = Path(path_str).resolve()
|
||||
target.relative_to(config.settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
if not target.is_file():
|
||||
return False
|
||||
try:
|
||||
target.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _unlink_job_outputs(job: dict[str, Any]) -> None:
|
||||
try:
|
||||
paths = json.loads(job.get("output_paths_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
if not isinstance(paths, list):
|
||||
return
|
||||
for p in paths:
|
||||
if isinstance(p, str) and p.strip():
|
||||
_safe_unlink(p)
|
||||
|
||||
|
||||
def _remove_test_dir_if_empty(workflow_id: int) -> None:
|
||||
d = workflow_test_dir(workflow_id)
|
||||
try:
|
||||
if d.is_dir() and not any(d.iterdir()):
|
||||
d.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _test_job_public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(job)
|
||||
out["payload"] = json.loads(job.get("payload_json") or "{}")
|
||||
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
|
||||
out.pop("payload_json", None)
|
||||
out.pop("output_paths_json", None)
|
||||
return out
|
||||
|
||||
|
||||
def list_test_jobs(workflow_id: int, limit: int = 20) -> list[dict[str, Any]]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" ORDER BY id DESC LIMIT ?",
|
||||
(workflow_id, TEST_ORIGIN, limit),
|
||||
).fetchall()
|
||||
return [_test_job_public(dict(r)) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def find_active_test_job(workflow_id: int, endpoint_id: str | None) -> dict[str, Any] | None:
|
||||
"""The unfinished test job for (workflow, endpoint), if one exists.
|
||||
|
||||
Idempotency guard for the "测试工作流" button: a second click re-uses the
|
||||
in-flight job instead of enqueuing a duplicate.
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" AND status IN ('pending', 'running') ORDER BY id DESC",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
for r in rows:
|
||||
job = dict(r)
|
||||
try:
|
||||
payload = json.loads(job.get("payload_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
payload = {}
|
||||
snap = payload.get("endpoint") or {}
|
||||
if not isinstance(snap, dict):
|
||||
snap = {}
|
||||
if (snap.get("id") or None) == (endpoint_id or None):
|
||||
return _test_job_public(job)
|
||||
return None
|
||||
|
||||
|
||||
def prune_workflow_tests(workflow_id: int, keep: int = TEST_KEEP) -> int:
|
||||
"""Keep only the newest ``keep`` test records (older rows + files go)."""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" ORDER BY id DESC LIMIT -1 OFFSET ?",
|
||||
(workflow_id, TEST_ORIGIN, max(keep, 0)),
|
||||
).fetchall()
|
||||
removed = 0
|
||||
for r in rows:
|
||||
job = dict(r)
|
||||
if job.get("status") in ("pending", "running"):
|
||||
continue
|
||||
_unlink_job_outputs(job)
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (job["id"],))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_remove_test_dir_if_empty(workflow_id)
|
||||
if removed:
|
||||
logger.info("Pruned %s old workflow-test records for workflow %s", removed, workflow_id)
|
||||
return removed
|
||||
|
||||
|
||||
def purge_workflow_tests(workflow_id: int, *, allow_active: bool = False) -> int:
|
||||
"""Delete EVERY test record and artifact for a workflow.
|
||||
|
||||
Returns the removed row count; raises RuntimeError when an unfinished
|
||||
test job is still in flight (unless ``allow_active``).
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
if not allow_active:
|
||||
active = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" AND status IN ('pending', 'running')",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchone()["n"]
|
||||
if active:
|
||||
raise RuntimeError(
|
||||
f"该工作流还有 {active} 个进行中的测试任务,请先等待完成或取消,再清除测试记录"
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchall()
|
||||
removed = 0
|
||||
for r in rows:
|
||||
_unlink_job_outputs(dict(r))
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (r["id"],))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
shutil.rmtree(workflow_test_dir(workflow_id), ignore_errors=True)
|
||||
return removed
|
||||
@@ -77,8 +77,11 @@ def _parsed_url(base_url: str) -> tuple[str, str]:
|
||||
url = (base_url or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="API 地址不能为空")
|
||||
if "://" not in url:
|
||||
url = f"http://{url}"
|
||||
# HEL-619: the server validates format as strictly as the UI already
|
||||
# does. A schemeless garbled string used to be silently prefixed with
|
||||
# http:// and then reported as a DNS failure; it is a 400 format error.
|
||||
if "://" not in url or any(ch.isspace() for ch in url):
|
||||
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
|
||||
@@ -135,7 +138,12 @@ async def list_endpoints() -> dict[str, Any]:
|
||||
|
||||
@router.post("/endpoints")
|
||||
async def create_endpoint(payload: EndpointIn) -> dict[str, Any]:
|
||||
base_url, _host = _parsed_url(payload.base_url)
|
||||
base_url, host = _parsed_url(payload.base_url)
|
||||
# HEL-619: the same SSRF guard as the connectivity test applies on
|
||||
# save — otherwise a blocked address can be stored and later probed by
|
||||
# the worker path. Unresolvable hosts still pass (resolution happens
|
||||
# again at probe time).
|
||||
_assert_not_ssrf(host)
|
||||
incoming = [ep for ep in settings.comfyui_endpoints or []]
|
||||
incoming.append(payload.model_dump() | {"base_url": base_url})
|
||||
try:
|
||||
@@ -151,7 +159,8 @@ async def update_endpoint(endpoint_id: str, payload: EndpointPatch) -> dict[str,
|
||||
_stored_endpoint(endpoint_id)
|
||||
base_url = payload.base_url
|
||||
if base_url is not None:
|
||||
base_url, _host = _parsed_url(base_url)
|
||||
base_url, host = _parsed_url(base_url)
|
||||
_assert_not_ssrf(host) # same save-time guard as create (HEL-619)
|
||||
patch = payload.model_dump(exclude_unset=True)
|
||||
if base_url is not None:
|
||||
patch["base_url"] = base_url
|
||||
|
||||
@@ -13,6 +13,10 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from calliope import config
|
||||
from calliope.agent.prompts import item_reference_prompt
|
||||
from calliope.comfyui.lint import blocking_problem
|
||||
from calliope.comfyui.output_map import mapping_problem
|
||||
from calliope.comfyui.parser import effective_input_schema
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.db import get_db, row_to_dict
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.queue.manager import queue_manager
|
||||
@@ -193,7 +197,14 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
return [_job_public(j) for j in queue_manager.list_jobs(pid, None, limit)]
|
||||
# Workflow-test jobs (HEL-643) ride the same scratch project but must not
|
||||
# surface in the studio windows' history — they carry their own dialog.
|
||||
jobs = [
|
||||
j
|
||||
for j in queue_manager.list_jobs(pid, None, limit)
|
||||
if str(j.get("origin") or "user") != "workflow_test"
|
||||
]
|
||||
return [_job_public(j) for j in jobs]
|
||||
|
||||
|
||||
@router.delete("/jobs/{job_id}")
|
||||
@@ -332,7 +343,8 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
try:
|
||||
pid = ensure_playground_project(conn)
|
||||
wf = conn.execute(
|
||||
"SELECT id, kind, is_enabled FROM workflows WHERE id = ?",
|
||||
"SELECT id, kind, is_enabled, workflow_json, input_schema, output_map,"
|
||||
" output_map_source FROM workflows WHERE id = ?",
|
||||
(payload.workflow_id,),
|
||||
).fetchone()
|
||||
if not wf:
|
||||
@@ -344,16 +356,33 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
status_code=400, detail="该工作流已停用,请到「ComfyUI 连接」页启用后再生成"
|
||||
)
|
||||
kind = wf["kind"]
|
||||
workflow_json = json.loads(wf["workflow_json"])
|
||||
# HEL-638: a workflow with unknown custom output nodes must have a
|
||||
# confirmed output mapping before anything is queued.
|
||||
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
broken = blocking_problem(workflow_json)
|
||||
if broken:
|
||||
raise HTTPException(status_code=400, detail=broken)
|
||||
|
||||
input_values, problems = validate_input_values(schema, payload.input_values)
|
||||
if problems:
|
||||
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
|
||||
|
||||
job = queue_manager.enqueue(
|
||||
project_id=pid,
|
||||
kind=kind,
|
||||
workflow_id=payload.workflow_id,
|
||||
scene_id=None,
|
||||
payload={
|
||||
"input_values": payload.input_values,
|
||||
"input_values": input_values,
|
||||
"source": payload.source,
|
||||
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
|
||||
},
|
||||
|
||||
@@ -107,7 +107,11 @@ async def update_settings(payload: SettingsUpdate) -> dict[str, Any]:
|
||||
if assignments_in is not None:
|
||||
settings.ensure_llm_profiles()
|
||||
ids = {p["id"] for p in settings.llm_profiles}
|
||||
cleaned: dict[str, str | None] = {}
|
||||
# HEL-619: per-role INCREMENTAL update. Rapid dropdown switches used
|
||||
# to send whole cached snapshots that fully overwrote the map, so a
|
||||
# stale snapshot could wipe a newer choice. Roles absent from the
|
||||
# payload keep their stored value; a role sent as null is cleared.
|
||||
cleaned: dict[str, str | None] = dict(settings.agent_llm_assignments or {})
|
||||
for role, pid in assignments_in.items():
|
||||
if role not in AGENT_LLM_ROLES:
|
||||
continue
|
||||
|
||||
@@ -5,44 +5,153 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs, parse_dynamic_outputs
|
||||
from calliope.comfyui.endpoints import client_for, endpoint_snapshot
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.output_map import (
|
||||
auto_output_map,
|
||||
load_output_map,
|
||||
mapping_problem,
|
||||
needs_user_output_map,
|
||||
validate_output_map,
|
||||
)
|
||||
from calliope.comfyui.parser import (
|
||||
classify_usage,
|
||||
effective_input_schema,
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
from calliope.comfyui.preflight import check_workflow_against
|
||||
from calliope.comfyui.profiles import detect_prompt_profile
|
||||
from calliope.comfyui.roles import usage_to_kind
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, row_to_dict
|
||||
from calliope.models.schemas import WorkflowAnalyze, WorkflowCreate, WorkflowUpdate
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.models.schemas import (
|
||||
WorkflowAnalyze,
|
||||
WorkflowCheckRequest,
|
||||
WorkflowCreate,
|
||||
WorkflowTestRequest,
|
||||
WorkflowUpdate,
|
||||
)
|
||||
from calliope.queue.manager import queue_manager
|
||||
from calliope.queue.workflow_test import (
|
||||
TEST_SOURCE,
|
||||
find_active_test_job,
|
||||
list_test_jobs,
|
||||
purge_workflow_tests,
|
||||
)
|
||||
|
||||
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"])
|
||||
data["input_schema"] = json.loads(data["input_schema"]) if data.get("input_schema") else []
|
||||
if not data["input_schema"]:
|
||||
# An empty stored schema is re-derived so untagged workflows still expose inputs.
|
||||
data["input_schema"] = parse_dynamic_inputs(data["workflow_json"])
|
||||
data["output_schema"] = json.loads(data["output_schema"]) if data.get("output_schema") else []
|
||||
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,
|
||||
# so parser upgrades reach old rows without a re-import.
|
||||
data["output_schema"] = parse_dynamic_outputs(data["workflow_json"])
|
||||
data["is_enabled"] = bool(data.get("is_enabled"))
|
||||
data["warnings"] = workflow_warnings(data["workflow_json"])
|
||||
raw_map = data.get("output_map")
|
||||
data["output_map"] = load_output_map(raw_map)
|
||||
# Pending flag: unknown output candidates the user has not mapped yet.
|
||||
data["needs_mapping"] = (
|
||||
mapping_problem(data["workflow_json"], raw_map, data.get("output_map_source")) is not None
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def _connection_check(
|
||||
endpoint_id: str | None, workflow_json: dict[str, Any], refresh: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""L2 preflight (HEL-643): workflow vs the target ComfyUI's /object_info.
|
||||
|
||||
An unreachable connection degrades to ``reachable: False`` with an honest
|
||||
「无法核查」 message — it never blocks and never pretends to pass.
|
||||
"""
|
||||
endpoint = settings.resolve_comfyui_endpoint(endpoint_id)
|
||||
if endpoint is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"endpoint_id": endpoint_id,
|
||||
"endpoint_name": None,
|
||||
"findings": [],
|
||||
"message": "没有可用的 ComfyUI 连接,无法核查节点与模型",
|
||||
}
|
||||
base = {
|
||||
"endpoint_id": endpoint.get("id"),
|
||||
"endpoint_name": endpoint.get("name"),
|
||||
"base_url": endpoint.get("base_url"),
|
||||
}
|
||||
client = client_for(endpoint)
|
||||
try:
|
||||
object_info = await client.object_info(force_refresh=refresh)
|
||||
except Exception as exc:
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"findings": [],
|
||||
"message": f"无法核查(连不上该 ComfyUI):{type(exc).__name__}",
|
||||
}
|
||||
finally:
|
||||
await client.close()
|
||||
findings = check_workflow_against(object_info, workflow_json)
|
||||
ok = not any(f.get("level") == "error" for f in findings)
|
||||
return {**base, "ok": ok, "reachable": True, "findings": findings}
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
|
||||
inputs = parse_dynamic_inputs(payload.workflow_json)
|
||||
outputs = parse_dynamic_outputs(payload.workflow_json)
|
||||
return {
|
||||
judgment = classify_usage(payload.workflow_json)
|
||||
result: dict[str, Any] = {
|
||||
"inputs": inputs,
|
||||
"outputs": outputs,
|
||||
"suggested_profile": detect_prompt_profile(payload.workflow_json),
|
||||
"warnings": workflow_warnings(payload.workflow_json),
|
||||
"usage": judgment["usage"],
|
||||
"usage_ambiguous": judgment["ambiguous"],
|
||||
"needs_mapping": judgment["needs_mapping"],
|
||||
"usage_problem": judgment["problem"],
|
||||
"usage_evidence": judgment["evidence"],
|
||||
"usable_usage": judgment["usable_usage"],
|
||||
}
|
||||
# L2 (optional): with a target connection the analyze response also
|
||||
# reports which nodes/models that ComfyUI actually has (HEL-643).
|
||||
if payload.endpoint_id:
|
||||
result["connection_check"] = await _connection_check(
|
||||
payload.endpoint_id, payload.workflow_json, payload.refresh
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _stored_schema(payload_schema: list[dict[str, Any]] | None, workflow: dict[str, Any]) -> str:
|
||||
"""Schema to persist: the confirm page's corrected copy, else the auto one."""
|
||||
if payload_schema and all(isinstance(e, dict) for e in payload_schema):
|
||||
return json.dumps(payload_schema)
|
||||
return json.dumps(parse_dynamic_inputs(workflow))
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/reanalyze")
|
||||
async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
"""Recompute input/output schemas from the stored workflow JSON.
|
||||
"""Recompute usage and input/output schemas from the stored workflow JSON.
|
||||
|
||||
Workflows imported before a parser upgrade keep stale (often empty)
|
||||
schemas — this refreshes them without re-importing.
|
||||
schemas — this refreshes them without re-importing. A usage or output
|
||||
mapping the user set by hand is never overwritten; only ``auto`` (and
|
||||
pending NULL) rows get the new judgment.
|
||||
"""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
@@ -51,11 +160,42 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
data = row_to_dict(row)
|
||||
workflow = json.loads(data["workflow_json"])
|
||||
inputs = parse_dynamic_inputs(workflow)
|
||||
judgment = classify_usage(workflow)
|
||||
usage = data["usage"]
|
||||
usage_source = data["usage_source"]
|
||||
if data["usage_source"] != "user" and judgment["usage"]:
|
||||
usage = judgment["usage"]
|
||||
usage_source = "auto"
|
||||
stored = json.loads(data["input_schema"]) if data["input_schema"] else []
|
||||
schema = effective_input_schema(stored, workflow)
|
||||
outputs = parse_dynamic_outputs(workflow)
|
||||
if data["output_map_source"] == "user":
|
||||
output_map, output_map_source = data["output_map"], "user"
|
||||
elif needs_user_output_map(workflow):
|
||||
# Unknown output candidates: no auto map, and the row must not
|
||||
# generate until the user designates the output nodes.
|
||||
output_map, output_map_source = None, None
|
||||
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(
|
||||
"UPDATE workflows SET input_schema = ?, output_schema = ? WHERE id = ?",
|
||||
(json.dumps(inputs), json.dumps(outputs), workflow_id),
|
||||
"UPDATE workflows SET input_schema = ?, output_schema = ?,"
|
||||
" usage = ?, usage_source = ?, output_map = ?, output_map_source = ?,"
|
||||
" is_enabled = ? WHERE id = ?",
|
||||
(
|
||||
json.dumps(schema),
|
||||
json.dumps(outputs),
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
is_enabled,
|
||||
workflow_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
fresh = conn.execute(
|
||||
@@ -66,16 +206,67 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _last_test_summary(job: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Slim "上次测试" record for a workflow row (queried live, never stored)."""
|
||||
try:
|
||||
outputs = json.loads(job.get("output_paths_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
outputs = []
|
||||
return {
|
||||
"id": job["id"],
|
||||
"status": job.get("status"),
|
||||
"phase": job.get("phase"),
|
||||
"error": job.get("error"),
|
||||
"created_at": job.get("created_at"),
|
||||
"completed_at": job.get("completed_at"),
|
||||
"output_count": len(outputs) if isinstance(outputs, list) else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
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()
|
||||
return [_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]] = {}
|
||||
for jr in conn.execute(
|
||||
"SELECT * FROM jobs WHERE origin = 'workflow_test' AND id IN"
|
||||
" (SELECT MAX(id) FROM jobs WHERE origin = 'workflow_test' GROUP BY workflow_id)"
|
||||
).fetchall():
|
||||
latest[int(jr["workflow_id"])] = _last_test_summary(dict(jr))
|
||||
for data in out:
|
||||
data["last_test"] = latest.get(int(data["id"]))
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/check")
|
||||
async def check_workflow(workflow_id: int, payload: WorkflowCheckRequest) -> dict[str, Any]:
|
||||
"""L2 re-check of a saved workflow against its (or a chosen) connection.
|
||||
|
||||
The user installs missing nodes/models in ComfyUI, comes back here and
|
||||
re-checks — no re-import needed.
|
||||
"""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"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)
|
||||
|
||||
|
||||
@router.post("/dedupe")
|
||||
async def dedupe_workflows() -> dict[str, Any]:
|
||||
"""Delete byte-identical duplicate workflows, keeping the lowest id.
|
||||
@@ -86,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] = []
|
||||
@@ -113,38 +305,77 @@ async def dedupe_workflows() -> dict[str, Any]:
|
||||
|
||||
@router.post("")
|
||||
async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
|
||||
inputs = parse_dynamic_inputs(payload.workflow_json)
|
||||
outputs = parse_dynamic_outputs(payload.workflow_json)
|
||||
judgment = classify_usage(payload.workflow_json)
|
||||
usage = payload.usage or judgment["usage"]
|
||||
usage_source = "user" if payload.usage else "auto"
|
||||
kind = payload.kind or usage_to_kind(usage)
|
||||
if kind is None:
|
||||
# Indeterminate and nothing chosen: classify_output hint keeps old
|
||||
# callers working while the workflow stays pending confirmation.
|
||||
out_kinds = {
|
||||
o.get("kind")
|
||||
for o in parse_dynamic_outputs(payload.workflow_json)
|
||||
if o.get("kind") in ("image", "video", "audio", "file")
|
||||
}
|
||||
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
|
||||
# until the user designates the output nodes.
|
||||
if payload.output_map:
|
||||
problems = validate_output_map(payload.workflow_json, payload.output_map)
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
output_map = json.dumps(payload.output_map)
|
||||
output_map_source = payload.output_map_source or "user"
|
||||
else:
|
||||
auto_map = auto_output_map(payload.workflow_json)
|
||||
output_map = json.dumps(auto_map) if auto_map else None
|
||||
output_map_source = "auto" if auto_map else None
|
||||
mapping_ok = not needs_user_output_map(payload.workflow_json) or bool(payload.output_map)
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
# Byte-identical re-imports are the main way the library fills up with
|
||||
# same-named clones (the "@" typeahead then shows 20 identical rows).
|
||||
existing = conn.execute(
|
||||
"SELECT id, name FROM workflows WHERE name = ? AND kind = ? AND workflow_json = ?",
|
||||
(payload.name, payload.kind, json.dumps(payload.workflow_json)),
|
||||
(payload.name, kind, json.dumps(payload.workflow_json)),
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"An identical workflow already exists (id {existing['id']}, “{existing['name']}”).",
|
||||
detail=(
|
||||
f"An identical workflow already exists (id {existing['id']},"
|
||||
f" “{existing['name']}”)."
|
||||
),
|
||||
)
|
||||
# Unconfirmed workflows (ambiguous usage / unknown outputs without a
|
||||
# user mapping) are stored but disabled: they must not reach the
|
||||
# generate pages until the user confirms usage AND, for unknown
|
||||
# output nodes, designates the outputs on the connections page.
|
||||
is_enabled = 1 if (usage and mapping_ok) else 0
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO workflows (name, kind, workflow_json, input_schema,
|
||||
output_schema, description, prompt_profile,
|
||||
endpoint_id, is_enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
endpoint_id, is_enabled, usage, usage_source,
|
||||
output_map, output_map_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
payload.name,
|
||||
payload.kind,
|
||||
kind,
|
||||
json.dumps(payload.workflow_json),
|
||||
json.dumps(inputs),
|
||||
json.dumps(outputs),
|
||||
_stored_schema(payload.input_schema, payload.workflow_json),
|
||||
json.dumps(parse_dynamic_outputs(payload.workflow_json)),
|
||||
payload.description,
|
||||
profile,
|
||||
payload.endpoint_id,
|
||||
is_enabled,
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -174,11 +405,62 @@ 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
|
||||
# endpoint_id: explicit null clears the binding; other Nones are skips.
|
||||
explicit_null = {"endpoint_id"} & {k for k, v in data.items() if v is None}
|
||||
workflow_json = json.loads(existing["workflow_json"])
|
||||
# HEL-638: output_map payload. A non-empty list validates against the
|
||||
# stored JSON and becomes the user decision; an explicit empty list
|
||||
# resets the row to pending (disabled).
|
||||
if "output_map" in data:
|
||||
if data["output_map"]:
|
||||
problems = validate_output_map(workflow_json, data["output_map"])
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
data["output_map"] = json.dumps(data["output_map"])
|
||||
data.setdefault("output_map_source", "user")
|
||||
else:
|
||||
data["output_map"] = None
|
||||
data["output_map_source"] = None
|
||||
data["is_enabled"] = 0
|
||||
if data.get("usage"):
|
||||
# Confirming / correcting the usage is an explicit user decision:
|
||||
# kind (output media) follows the usage. Whether it re-enables is
|
||||
# decided by the gate below — with unknown output nodes the user
|
||||
# must also designate the output nodes (HEL-638).
|
||||
data.setdefault("usage_source", "user")
|
||||
data["kind"] = usage_to_kind(data["usage"]) or existing["kind"]
|
||||
if "input_schema" in data and data["input_schema"] is not None:
|
||||
if not all(isinstance(e, dict) for e in data["input_schema"]):
|
||||
raise HTTPException(status_code=422, detail="input_schema 必须是参数对象数组")
|
||||
data["input_schema"] = json.dumps(data["input_schema"])
|
||||
# endpoint_id: explicit null clears the binding; other Nones are skips
|
||||
# (an output_map reset keeps its explicit NULLs).
|
||||
explicit_null = {"endpoint_id", "output_map", "output_map_source"} & {
|
||||
k for k, v in data.items() if v is None
|
||||
}
|
||||
data = {k: v for k, v in data.items() if v is not None or k in explicit_null}
|
||||
|
||||
# Enable gate: usage must be settled and, when the graph carries
|
||||
# unknown custom output nodes, a user output mapping must exist.
|
||||
usage_ok = bool(data.get("usage") or existing["usage"])
|
||||
map_raw = data.get("output_map", existing["output_map"])
|
||||
map_source = data.get("output_map_source", existing["output_map_source"])
|
||||
map_problem = mapping_problem(workflow_json, map_raw, map_source)
|
||||
if data.get("is_enabled"):
|
||||
if not usage_ok:
|
||||
raise HTTPException(status_code=400, detail="请先选择工作流用途,再启用该工作流")
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
elif (data.get("usage") or data.get("output_map")) and "is_enabled" not in data:
|
||||
# A usage-confirm or output-map-confirm path: enabled only when
|
||||
# both decisions are settled.
|
||||
data["is_enabled"] = 1 if (usage_ok and not map_problem) else 0
|
||||
if data:
|
||||
fields = ", ".join(f"{k} = :{k}" for k in data)
|
||||
data["id"] = workflow_id
|
||||
@@ -190,14 +472,151 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
|
||||
conn.close()
|
||||
|
||||
|
||||
def _job_public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(job)
|
||||
out["payload"] = json.loads(job.get("payload_json") or "{}")
|
||||
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
|
||||
out.pop("payload_json", None)
|
||||
out.pop("output_paths_json", None)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/test")
|
||||
async def test_workflow(workflow_id: int, payload: WorkflowTestRequest) -> dict[str, Any]:
|
||||
"""Launch (or re-attach to) one REAL test run of a workflow (HEL-643 L3).
|
||||
|
||||
The job goes through the ordinary queue → ComfyUI → outputs pipeline;
|
||||
only its artifacts and bookkeeping are isolated (origin='workflow_test').
|
||||
A dry-run setting is refused outright — placeholder output must never
|
||||
count as a test pass. Repeat clicks reuse the in-flight job.
|
||||
"""
|
||||
from calliope import config
|
||||
|
||||
if config.settings.dry_run:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
|
||||
"请到「设置」关闭 Dry-run 后再测试"
|
||||
),
|
||||
)
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
wf = conn.execute(
|
||||
"SELECT id, kind, workflow_json, input_schema, output_map, output_map_source,"
|
||||
" 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:
|
||||
raise HTTPException(status_code=400, detail=broken)
|
||||
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
|
||||
)
|
||||
input_values, problems = validate_input_values(schema, payload.input_values)
|
||||
if problems:
|
||||
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
|
||||
|
||||
endpoint_id = payload.endpoint_id or wf["endpoint_id"] or None
|
||||
snapshot = endpoint_snapshot(endpoint_id)
|
||||
active = find_active_test_job(workflow_id, (snapshot or {}).get("id"))
|
||||
if active is not None:
|
||||
return {"ok": True, "job": _job_public(active), "reused": True}
|
||||
|
||||
from calliope.routers.playground import ensure_playground_project
|
||||
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
job = queue_manager.enqueue(
|
||||
project_id=pid,
|
||||
kind=wf["kind"],
|
||||
workflow_id=workflow_id,
|
||||
scene_id=None,
|
||||
payload={
|
||||
"input_values": input_values,
|
||||
"source": TEST_SOURCE,
|
||||
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
|
||||
},
|
||||
origin="workflow_test",
|
||||
)
|
||||
await event_bus.publish(
|
||||
"job.created",
|
||||
{"job_id": job["id"], "kind": job["kind"], "source": TEST_SOURCE,
|
||||
"project_id": job["project_id"], "workflow_test": True},
|
||||
)
|
||||
return {"ok": True, "job": _job_public(job), "reused": False}
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/tests")
|
||||
async def list_workflow_tests(workflow_id: int) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
return list_test_jobs(workflow_id)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/clear-tests")
|
||||
async def clear_workflow_tests(workflow_id: int) -> dict[str, Any]:
|
||||
"""Delete every test record and artifact for this workflow."""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
try:
|
||||
removed = purge_workflow_tests(workflow_id)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
await event_bus.publish("job.deleted", {"workflow_id": workflow_id, "source": TEST_SOURCE})
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}")
|
||||
async def delete_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
cur = conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
# HEL-619: refuse to delete while queued/running jobs still use the
|
||||
# workflow — deleting sets their workflow_id NULL and the job dies
|
||||
# with "工作流已被删除" only after the user waits for it.
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workflows WHERE id = ?", (workflow_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
pending = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM jobs"
|
||||
" WHERE workflow_id = ? AND status IN ('pending', 'running')",
|
||||
(workflow_id,),
|
||||
).fetchone()["n"]
|
||||
if pending:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"该工作流还有 {pending} 个排队或执行中的任务,"
|
||||
"请先等任务完成或取消任务,再删除该工作流"
|
||||
),
|
||||
)
|
||||
# Test records/artifacts belong to the workflow — cascade (HEL-643).
|
||||
# The 409 above guarantees none are in flight here.
|
||||
purge_workflow_tests(workflow_id, allow_active=True)
|
||||
conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -10,6 +10,10 @@ from fastapi.testclient import TestClient
|
||||
from calliope.db import migrate_db
|
||||
from calliope.main import create_app
|
||||
|
||||
# Shared fake-ComfyUI HTTP server (defined in test_studio_generation,
|
||||
# reused by the generation / status / unknown-output-mapping suites).
|
||||
from tests.test_studio_generation import fake_comfy # noqa: F401,E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"value": "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)."
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "PROMPT (feeds every stage)"
|
||||
}
|
||||
},
|
||||
"92": {
|
||||
"inputs": {
|
||||
"filename_prefix": "video/04_i2v_fl2v_4step_sla",
|
||||
"format": "auto",
|
||||
"format.codec": "auto",
|
||||
"codec": "auto",
|
||||
"video": [
|
||||
"130",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveVideo",
|
||||
"_meta": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"115": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "16:9 (Widescreen)",
|
||||
"megapixels": 0.7,
|
||||
"multiple": 32
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "ResolutionSelector"
|
||||
}
|
||||
},
|
||||
"116": {
|
||||
"inputs": {
|
||||
"expression": "a"
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"119": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"120": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"121": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"120",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAEDecodeAudio"
|
||||
}
|
||||
},
|
||||
"122": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAEDecode"
|
||||
}
|
||||
},
|
||||
"124": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 4,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"125": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"129",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"126",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"135",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"124",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"131",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"126": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"131",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "BasicGuider"
|
||||
}
|
||||
},
|
||||
"127": {
|
||||
"inputs": {
|
||||
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNETLoader"
|
||||
}
|
||||
},
|
||||
"128": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
|
||||
"type": "minimax",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "CLIPLoader"
|
||||
}
|
||||
},
|
||||
"129": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"130": {
|
||||
"inputs": {
|
||||
"fps": 24,
|
||||
"bit_depth": 8,
|
||||
"color_space": "sRGB",
|
||||
"codec": "none",
|
||||
"images": [
|
||||
"122",
|
||||
0
|
||||
],
|
||||
"audio": [
|
||||
"121",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CreateVideo",
|
||||
"_meta": {
|
||||
"title": "CreateVideo"
|
||||
}
|
||||
},
|
||||
"131": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"width": [
|
||||
"115",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"115",
|
||||
1
|
||||
],
|
||||
"length": [
|
||||
"132",
|
||||
1
|
||||
],
|
||||
"clip": [
|
||||
"128",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
],
|
||||
"first_frame": [
|
||||
"901",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3ImageToVideo",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3ImageToVideo"
|
||||
}
|
||||
},
|
||||
"132": {
|
||||
"inputs": {
|
||||
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
|
||||
"values.a": [
|
||||
"133",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"133": {
|
||||
"inputs": {
|
||||
"value": 10
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "PrimitiveFloat"
|
||||
}
|
||||
},
|
||||
"135": {
|
||||
"inputs": {
|
||||
"sampler_name": "res_multistep"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"142": {
|
||||
"inputs": {
|
||||
"shift_video": 12,
|
||||
"shift_audio": 3,
|
||||
"model": [
|
||||
"900",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3SigmaShift",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3SigmaShift"
|
||||
}
|
||||
},
|
||||
"900": {
|
||||
"inputs": {
|
||||
"sparsity_ratio": 0.9,
|
||||
"block_size": "64",
|
||||
"min_seq_len": 8192,
|
||||
"dense_last_steps": 0,
|
||||
"protect_audio": true,
|
||||
"enabled": true,
|
||||
"dense_steps": "1",
|
||||
"dense_backend": "comfy_kitchen",
|
||||
"disable_fp16_accum": true,
|
||||
"stabilize_motion": false,
|
||||
"reference_protection": "Off",
|
||||
"tail_correction": false,
|
||||
"use_int8_qk": true,
|
||||
"engine": "comfy_kitchen",
|
||||
"model": [
|
||||
"127",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "H3SLAAttention",
|
||||
"_meta": {
|
||||
"title": "H3 SLA Attention 0.9/64 (their graph)"
|
||||
}
|
||||
},
|
||||
"901": {
|
||||
"inputs": {
|
||||
"image": "first_frame.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "FIRST FRAME (add a second LoadImage into last_frame for FL2V)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"value": "PASTE YOUR PROMPT HERE, in the reference contract:\nsubject_definitions:\n<Subject 1> is the person in <Picture 1>; their face, hair and clothing follow <Picture 1>.\n\nsummary:\n[reference generation] one continuous shot of <Subject 1> doing X, appearance taken from <Picture 1>.\n\nretention_analysis:\n<Subject 1> is fully preserved from <Picture 1>.\n\ndetailed_description:\n[Shot 1] ... camera, action, timing ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: N/A"
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "PROMPT (feeds every stage)"
|
||||
}
|
||||
},
|
||||
"92": {
|
||||
"inputs": {
|
||||
"filename_prefix": "video/05_ref2va_4step_sla",
|
||||
"format": "auto",
|
||||
"format.codec": "auto",
|
||||
"codec": "auto",
|
||||
"video": [
|
||||
"130",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveVideo",
|
||||
"_meta": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"115": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "16:9 (Widescreen)",
|
||||
"megapixels": 0.7,
|
||||
"multiple": 32
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "ResolutionSelector"
|
||||
}
|
||||
},
|
||||
"116": {
|
||||
"inputs": {
|
||||
"expression": "a"
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"119": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"120": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"121": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"120",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAEDecodeAudio"
|
||||
}
|
||||
},
|
||||
"122": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAEDecode"
|
||||
}
|
||||
},
|
||||
"124": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 4,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"125": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"129",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"126",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"135",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"124",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"131",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"126": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"131",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "BasicGuider"
|
||||
}
|
||||
},
|
||||
"127": {
|
||||
"inputs": {
|
||||
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNETLoader"
|
||||
}
|
||||
},
|
||||
"128": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
|
||||
"type": "minimax",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "CLIPLoader"
|
||||
}
|
||||
},
|
||||
"129": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"130": {
|
||||
"inputs": {
|
||||
"fps": 24,
|
||||
"bit_depth": 8,
|
||||
"color_space": "sRGB",
|
||||
"codec": "none",
|
||||
"images": [
|
||||
"122",
|
||||
0
|
||||
],
|
||||
"audio": [
|
||||
"121",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CreateVideo",
|
||||
"_meta": {
|
||||
"title": "CreateVideo"
|
||||
}
|
||||
},
|
||||
"131": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"width": [
|
||||
"115",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"115",
|
||||
1
|
||||
],
|
||||
"length": [
|
||||
"132",
|
||||
1
|
||||
],
|
||||
"ref_image_size": "match",
|
||||
"clip": [
|
||||
"128",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
],
|
||||
"audio_vae": [
|
||||
"120",
|
||||
0
|
||||
],
|
||||
"ref_images.ref_image_0": [
|
||||
"901",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3ReferenceToVideo",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3ReferenceToVideo"
|
||||
}
|
||||
},
|
||||
"132": {
|
||||
"inputs": {
|
||||
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
|
||||
"values.a": [
|
||||
"133",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"133": {
|
||||
"inputs": {
|
||||
"value": 10
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "PrimitiveFloat"
|
||||
}
|
||||
},
|
||||
"135": {
|
||||
"inputs": {
|
||||
"sampler_name": "res_multistep"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"142": {
|
||||
"inputs": {
|
||||
"shift_video": 12,
|
||||
"shift_audio": 3,
|
||||
"model": [
|
||||
"900",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3SigmaShift",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3SigmaShift"
|
||||
}
|
||||
},
|
||||
"900": {
|
||||
"inputs": {
|
||||
"sparsity_ratio": 0.9,
|
||||
"block_size": "64",
|
||||
"min_seq_len": 8192,
|
||||
"dense_last_steps": 0,
|
||||
"protect_audio": true,
|
||||
"enabled": true,
|
||||
"dense_steps": "1",
|
||||
"dense_backend": "comfy_kitchen",
|
||||
"disable_fp16_accum": true,
|
||||
"stabilize_motion": false,
|
||||
"reference_protection": "Off",
|
||||
"tail_correction": false,
|
||||
"use_int8_qk": true,
|
||||
"engine": "comfy_kitchen",
|
||||
"model": [
|
||||
"127",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "H3SLAAttention",
|
||||
"_meta": {
|
||||
"title": "H3 SLA Attention 0.9/64 (their graph)"
|
||||
}
|
||||
},
|
||||
"901": {
|
||||
"inputs": {
|
||||
"image": "reference_1.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "REFERENCE IMAGE 1 (<Picture 1>); the node grows more image / video / audio slots"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"UNKNOWN": "qwen-image-2.1-Q4_K_M.gguf"
|
||||
},
|
||||
"_meta": {
|
||||
"title": "Unet Loader (GGUF)"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "加载CLIP"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "加载VAE"
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"inputs": {
|
||||
"prompt": "参考角色五官,重新生成图片:高细节皮肤质感",
|
||||
"negative_prompt": "模糊,低质量,文字乱码,变形",
|
||||
"resolution": 1024,
|
||||
"clip": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"images.image_1": [
|
||||
"10",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"_meta": {
|
||||
"title": "Text Encode Qwen Image 2.1"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": {
|
||||
"title": "空Latent图像"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"seed": 278835418249884,
|
||||
"steps": 25,
|
||||
"cfg": 1,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"positive": [
|
||||
"4",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"4",
|
||||
1
|
||||
],
|
||||
"latent_image": [
|
||||
"4",
|
||||
2
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "K采样器"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE解码"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"filename_prefix": "qwen_image",
|
||||
"images": [
|
||||
"7",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "保存图像"
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"UNKNOWN": "auto"
|
||||
},
|
||||
"_meta": {
|
||||
"title": "Patch Sage Attention KJ"
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"inputs": {
|
||||
"image": "3f24a075cd7b4118829be1b222ca1ab9.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "加载图像"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,6 +374,61 @@ def test_comfy_test_requires_url(client):
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_schemeless_url_is_format_error_everywhere(client):
|
||||
"""HEL-619: a schemeless/garbled address is a 400 format error on every
|
||||
entry point (probe + save), not a silent http:// prefix that later
|
||||
surfaces as a DNS-classified probe failure."""
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
for url in ("192.168.0.1:8188", "http:192.168.0.1", "ht!tp://x", "http://a b.test/"):
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints/test", json={"base_url": url, "timeout_sec": 2}
|
||||
)
|
||||
assert r.status_code == 400, url
|
||||
assert "http://" in r.json()["detail"] or "https://" in r.json()["detail"]
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints", json={"name": "无协议", "base_url": url}
|
||||
)
|
||||
assert r.status_code == 400, url
|
||||
assert "无协议" not in [ep.get("name") for ep in settings.comfyui_endpoints or []]
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_save_endpoint_runs_ssrf_guard(client):
|
||||
"""HEL-619: the SSRF guard applies on save (create + edit), not only on
|
||||
the connectivity probe; loopback stays allowed."""
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
_clear_endpoints()
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "元数据", "base_url": "http://169.254.169.254/latest"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "禁止" in r.json()["detail"]
|
||||
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "本机", "base_url": "http://127.0.0.1:8188"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
eid = r.json()["endpoints"][-1]["id"]
|
||||
|
||||
r = client.patch(
|
||||
f"/api/comfyui/endpoints/{eid}",
|
||||
json={"base_url": "http://169.254.169.254/latest"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
# the stored endpoint is untouched
|
||||
stored = next(ep for ep in settings.comfyui_endpoints if ep["id"] == eid)
|
||||
assert stored["base_url"] == "http://127.0.0.1:8188"
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# workflow binding + job snapshots
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -381,10 +436,21 @@ def test_comfy_test_requires_url(client):
|
||||
|
||||
def _import_workflow(client, name="H3 视频", kind="video", endpoint_id=None):
|
||||
wf_json = {
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "model.safetensors"},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "placeholder prompt", "clip": ["4", 1]},
|
||||
},
|
||||
# HEL-636: a workflow needs a recognizable output node to classify a
|
||||
# usage and stay enabled; without it generation is refused.
|
||||
"9": {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {"images": ["6", 0]},
|
||||
"_meta": {"title": "Video Output (Output:video)"},
|
||||
},
|
||||
}
|
||||
payload = {"name": name, "kind": kind, "workflow_json": wf_json}
|
||||
if endpoint_id is not None:
|
||||
@@ -531,6 +597,41 @@ def test_queue_enqueue_stamps_all_paths(client):
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_workflow_delete_blocked_while_jobs_pending(client):
|
||||
"""HEL-619: deleting a workflow with pending/running jobs is refused
|
||||
with 409 instead of silently nulling the jobs' workflow_id."""
|
||||
from calliope.db import get_db
|
||||
from calliope.queue.manager import queue_manager
|
||||
|
||||
wf = _import_workflow(client).json()
|
||||
project = client.post(
|
||||
"/api/projects", json={"title": "删除保护测试", "idea": "x"}
|
||||
).json()
|
||||
# Pause the lifespan-started worker so the pending job STAYS pending.
|
||||
queue_manager.paused = True
|
||||
try:
|
||||
queue_manager.enqueue(
|
||||
project_id=project["id"], kind="video", workflow_id=wf["id"], payload={}
|
||||
)
|
||||
|
||||
r = client.delete(f"/api/workflows/{wf['id']}")
|
||||
assert r.status_code == 409
|
||||
assert "排队" in r.json()["detail"]
|
||||
finally:
|
||||
queue_manager.paused = False
|
||||
|
||||
# job finished → deletion allowed again
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute("UPDATE jobs SET status = 'done' WHERE project_id = ?", (project["id"],))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
r = client.delete(f"/api/workflows/{wf['id']}")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_settings_public_dict_masks_endpoint_tokens(client):
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,594 @@
|
||||
"""HEL-633: the three image-input workflows the user supplied — Qwen Image 2.1
|
||||
图片编辑, MiniMax H3 首帧图生视频 (04) and H3 参考图生视频 (05, 1–9 refs).
|
||||
|
||||
Slots come from real wiring, every picked image is uploaded to ComfyUI and
|
||||
lands in the node input it belongs to (in order, nothing stale), and a
|
||||
broken export is refused before anything is queued."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.parser import detect_untagged_inputs, parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.smart_fill import smart_fill_inputs
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import (
|
||||
PNG_1PX,
|
||||
_FakeComfyHandler,
|
||||
_import_workflow,
|
||||
_real_run,
|
||||
_wait_done,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
I2V = "h3_i2v_04_API.json" # 04_i2v_fl2v_4step_sla (1).json
|
||||
REF = "h3_ref2va_05_API.json" # 05_ref2va_4step_sla (1).json
|
||||
QWEN_EDIT = "qwen21_edit_sage_API.json" # qwen-image-2.1-图片编辑-sage加速.json
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
REF_KEY = "131@ref_images"
|
||||
LAST_KEY = "131@last_frame"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _by_node(inputs: list[dict]) -> dict[str, dict]:
|
||||
return {i["nodeId"]: i for i in inputs}
|
||||
|
||||
|
||||
def _ref_links(wf: dict, node_id: str = "131") -> list[tuple[str, str]]:
|
||||
"""[(input name, LoadImage image value), …] in slot order."""
|
||||
inputs = wf[node_id]["inputs"]
|
||||
names = sorted(
|
||||
(k for k in inputs if k.startswith("ref_images.ref_image_")),
|
||||
key=lambda k: int(k.rsplit("_", 1)[1]),
|
||||
)
|
||||
return [(k, wf[inputs[k][0]]["inputs"]["image"]) for k in names]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slot detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_i2v_exposes_required_first_frame_and_optional_last_frame():
|
||||
"""HEL-671: the 04 export is H3 FL2VA — `last_frame` is unwired but real."""
|
||||
inputs = parse_dynamic_inputs(_load(I2V))
|
||||
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
|
||||
assert media == [
|
||||
{
|
||||
"nodeId": "901",
|
||||
"label": "首帧",
|
||||
"role": "first_frame",
|
||||
"kind": "image",
|
||||
"defaultValue": None,
|
||||
"required": True,
|
||||
"auto": True,
|
||||
},
|
||||
{
|
||||
"nodeId": LAST_KEY,
|
||||
"label": "尾帧",
|
||||
"role": "last_frame",
|
||||
"kind": "image",
|
||||
"defaultValue": None,
|
||||
"required": False,
|
||||
"auto": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_wired_last_frame_is_its_own_slot_not_a_second_virtual_one():
|
||||
wf = _load(I2V)
|
||||
wf["950"] = {"class_type": "LoadImage", "inputs": {"image": "last.png"}}
|
||||
wf["131"]["inputs"]["last_frame"] = ["950", 0]
|
||||
media = [i for i in parse_dynamic_inputs(wf) if i["kind"] == "image"]
|
||||
assert [(i["nodeId"], i["role"], i["required"]) for i in media] == [
|
||||
("901", "first_frame", True),
|
||||
("950", "last_frame", True),
|
||||
]
|
||||
|
||||
|
||||
def test_first_frame_through_a_custom_loader_gets_no_last_frame_slot():
|
||||
# Repo example: VHS_LoadImagePath → resize → first_frame; no slot pair to mirror.
|
||||
wf = json.loads(
|
||||
(EXAMPLES / "minimax_h3-Turbo_Image2Video_20260813_API.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert not any(i.get("role") == "last_frame" for i in parse_dynamic_inputs(wf))
|
||||
|
||||
|
||||
def test_ref2v_exposes_one_group_of_one_to_nine_reference_images():
|
||||
inputs = parse_dynamic_inputs(_load(REF))
|
||||
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
|
||||
assert len(media) == 1
|
||||
group = media[0]
|
||||
assert group["nodeId"] == REF_KEY
|
||||
assert group["kind"] == "image_list"
|
||||
assert group["role"] == "reference_image"
|
||||
assert group["label"] == "参考图"
|
||||
assert (group["min"], group["max"], group["required"]) == (1, 9, True)
|
||||
# the parameters HEL-627 wired stay exposed
|
||||
by_node = _by_node(inputs)
|
||||
for key in ("1", "115@aspect_ratio", "115@megapixels", "124@steps", "124@denoise",
|
||||
"129@noise_seed", "133"):
|
||||
assert key in by_node, key
|
||||
assert not any(i["kind"] == "video" for i in inputs)
|
||||
|
||||
|
||||
def test_example_five_ref_workflow_folds_into_the_same_group():
|
||||
# The repo example is (Input:…)-tagged and keeps its tagged slots; stripped
|
||||
# of tags (as a plain ComfyUI export) its five loaders become one group.
|
||||
wf = json.loads((EXAMPLES / "video_minimax_h3_r2v_5ref_API.json").read_text(encoding="utf-8"))
|
||||
inputs = detect_untagged_inputs(wf)
|
||||
groups = [i for i in inputs if i["kind"] == "image_list"]
|
||||
assert [g["nodeId"] for g in groups] == ["178@ref_images"]
|
||||
# its five LoadImages are the group, not five separate slots
|
||||
assert not any(i["kind"] == "image" for i in inputs if i.get("role") != "mask")
|
||||
|
||||
|
||||
def test_qwen_edit_exposes_input_image_real_resolution_and_hides_orphan_latent():
|
||||
inputs = parse_dynamic_inputs(_load(QWEN_EDIT))
|
||||
by_node = _by_node(inputs)
|
||||
img = by_node["10"]
|
||||
assert (img["kind"], img["role"], img["label"]) == ("image", "image", "输入图")
|
||||
assert img["required"] is True
|
||||
res = by_node["4@resolution"]
|
||||
assert (res["role"], res["defaultValue"], res["min"], res["max"], res["step"]) == (
|
||||
"resolution", 1024, 0, 4096, 32,
|
||||
)
|
||||
assert by_node["4@prompt"]["role"] == "prompt"
|
||||
assert by_node["4@negative_prompt"]["role"] == "negative"
|
||||
for key in ("6@seed", "6@steps", "6@cfg", "6@denoise"):
|
||||
assert key in by_node
|
||||
# EmptyLatentImage #5 feeds nothing — its width/height would do nothing
|
||||
assert not any(i["nodeId"].startswith("5@") for i in inputs)
|
||||
|
||||
|
||||
def test_unwired_load_image_gets_no_slot():
|
||||
wf = _load(I2V)
|
||||
wf["950"] = {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "last.png"},
|
||||
"_meta": {"title": "LAST"},
|
||||
}
|
||||
assert "950" not in _by_node(parse_dynamic_inputs(wf))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Broken exports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_broken_qwen_export_is_flagged_and_blocked():
|
||||
warnings = workflow_warnings(_load(QWEN_EDIT))
|
||||
errors = {(w["code"], w["node_id"]) for w in warnings if w["level"] == "error"}
|
||||
assert errors == {
|
||||
("missing_class_type", "1"),
|
||||
("missing_class_type", "9"),
|
||||
("missing_model_link", "6"),
|
||||
}
|
||||
assert ("orphan_latent", "5") in {(w["code"], w["node_id"]) for w in warnings}
|
||||
msg = blocking_problem(_load(QWEN_EDIT))
|
||||
assert msg and "Unet Loader (GGUF)" in msg and "重新导出" in msg
|
||||
|
||||
|
||||
def test_h3_workflows_are_clean():
|
||||
assert workflow_warnings(_load(I2V)) == []
|
||||
assert workflow_warnings(_load(REF)) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patching 1 / 4 / 9 reference images
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 4, 9])
|
||||
def test_patch_wires_each_reference_image_in_order(count):
|
||||
base = _load(REF)
|
||||
paths = [f"/assets/uploads/ref-{i}.png" for i in range(1, count + 1)]
|
||||
wf = patch_workflow(base, {REF_KEY: paths})
|
||||
links = _ref_links(wf)
|
||||
assert links == [(f"ref_images.ref_image_{i}", p) for i, p in enumerate(paths)]
|
||||
# every loader is a real, distinct LoadImage feeding node 131
|
||||
loader_ids = [wf["131"]["inputs"][name][0] for name, _ in links]
|
||||
assert len(set(loader_ids)) == count
|
||||
assert all(wf[n]["class_type"] == "LoadImage" for n in loader_ids)
|
||||
assert loader_ids[0] == "901"
|
||||
# the base workflow is untouched
|
||||
assert _ref_links(base) == [("ref_images.ref_image_0", "reference_1.png")]
|
||||
|
||||
|
||||
def test_patch_shrinking_removes_stale_slots_and_loaders():
|
||||
nine = patch_workflow(_load(REF), {REF_KEY: [f"/u/{i}.png" for i in range(9)]})
|
||||
two = patch_workflow(nine, {REF_KEY: ["/u/b.png", "/u/a.png"]})
|
||||
assert _ref_links(two) == [
|
||||
("ref_images.ref_image_0", "/u/b.png"),
|
||||
("ref_images.ref_image_1", "/u/a.png"),
|
||||
]
|
||||
load_images = [n for n, v in two.items() if v.get("class_type") == "LoadImage"]
|
||||
assert len(load_images) == 2
|
||||
assert not any("/u/8.png" in json.dumps(v) for v in two.values())
|
||||
|
||||
|
||||
def test_single_slot_values_patch_first_frame_and_qwen_input():
|
||||
i2v = patch_workflow(_load(I2V), {"901": "/assets/uploads/first.png"})
|
||||
assert i2v["901"]["inputs"]["image"] == "/assets/uploads/first.png"
|
||||
assert i2v["131"]["inputs"]["first_frame"] == ["901", 0]
|
||||
qwen = patch_workflow(_load(QWEN_EDIT), {"10": "/assets/uploads/in.png", "4@resolution": 1536})
|
||||
assert qwen["10"]["inputs"]["image"] == "/assets/uploads/in.png"
|
||||
assert qwen["4"]["inputs"]["resolution"] == 1536
|
||||
|
||||
|
||||
def test_patch_last_frame_adds_its_own_load_image():
|
||||
base = _load(I2V)
|
||||
wf = patch_workflow(base, {"901": "/u/first.png", LAST_KEY: "/u/last.png"})
|
||||
node = wf["131"]["inputs"]
|
||||
assert node["first_frame"] == ["901", 0]
|
||||
last_id = node["last_frame"][0]
|
||||
assert last_id not in base and node["last_frame"] == [last_id, 0]
|
||||
assert wf[last_id]["class_type"] == "LoadImage"
|
||||
assert wf[last_id]["inputs"] == {"image": "/u/last.png"}
|
||||
assert wf["901"]["inputs"]["image"] == "/u/first.png"
|
||||
# base untouched; patching the result again rewires nothing
|
||||
assert "last_frame" not in base["131"]["inputs"]
|
||||
again = patch_workflow(wf, {LAST_KEY: "/u/other.png"})
|
||||
assert again["131"]["inputs"]["last_frame"] == [last_id, 0]
|
||||
|
||||
|
||||
def test_patch_without_last_frame_leaves_first_frame_only_graph():
|
||||
for values in ({"901": "/u/first.png"}, {"901": "/u/first.png", LAST_KEY: ""}):
|
||||
wf = patch_workflow(_load(I2V), values)
|
||||
assert "last_frame" not in wf["131"]["inputs"]
|
||||
assert sorted(wf) == sorted(_load(I2V))
|
||||
|
||||
|
||||
def test_legacy_job_values_keyed_by_load_image_still_patch():
|
||||
"""Jobs queued before HEL-633 carry the LoadImage id, not the group key."""
|
||||
wf = patch_workflow(_load(REF), {"901": "/assets/uploads/old.png"})
|
||||
assert _ref_links(wf) == [("ref_images.ref_image_0", "/assets/uploads/old.png")]
|
||||
|
||||
|
||||
def test_smart_fill_hands_reference_list_to_the_group():
|
||||
inputs = parse_dynamic_inputs(_load(REF))
|
||||
refs = [f"/r/{i}.png" for i in range(12)]
|
||||
values = smart_fill_inputs(inputs, prompt="p", ref_images=refs)
|
||||
assert values[REF_KEY] == refs[:9]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uploads_dir(client):
|
||||
d = settings.assets_dir / "uploads"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _pngs(d: Path, n: int) -> list[str]:
|
||||
out = []
|
||||
for i in range(n):
|
||||
p = d / f"ref{i + 1}.png"
|
||||
p.write_bytes(PNG_1PX)
|
||||
out.append(str(p))
|
||||
return out
|
||||
|
||||
|
||||
def test_validate_reference_count_limits(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_load(REF))
|
||||
base = {"1": "prompt"}
|
||||
for n in (1, 4, 9):
|
||||
clean, errs = validate_input_values(schema, {**base, REF_KEY: _pngs(uploads_dir, n)})
|
||||
assert errs == [] and len(clean[REF_KEY]) == n
|
||||
_, errs = validate_input_values(schema, {**base, REF_KEY: _pngs(uploads_dir, 10)})
|
||||
assert errs == ["参考图最多 9 张(当前 10 张),请删除多余的图片"]
|
||||
_, errs = validate_input_values(schema, base)
|
||||
assert errs == ["需要至少 1 张参考图"]
|
||||
_, errs = validate_input_values(schema, {**base, REF_KEY: []})
|
||||
assert errs == ["需要至少 1 张参考图"]
|
||||
|
||||
|
||||
def test_validate_rejects_bad_reference_files(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_load(REF))
|
||||
gif = uploads_dir / "anim.gif"
|
||||
gif.write_bytes(b"GIF89a")
|
||||
ok = _pngs(uploads_dir, 1)[0]
|
||||
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: [ok, str(gif)]})
|
||||
assert errs == ["参考图 2:格式不支持,请使用 PNG / JPG / WebP"]
|
||||
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: ["/etc/passwd.png"]})
|
||||
assert errs == ["参考图 1:只能使用在本页上传或从生成结果选入的图片"]
|
||||
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: [str(uploads_dir / "gone.png")]})
|
||||
assert errs == ["参考图 1:文件已不存在,请重新上传"]
|
||||
|
||||
|
||||
def test_validate_requires_first_frame_and_input_image():
|
||||
_, errs = validate_input_values(parse_dynamic_inputs(_load(I2V)), {"1": "p"})
|
||||
assert errs == ["请上传首帧"]
|
||||
_, errs = validate_input_values(
|
||||
parse_dynamic_inputs(_load(I2V)), {"1": "p", LAST_KEY: "/u/l.png"}
|
||||
)
|
||||
assert errs == ["请上传首帧"]
|
||||
clean, errs = validate_input_values(
|
||||
parse_dynamic_inputs(_load(I2V)), {"1": "p", "901": "/u/f.png"}
|
||||
)
|
||||
assert errs == [] and LAST_KEY not in clean
|
||||
_, errs = validate_input_values(parse_dynamic_inputs(_load(QWEN_EDIT)), {"4@prompt": "p"})
|
||||
assert errs == ["请上传输入图"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full chain against a fake ComfyUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _NamedUploadHandler(_FakeComfyHandler):
|
||||
"""/upload/image answers with the uploaded file's own name, like ComfyUI."""
|
||||
|
||||
def do_POST(self): # noqa: N802 — BaseHTTPRequestHandler hook name
|
||||
if self.path != "/upload/image":
|
||||
super().do_POST()
|
||||
return
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else b""
|
||||
self.server.requests.append(self.path)
|
||||
self.server.uploads.append(body)
|
||||
m = re.search(rb'filename="([^"]+)"', body)
|
||||
name = m.group(1).decode() if m else "x.png"
|
||||
self.server.upload_names.append(name)
|
||||
self._json(200, {"name": name, "subfolder": "calliope", "type": "input"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def named_comfy():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _NamedUploadHandler)
|
||||
server.requests = []
|
||||
server.prompts = []
|
||||
server.uploads = []
|
||||
server.upload_names = []
|
||||
server.history = {}
|
||||
server.fail_prompt = False
|
||||
server.out_name = "ref2v_00001_.mp4"
|
||||
server.outputs_key = "images"
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _upload(client, name: str) -> str:
|
||||
r = client.post("/api/playground/uploads", files={"file": (name, PNG_1PX, "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["path"]
|
||||
|
||||
|
||||
def _generate(client, wf_id: int, values: dict, source: str = "studio-video"):
|
||||
return client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": values,
|
||||
"source": source,
|
||||
"endpoint_id": settings.comfyui_active_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 4, 9])
|
||||
def test_ref2v_chain_uploads_every_image_and_wires_it_in_order(client, named_comfy, count):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
|
||||
paths = [_upload(client, f"角色{i}.png") for i in range(1, count + 1)]
|
||||
r = _generate(
|
||||
client,
|
||||
wf_id,
|
||||
{"1": "<Subject 1> 走进雨夜码头", REF_KEY: paths, "124@steps": 4, "133": 2},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["payload"]["input_values"][REF_KEY] == paths
|
||||
|
||||
assert len(named_comfy.prompts) == 1
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
expected = [f"calliope/{Path(p).name}" for p in paths]
|
||||
assert [img for _k, img in _ref_links(prompt)] == expected
|
||||
assert named_comfy.upload_names == [Path(p).name for p in paths]
|
||||
assert prompt["1"]["inputs"]["value"] == "<Subject 1> 走进雨夜码头"
|
||||
assert prompt["124"]["inputs"]["steps"] == 4
|
||||
assert prompt["133"]["inputs"]["value"] == 2
|
||||
# no leftover default reference and no unconsumed LoadImage
|
||||
assert "reference_1.png" not in json.dumps(prompt)
|
||||
loaders = {n for n, v in prompt.items() if v["class_type"] == "LoadImage"}
|
||||
wired = {v[0] for k, v in prompt["131"]["inputs"].items() if k.startswith("ref_images.")}
|
||||
assert loaders == wired
|
||||
|
||||
|
||||
def test_ref2v_replace_and_delete_leave_no_stale_image(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
|
||||
a, b, c = (_upload(client, n) for n in ("a.png", "b.png", "c.png"))
|
||||
r = _generate(client, wf_id, {"1": "x", REF_KEY: [a, b, c]})
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
# user replaced #2 with d and deleted #3
|
||||
d = _upload(client, "d.png")
|
||||
r = _generate(client, wf_id, {"1": "x", REF_KEY: [a, d]})
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
second = named_comfy.prompts[1]["prompt"]
|
||||
assert [img for _k, img in _ref_links(second)] == [
|
||||
f"calliope/{Path(a).name}",
|
||||
f"calliope/{Path(d).name}",
|
||||
]
|
||||
dumped = json.dumps(second)
|
||||
assert Path(b).name not in dumped and Path(c).name not in dumped
|
||||
|
||||
|
||||
def test_ref2v_ten_images_rejected_before_queue(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
|
||||
paths = [_upload(client, f"r{i}.png") for i in range(10)]
|
||||
r = _generate(client, wf_id, {"1": "x", REF_KEY: paths})
|
||||
assert r.status_code == 400
|
||||
assert "参考图最多 9 张" in r.json()["detail"]
|
||||
r = _generate(client, wf_id, {"1": "x"})
|
||||
assert r.status_code == 400
|
||||
assert "需要至少 1 张参考图" in r.json()["detail"]
|
||||
assert named_comfy.prompts == [] and named_comfy.upload_names == []
|
||||
|
||||
|
||||
def test_i2v_chain_uploads_first_frame(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 图生视频", "video", _load(I2V))
|
||||
r = _generate(client, wf_id, {"1": "镜头缓慢推近"})
|
||||
assert r.status_code == 400 and "请上传首帧" in r.json()["detail"]
|
||||
first = _upload(client, "首帧.png")
|
||||
r = _generate(client, wf_id, {"1": "镜头缓慢推近", "901": first, "124@denoise": 0.8})
|
||||
assert r.status_code == 200, r.text
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
assert prompt["901"]["inputs"]["image"] == f"calliope/{Path(first).name}"
|
||||
assert prompt["131"]["inputs"]["first_frame"] == ["901", 0]
|
||||
assert "last_frame" not in prompt["131"]["inputs"]
|
||||
assert prompt["124"]["inputs"]["denoise"] == 0.8
|
||||
|
||||
|
||||
def test_fl2v_chain_uploads_first_and_last_frame_into_their_own_inputs(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 首尾帧生视频", "video", _load(I2V))
|
||||
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
|
||||
assert listed["usage"] == "image2video"
|
||||
slots = {i["nodeId"]: i for i in listed["input_schema"] if i["kind"] == "image"}
|
||||
assert slots["901"]["role"] == "first_frame" and slots[LAST_KEY]["role"] == "last_frame"
|
||||
last = _upload(client, "尾帧.png")
|
||||
r = _generate(client, wf_id, {"1": "从白天过渡到夜晚", LAST_KEY: last})
|
||||
assert r.status_code == 400 and "请上传首帧" in r.json()["detail"]
|
||||
first = _upload(client, "首帧.png")
|
||||
r = _generate(client, wf_id, {"1": "从白天过渡到夜晚", "901": first, LAST_KEY: last})
|
||||
assert r.status_code == 200, r.text
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
node = prompt["131"]["inputs"]
|
||||
assert prompt[node["first_frame"][0]]["inputs"]["image"] == f"calliope/{Path(first).name}"
|
||||
assert prompt[node["last_frame"][0]]["inputs"]["image"] == f"calliope/{Path(last).name}"
|
||||
assert node["first_frame"][0] != node["last_frame"][0]
|
||||
assert sorted(named_comfy.upload_names) == sorted([Path(first).name, Path(last).name])
|
||||
|
||||
|
||||
def test_migration_renames_the_imported_first_frame_workflow(tmp_path):
|
||||
import asyncio
|
||||
import sqlite3
|
||||
|
||||
from calliope.db import migrate_db
|
||||
|
||||
db = tmp_path / "c.db"
|
||||
asyncio.run(migrate_db(db))
|
||||
conn = sqlite3.connect(db)
|
||||
i2v, ref = json.dumps(_load(I2V)), json.dumps(_load(REF))
|
||||
conn.executemany(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, description) VALUES (?, 'video', ?, ?)",
|
||||
[
|
||||
("H3 图生视频·首帧(04 用户提供)", i2v, None),
|
||||
("H3图生视频·首帧", i2v, "我自己写的说明"),
|
||||
("图生视频·首帧模板", ref, None), # not the FL2VA graph — left alone
|
||||
("我的视频工作流", i2v, None), # user already renamed — left alone
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
asyncio.run(migrate_db(db))
|
||||
asyncio.run(migrate_db(db)) # idempotent
|
||||
conn = sqlite3.connect(db)
|
||||
rows = conn.execute("SELECT name, description FROM workflows ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
assert rows[0] == (
|
||||
"H3 首尾帧生视频(04 用户提供)",
|
||||
"首帧必填,尾帧可选:只传首帧为图生视频,同时传尾帧为首尾帧生视频",
|
||||
)
|
||||
assert rows[1] == ("H3首尾帧生视频", "我自己写的说明") # suffix kept, description kept
|
||||
assert rows[2] == ("图生视频·首帧模板", None)
|
||||
assert rows[3] == ("我的视频工作流", None)
|
||||
|
||||
|
||||
def test_broken_qwen_import_warns_and_generation_is_refused(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": _load(QWEN_EDIT)})
|
||||
assert analyzed.status_code == 200
|
||||
codes = {w["code"] for w in analyzed.json()["warnings"]}
|
||||
assert {"missing_class_type", "missing_model_link", "orphan_latent"} <= codes
|
||||
# importing is allowed (the user may fix it later) …
|
||||
wf_id = _import_workflow(client, "Qwen 图片编辑", "image", _load(QWEN_EDIT))
|
||||
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
|
||||
assert any(w["level"] == "error" for w in listed["warnings"])
|
||||
# … but it never reaches ComfyUI and never shows as done
|
||||
img = _upload(client, "门面.png")
|
||||
r = _generate(client, wf_id, {"4@prompt": "换招牌", "10": img}, source="studio-image")
|
||||
assert r.status_code == 400
|
||||
assert "重新导出" in r.json()["detail"]
|
||||
assert client.get("/api/playground/jobs").json() == []
|
||||
assert named_comfy.prompts == [] and named_comfy.upload_names == []
|
||||
|
||||
|
||||
def test_repaired_qwen_export_runs_with_its_input_image(client, named_comfy):
|
||||
wf = _load(QWEN_EDIT)
|
||||
wf["1"] = {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_2.1_int8_convrot.safetensors",
|
||||
"weight_dtype": "default",
|
||||
},
|
||||
}
|
||||
del wf["9"]
|
||||
wf["6"]["inputs"]["model"] = ["1", 0]
|
||||
with _real_run(client, named_comfy):
|
||||
named_comfy.out_name = "qwen_image_00001_.png"
|
||||
wf_id = _import_workflow(client, "Qwen 图片编辑(修复版)", "image", wf)
|
||||
img = _upload(client, "门面.png")
|
||||
r = _generate(
|
||||
client, wf_id, {"4@prompt": "换招牌", "10": img, "4@resolution": 1536, "6@steps": 20},
|
||||
source="studio-image",
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done" and job["output_paths"]
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
assert prompt["10"]["inputs"]["image"] == f"calliope/{Path(img).name}"
|
||||
assert prompt["4"]["inputs"]["images.image_1"] == ["10", 0]
|
||||
assert prompt["4"]["inputs"]["resolution"] == 1536
|
||||
assert prompt["6"]["inputs"]["steps"] == 20
|
||||
|
||||
|
||||
def test_worker_refuses_broken_workflow_from_other_callers(client, named_comfy):
|
||||
"""Workbench/agent jobs skip the playground check — the worker still stops them."""
|
||||
from calliope.db import get_db
|
||||
from calliope.queue.manager import queue_manager
|
||||
from calliope.routers.playground import ensure_playground_project
|
||||
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "Qwen 图片编辑", "image", _load(QWEN_EDIT))
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
job = queue_manager.enqueue(
|
||||
project_id=pid, kind="image", workflow_id=wf_id, scene_id=None,
|
||||
payload={"input_values": {"4@prompt": "x"}, "source": "playground"},
|
||||
)
|
||||
done = _wait_done(client, job["id"])
|
||||
assert done["status"] == "failed"
|
||||
assert "重新导出" in (done["error"] or "")
|
||||
assert named_comfy.prompts == []
|
||||
@@ -216,6 +216,18 @@ def test_settings_accepts_agent_llm_assignments(client):
|
||||
# Unknown role keys are dropped, known roles stored (incl. explicit None)
|
||||
assert body["agent_llm_assignments"] == {"video": a_id, "story": None}
|
||||
|
||||
# HEL-619: updates are per-role incremental — a partial payload must
|
||||
# not wipe roles it does not mention (rapid-switch race).
|
||||
r = client.post(
|
||||
"/api/settings",
|
||||
json={"agent_llm_assignments": {"main": a_id}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["agent_llm_assignments"]["main"] == a_id
|
||||
assert body["agent_llm_assignments"]["video"] == a_id
|
||||
assert body["agent_llm_assignments"]["story"] is None
|
||||
|
||||
r = client.post(
|
||||
"/api/settings",
|
||||
json={"agent_llm_assignments": {"video": "no-such-profile"}},
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""媒体组泛化(HEL-643 批次 3):参考视频/参考音频按 JSON 实际连线进入
|
||||
input_schema 与提交流程;无连线则无入口;05_ref2va 只保留参考图。
|
||||
|
||||
视频/音频参考的真实 H3 JSON 暂无可执行样本(.15 无 ComfyUI),本套用
|
||||
自造 fixture + fake ComfyUI 验证解析/表单/回填/上传全链路(模拟链路)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import PNG_1PX, _real_run
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uploads_dir(client):
|
||||
d = settings.assets_dir / "uploads"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _load_05() -> dict:
|
||||
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _ref2va_workflow(*, images=1, videos=1, audios=1) -> dict:
|
||||
"""H3 Ref2VA 形状的自造 JSON:图/视频/音频各一组 Autogrow 连线。"""
|
||||
wf: dict = {
|
||||
"1": {
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"inputs": {"value": "提示词"},
|
||||
},
|
||||
"92": {
|
||||
"class_type": "SaveVideo",
|
||||
"inputs": {"video": ["131", 0]},
|
||||
},
|
||||
"131": {
|
||||
"class_type": "MiniMaxH3ReferenceToVideo",
|
||||
"inputs": {"prompt": ["1", 0]},
|
||||
},
|
||||
"901": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
|
||||
"902": {"class_type": "LoadVideo", "inputs": {"file": "ref.mp4", "force_rate": 0}},
|
||||
"903": {"class_type": "LoadAudio", "inputs": {"audio": "ref.wav"}},
|
||||
}
|
||||
ins = wf["131"]["inputs"]
|
||||
for i in range(images):
|
||||
ins[f"ref_images.ref_image_{i}"] = ["901", 0]
|
||||
for i in range(videos):
|
||||
ins[f"ref_videos.ref_video_{i}"] = ["902", 0]
|
||||
for i in range(audios):
|
||||
ins[f"ref_audios.ref_audio_{i}"] = ["903", 0]
|
||||
return wf
|
||||
|
||||
|
||||
def _entry(schema: list[dict], key_suffix: str) -> dict | None:
|
||||
for e in schema:
|
||||
if e["nodeId"].endswith("@" + key_suffix):
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema: 入口只由实际连线决定
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ref2va_schema_exposes_three_media_groups():
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow())
|
||||
img = _entry(schema, "ref_images")
|
||||
vid = _entry(schema, "ref_videos")
|
||||
aud = _entry(schema, "ref_audios")
|
||||
assert img and img["kind"] == "image_list" and img["role"] == "reference_image"
|
||||
assert img["max"] == 9 and img["label"] == "参考图"
|
||||
assert vid and vid["kind"] == "video_list" and vid["role"] == "reference_video"
|
||||
assert vid["max"] == 3 and vid["label"] == "参考视频"
|
||||
assert aud and aud["kind"] == "audio_list" and aud["role"] == "reference_audio"
|
||||
assert aud["max"] == 3 and aud["label"] == "参考音频"
|
||||
# 官方限额提示按节点类名驱动(通用检测不携带 hint)
|
||||
assert img["media_hint"] == vid["media_hint"] == aud["media_hint"] == "minimax_h3_ref2va"
|
||||
# 被组吸收的 loader 不再单独出现
|
||||
assert not any(e["nodeId"] == "902" for e in schema)
|
||||
assert not any(e["nodeId"] == "903" for e in schema)
|
||||
|
||||
|
||||
def test_unwired_media_kinds_produce_no_entry():
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow(videos=0, audios=0))
|
||||
assert _entry(schema, "ref_images") is not None
|
||||
assert _entry(schema, "ref_videos") is None
|
||||
assert _entry(schema, "ref_audios") is None
|
||||
|
||||
|
||||
def test_generic_class_gets_group_without_hint():
|
||||
wf = _ref2va_workflow(videos=0, audios=0)
|
||||
wf["20"] = {"class_type": "GenericRefNode", "inputs": {}}
|
||||
wf["701"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "a.mp4"}}
|
||||
wf["702"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "b.mp4"}}
|
||||
wf["20"]["inputs"]["media.clip_0"] = ["701", 0]
|
||||
wf["20"]["inputs"]["media.clip_1"] = ["702", 0]
|
||||
schema = parse_dynamic_inputs(wf)
|
||||
g = _entry(schema, "media")
|
||||
assert g and g["kind"] == "video_list" and g["max"] == 9
|
||||
assert "media_hint" not in g
|
||||
|
||||
|
||||
def test_05_ref2va_fixture_stays_image_only():
|
||||
schema = parse_dynamic_inputs(_load_05())
|
||||
lists = [e for e in schema if e["kind"].endswith("_list")]
|
||||
assert len(lists) == 1
|
||||
assert lists[0]["kind"] == "image_list" and lists[0]["max"] == 9
|
||||
assert lists[0]["nodeId"] == "131@ref_images"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _media_file(path: str, data: bytes) -> str:
|
||||
p = Path(path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(data)
|
||||
return str(p)
|
||||
|
||||
|
||||
def test_validate_video_and_audio_lists(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow())
|
||||
img_key = _entry(schema, "ref_images")["nodeId"]
|
||||
vid_key = _entry(schema, "ref_videos")["nodeId"]
|
||||
aud_key = _entry(schema, "ref_audios")["nodeId"]
|
||||
png = _media_file(str(uploads_dir / "ok.png"), PNG_1PX)
|
||||
mp4 = _media_file(str(uploads_dir / "ok.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "ok.mp3"), b"00")
|
||||
|
||||
clean, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == [] and clean[vid_key] == [mp4] and clean[aud_key] == [mp3]
|
||||
|
||||
# 错误格式(图片文件填视频槽 / 视频文件填音频槽)
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [png], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["参考视频 1:格式不支持,请使用 MP4 / WebM / MOV"]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp4]}
|
||||
)
|
||||
assert errs == ["参考音频 1:格式不支持,请使用 MP3 / WAV / M4A"]
|
||||
|
||||
# 上限(H3 官方 3 段)与最少 1 段
|
||||
four = [_media_file(str(uploads_dir / f"v{i}.mp4"), b"00") for i in range(4)]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: four, aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["参考视频最多 3 段(当前 4 段),请删除多余的视频"]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["需要至少 1 段参考视频"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patcher + full chain against fake ComfyUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patcher_clones_and_backfills_each_media_kind(uploads_dir):
|
||||
wf = _ref2va_workflow()
|
||||
schema = parse_dynamic_inputs(wf)
|
||||
pngs = [_media_file(str(uploads_dir / "r1.png"), PNG_1PX),
|
||||
_media_file(str(uploads_dir / "r2.png"), PNG_1PX)]
|
||||
mp4 = _media_file(str(uploads_dir / "v1.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "a1.mp3"), b"00")
|
||||
patched = patch_workflow(
|
||||
wf,
|
||||
{
|
||||
_entry(schema, "ref_images")["nodeId"]: pngs,
|
||||
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
||||
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
||||
},
|
||||
)
|
||||
ins = patched["131"]["inputs"]
|
||||
image_links = [v for k, v in ins.items() if k.startswith("ref_images.")]
|
||||
video_links = [v for k, v in ins.items() if k.startswith("ref_videos.")]
|
||||
audio_links = [v for k, v in ins.items() if k.startswith("ref_audios.")]
|
||||
assert len(image_links) == 2 and len(video_links) == 1 and len(audio_links) == 1
|
||||
loaders = {nid: n for nid, n in patched.items()
|
||||
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")}
|
||||
imgs = [n for n in loaders.values() if n["class_type"] == "LoadImage"]
|
||||
vids = [n for n in loaders.values() if n["class_type"] == "LoadVideo"]
|
||||
auds = [n for n in loaders.values() if n["class_type"] == "LoadAudio"]
|
||||
assert len(imgs) == 2 and all(n["inputs"]["image"] in pngs for n in imgs)
|
||||
assert vids and vids[0]["inputs"]["file"] == mp4
|
||||
assert auds and auds[0]["inputs"]["audio"] == mp3
|
||||
# 克隆出的节点带中文槽位标题
|
||||
assert {n["_meta"]["title"] for n in imgs} == {"参考图 1", "参考图 2"}
|
||||
assert vids[0]["_meta"]["title"] == "参考视频 1"
|
||||
assert auds[0]["_meta"]["title"] == "参考音频 1"
|
||||
|
||||
|
||||
def test_full_chain_uploads_and_backfills_media_groups(client, fake_comfy, uploads_dir):
|
||||
"""提交 → 上传 → 回填:fake ComfyUI 收到的 prompt 里每类 loader 都
|
||||
指向已上传文件(模拟链路:fake 端点,非真机 H3)。"""
|
||||
wf = _ref2va_workflow()
|
||||
png = _media_file(str(uploads_dir / "ref.png"), PNG_1PX)
|
||||
mp4 = _media_file(str(uploads_dir / "ref.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "ref.mp3"), b"00")
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "媒体组链路", "workflow_json": wf, "usage": "ref2video"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf_id = r.json()["id"]
|
||||
schema = r.json()["input_schema"]
|
||||
|
||||
with _real_run(client, fake_comfy):
|
||||
fake_comfy.out_name = "media_out.mp4"
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test",
|
||||
json={
|
||||
"input_values": {
|
||||
_entry(schema, "ref_images")["nodeId"]: [png],
|
||||
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
||||
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job_id = r.json()["job"]["id"]
|
||||
|
||||
deadline = time.time() + 25
|
||||
job = {}
|
||||
while time.time() < deadline:
|
||||
job = client.get(f"/api/jobs/{job_id}").json()
|
||||
if job["status"] not in ("pending", "running"):
|
||||
break
|
||||
time.sleep(0.4)
|
||||
assert job["status"] == "done", job.get("error")
|
||||
|
||||
assert len(fake_comfy.uploads) >= 3 # 图、视频、音频各一次
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
loaders = [n for n in queued.values()
|
||||
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")]
|
||||
by_field = {
|
||||
(n["class_type"], k): v
|
||||
for n in loaders
|
||||
for k, v in n["inputs"].items()
|
||||
if isinstance(v, str) and "uploaded-input" in v
|
||||
}
|
||||
assert ("LoadImage", "image") in by_field
|
||||
assert ("LoadVideo", "file") in by_field
|
||||
assert ("LoadAudio", "audio") in by_field
|
||||
# 组连线数与提交数一致
|
||||
ins = queued["131"]["inputs"]
|
||||
assert len([k for k in ins if k.startswith("ref_images.")]) == 1
|
||||
assert len([k for k in ins if k.startswith("ref_videos.")]) == 1
|
||||
assert len([k for k in ins if k.startswith("ref_audios.")]) == 1
|
||||
@@ -0,0 +1,411 @@
|
||||
"""HEL-627: the two workflows the user runs (Qwen Image 2.1 文生图 and
|
||||
MiniMax H3 reference 文生视频) expose every widget they really have, the
|
||||
values land in the /prompt payload, and a job is only "done" once ComfyUI
|
||||
returned real files — empty, failed, lost and placeholder runs never are."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.parser import (
|
||||
RESOLUTION_ASPECT_RATIOS,
|
||||
effective_input_schema,
|
||||
parse_dynamic_inputs,
|
||||
resolution_selector_size,
|
||||
)
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db
|
||||
from tests.test_studio_generation import (
|
||||
_FakeComfyHandler,
|
||||
_import_workflow,
|
||||
_real_run,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
T2I = "wen_sheng_tu_API.json" # image_qwen_image_2_1_t2i (2).json
|
||||
T2V = "wen_sheng_shipin_API.json" # 01_reference_4step_sla (1).json
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _by_role(inputs: list[dict]) -> dict[str, dict]:
|
||||
return {i["role"]: i for i in inputs if i.get("role")}
|
||||
|
||||
|
||||
class _StatusHandler(_FakeComfyHandler):
|
||||
"""Fake ComfyUI with /queue and scriptable history outcomes."""
|
||||
|
||||
def do_GET(self):
|
||||
srv = self.server
|
||||
if self.path == "/queue":
|
||||
if srv.queue_mode is None:
|
||||
self._json(404, {"error": "not found"})
|
||||
return
|
||||
self.server.requests.append(self.path)
|
||||
items = [[i, pid, {}, {}, []] for i, pid in enumerate(srv.history)]
|
||||
if srv.queue_mode == "running":
|
||||
self._json(200, {"queue_running": items, "queue_pending": []})
|
||||
elif srv.queue_mode == "pending":
|
||||
blocker = [[-1, "someone-else", {}, {}, []]]
|
||||
self._json(200, {"queue_running": blocker, "queue_pending": items})
|
||||
else:
|
||||
self._json(200, {"queue_running": [], "queue_pending": []})
|
||||
return
|
||||
if self.path.startswith("/history/"):
|
||||
if srv.lose_history or srv.hidden_history_polls > 0:
|
||||
srv.hidden_history_polls = max(0, srv.hidden_history_polls - 1)
|
||||
self.server.requests.append(self.path)
|
||||
self._json(200, {})
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
super().do_POST()
|
||||
if self.path == "/prompt" and self.server.history_patch:
|
||||
for entry in self.server.history.values():
|
||||
entry.update(self.server.history_patch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def status_comfy():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _StatusHandler)
|
||||
server.requests = []
|
||||
server.prompts = []
|
||||
server.uploads = []
|
||||
server.history = {}
|
||||
server.fail_prompt = False
|
||||
server.out_name = "out-1.png"
|
||||
server.outputs_key = "images"
|
||||
server.queue_mode = None
|
||||
server.lose_history = False
|
||||
server.hidden_history_polls = 0
|
||||
server.history_patch = None
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _wait_final(client, job_id: int, timeout: float = 25.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
for j in client.get("/api/playground/jobs").json():
|
||||
if j["id"] == job_id and j["status"] in ("done", "failed"):
|
||||
return j
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"job {job_id} did not finish")
|
||||
|
||||
|
||||
def _generate(client, wf_id: int, values: dict, source: str = "studio-image"):
|
||||
return client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": values,
|
||||
"source": source,
|
||||
"endpoint_id": settings.comfyui_active_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parameter detection on the two real workflows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_real_t2i_exposes_ratio_megapixels_steps_denoise():
|
||||
roles = _by_role(parse_dynamic_inputs(_load(T2I)))
|
||||
assert set(roles) == {
|
||||
"prompt", "negative", "aspect_ratio", "megapixels", "batch",
|
||||
"seed", "steps", "cfg", "denoise",
|
||||
}
|
||||
ratio = roles["aspect_ratio"]
|
||||
assert ratio["nodeId"] == "13@aspect_ratio"
|
||||
assert ratio["kind"] == "select"
|
||||
assert ratio["defaultValue"] == "1:1 (Square)"
|
||||
assert ratio["options"] == list(RESOLUTION_ASPECT_RATIOS)
|
||||
assert ratio["resolution"] == {"megapixels": "13@megapixels", "multiple": 8}
|
||||
assert roles["megapixels"]["defaultValue"] == 1
|
||||
assert roles["steps"]["nodeId"] == "459:458@steps"
|
||||
assert roles["steps"]["defaultValue"] == 25 and roles["steps"]["integer"] is True
|
||||
assert roles["denoise"]["nodeId"] == "459:458@denoise"
|
||||
assert (roles["denoise"]["min"], roles["denoise"]["max"]) == (0.0, 1.0)
|
||||
# Width/height are wired from ResolutionSelector; they are not faked as
|
||||
# free inputs, and the encoder's reference-image `resolution` is not exposed.
|
||||
assert "width" not in roles and "height" not in roles
|
||||
assert all("resolution" not in i["nodeId"] for i in parse_dynamic_inputs(_load(T2I)))
|
||||
|
||||
|
||||
def test_real_t2v_exposes_ratio_steps_denoise_seed_duration():
|
||||
inputs = parse_dynamic_inputs(_load(T2V))
|
||||
roles = _by_role(inputs)
|
||||
assert set(roles) == {
|
||||
"prompt", "aspect_ratio", "megapixels", "steps", "denoise", "seed", "duration",
|
||||
}
|
||||
assert roles["aspect_ratio"]["defaultValue"] == "16:9 (Widescreen)"
|
||||
assert roles["aspect_ratio"]["resolution"]["multiple"] == 32
|
||||
assert roles["megapixels"]["defaultValue"] == 0.7
|
||||
assert roles["steps"]["nodeId"] == "124@steps" and roles["steps"]["defaultValue"] == 4
|
||||
assert roles["seed"]["nodeId"] == "129@noise_seed"
|
||||
assert roles["duration"]["nodeId"] == "133" and roles["duration"]["defaultValue"] == 10
|
||||
# BasicGuider has no CFG; the frame expression hard-codes 24 fps, so no fps field.
|
||||
assert "cfg" not in roles and "fps" not in roles
|
||||
|
||||
|
||||
def test_resolution_matches_comfyui_resolution_selector():
|
||||
# The H3 workflow's own title documents 1152x640 for 16:9 · 0.7 MP · ×32.
|
||||
assert resolution_selector_size("16:9 (Widescreen)", 0.7, 32) == (1152, 640)
|
||||
assert resolution_selector_size("1:1 (Square)", 1, 8) == (1024, 1024)
|
||||
assert resolution_selector_size("9:16 (Portrait Widescreen)", 1, 8) == (768, 1368)
|
||||
|
||||
|
||||
def test_validation_rejects_bad_values_in_chinese():
|
||||
schema = parse_dynamic_inputs(_load(T2I))
|
||||
clean, errors = validate_input_values(
|
||||
schema,
|
||||
{
|
||||
"459:452@prompt": "猫",
|
||||
"13@aspect_ratio": "5:4",
|
||||
"459:458@steps": 12.5,
|
||||
"459:458@denoise": 1.5,
|
||||
"13@megapixels": "abc",
|
||||
},
|
||||
)
|
||||
assert any("画面比例" in e and "5:4" in e for e in errors)
|
||||
assert any("采样步数" in e and "整数" in e for e in errors)
|
||||
assert any("降噪强度" in e and "不能大于 1" in e for e in errors)
|
||||
assert any("总像素" in e and "数字" in e for e in errors)
|
||||
clean, errors = validate_input_values(schema, {"459:458@steps": "30", "13@megapixels": 2})
|
||||
assert errors == [] # an omitted prompt falls back to the workflow's own text
|
||||
assert clean["459:458@steps"] == 30 and isinstance(clean["459:458@steps"], int)
|
||||
|
||||
blank = [dict(e, defaultValue="") if e.get("role") == "prompt" else e for e in schema]
|
||||
_, errors = validate_input_values(blank, {"459:452@prompt": " "})
|
||||
assert errors == ["请填写「正向提示词」"]
|
||||
|
||||
|
||||
def test_stale_auto_schema_is_refreshed_without_reimport(client):
|
||||
wf = _load(T2I)
|
||||
stale = [
|
||||
{"nodeId": "459:452@prompt", "label": "正向提示词", "role": "prompt",
|
||||
"kind": "textarea", "defaultValue": "x", "required": True, "auto": True},
|
||||
]
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO workflows"
|
||||
" (name, kind, workflow_json, input_schema, output_schema, is_enabled)"
|
||||
" VALUES (?, 'image', ?, ?, '[]', 1)",
|
||||
("旧导入·Qwen2.1", json.dumps(wf), json.dumps(stale)),
|
||||
)
|
||||
conn.commit()
|
||||
wf_id = cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
roles = _by_role(client.get(f"/api/workflows/{wf_id}").json()["input_schema"])
|
||||
assert {"aspect_ratio", "megapixels", "steps", "denoise"} <= set(roles)
|
||||
|
||||
|
||||
def test_tagged_schema_is_kept_as_stored():
|
||||
stored = [{"nodeId": "6", "label": "P", "role": "prompt", "kind": "textarea",
|
||||
"required": True}]
|
||||
assert effective_input_schema(stored, _load(T2I)) == stored
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Submit payload + job status against a fake ComfyUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_t2i_values_reach_the_prompt_payload(client, status_comfy):
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(参数)", "image", _load(T2I))
|
||||
r = _generate(
|
||||
client,
|
||||
wf_id,
|
||||
{
|
||||
"459:452@prompt": "雨夜霓虹街道",
|
||||
"459:452@negative_prompt": "模糊",
|
||||
"13@aspect_ratio": "16:9 (Widescreen)",
|
||||
"13@megapixels": 2,
|
||||
"459:458@steps": "30",
|
||||
"459:458@cfg": 2.5,
|
||||
"459:458@denoise": 0.8,
|
||||
"459:458@seed": 7,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_final(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
queued = status_comfy.prompts[-1]["prompt"]
|
||||
assert queued["13"]["inputs"]["aspect_ratio"] == "16:9 (Widescreen)"
|
||||
assert queued["13"]["inputs"]["megapixels"] == 2
|
||||
assert queued["459:458"]["inputs"]["steps"] == 30
|
||||
assert queued["459:458"]["inputs"]["cfg"] == 2.5
|
||||
assert queued["459:458"]["inputs"]["denoise"] == 0.8
|
||||
assert queued["459:458"]["inputs"]["seed"] == 7
|
||||
assert queued["459:452"]["inputs"]["prompt"] == "雨夜霓虹街道"
|
||||
# Wiring intact: the latent still takes its size from the selector.
|
||||
assert queued["459:456"]["inputs"]["width"] == ["13", 0]
|
||||
assert queued["459:456"]["inputs"]["height"] == ["13", 1]
|
||||
assert job["comfy_prompt_id"] in status_comfy.history
|
||||
assert job["phase"] == "finished"
|
||||
assert job["output_paths"] and Path(job["output_paths"][0]).stat().st_size > 0
|
||||
|
||||
|
||||
def test_t2v_values_reach_the_prompt_payload(client, status_comfy):
|
||||
status_comfy.outputs_key = "images" # SaveVideo reports under "images" + animated
|
||||
status_comfy.out_name = "clip.mp4"
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·H3 参考生视频", "video", _load(T2V))
|
||||
r = _generate(
|
||||
client,
|
||||
wf_id,
|
||||
{
|
||||
"1": "地铁车厢里的长镜头",
|
||||
"115@aspect_ratio": "9:16 (Portrait Widescreen)",
|
||||
"115@megapixels": 0.5,
|
||||
"124@steps": 6,
|
||||
"124@denoise": 1,
|
||||
"129@noise_seed": 1234,
|
||||
"133": 5,
|
||||
},
|
||||
source="studio-video",
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_final(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
queued = status_comfy.prompts[-1]["prompt"]
|
||||
assert queued["1"]["inputs"]["value"] == "地铁车厢里的长镜头"
|
||||
assert queued["115"]["inputs"]["aspect_ratio"] == "9:16 (Portrait Widescreen)"
|
||||
assert queued["115"]["inputs"]["megapixels"] == 0.5
|
||||
assert queued["124"]["inputs"]["steps"] == 6
|
||||
assert queued["129"]["inputs"]["noise_seed"] == 1234
|
||||
assert queued["133"]["inputs"]["value"] == 5
|
||||
assert queued["132"]["inputs"]["values.a"] == ["133", 0]
|
||||
|
||||
|
||||
def test_invalid_values_are_rejected_before_queueing(client, status_comfy):
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(校验)", "image", _load(T2I))
|
||||
r = _generate(client, wf_id, {"459:452@prompt": "猫", "459:458@steps": 0})
|
||||
assert r.status_code == 400
|
||||
assert "采样步数" in r.json()["detail"] and "不能小于 1" in r.json()["detail"]
|
||||
r = _generate(client, wf_id, {"459:452@prompt": "猫", "13@megapixels": 99})
|
||||
assert r.status_code == 400 and "总像素" in r.json()["detail"]
|
||||
assert "/prompt" not in status_comfy.requests
|
||||
|
||||
|
||||
def test_empty_outputs_never_count_as_done(client, status_comfy):
|
||||
status_comfy.history_patch = {"outputs": {}}
|
||||
with _real_run(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 job["comfy_prompt_id"] and job["comfy_prompt_id"] in job["error"]
|
||||
assert job["output_paths"] == []
|
||||
|
||||
|
||||
def test_comfy_execution_error_is_failed_with_reason(client, status_comfy):
|
||||
status_comfy.history_patch = {
|
||||
"status": {
|
||||
"status_str": "error",
|
||||
"completed": False,
|
||||
"messages": [
|
||||
["execution_start", {}],
|
||||
["execution_error", {"node_type": "UNETLoader",
|
||||
"exception_message": "模型文件不存在"}],
|
||||
],
|
||||
},
|
||||
"outputs": {},
|
||||
}
|
||||
with _real_run(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 "ComfyUI 执行出错" in job["error"] and "UNETLoader" in job["error"]
|
||||
assert "模型文件不存在" in job["error"]
|
||||
|
||||
|
||||
def test_prompt_lost_from_queue_and_history_fails(client, status_comfy):
|
||||
status_comfy.lose_history = True
|
||||
status_comfy.queue_mode = "missing"
|
||||
with _real_run(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"]
|
||||
|
||||
|
||||
def test_running_then_finished_reports_real_phase(client, status_comfy):
|
||||
status_comfy.queue_mode = "running"
|
||||
status_comfy.hidden_history_polls = 3
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(执行中)", "image", _load(T2I))
|
||||
job_id = _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"]
|
||||
seen: set[str] = set()
|
||||
deadline = time.time() + 20
|
||||
while time.time() < deadline:
|
||||
row = next(j for j in client.get("/api/playground/jobs").json() if j["id"] == job_id)
|
||||
if row.get("phase"):
|
||||
seen.add(row["phase"])
|
||||
if row["status"] in ("done", "failed"):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
assert row["status"] == "done", row.get("error")
|
||||
assert "comfy_running" in seen
|
||||
assert "/queue" in status_comfy.requests
|
||||
|
||||
|
||||
def test_missing_model_rejection_names_the_file():
|
||||
"""Body captured from the user's ComfyUI 0.37.4 when Qwen 2.1 models are absent."""
|
||||
import httpx
|
||||
|
||||
from calliope.comfyui.client import _surface_error
|
||||
|
||||
body = {
|
||||
"error": {"type": "prompt_outputs_failed_validation"},
|
||||
"node_errors": {
|
||||
"459:451": {
|
||||
"errors": [{
|
||||
"type": "value_not_in_list",
|
||||
"message": "Value not in list",
|
||||
"details": "unet_name: 'qwen_image_2.1_int8_convrot.safetensors' not in [...]",
|
||||
"extra_info": {
|
||||
"input_name": "unet_name",
|
||||
"received_value": "qwen_image_2.1_int8_convrot.safetensors",
|
||||
},
|
||||
}],
|
||||
"class_type": "UNETLoader",
|
||||
}
|
||||
},
|
||||
}
|
||||
msg = str(_surface_error("ComfyUI 拒绝执行该工作流", httpx.Response(400, json=body)))
|
||||
assert "节点 459:451(UNETLoader)" in msg
|
||||
assert "qwen_image_2.1_int8_convrot.safetensors" in msg and "模型文件未安装" in msg
|
||||
|
||||
|
||||
def test_dry_run_result_is_marked_placeholder(client):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(dry)", "image", _load(T2I))
|
||||
prev = settings.dry_run
|
||||
settings.dry_run = True
|
||||
try:
|
||||
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
|
||||
finally:
|
||||
settings.dry_run = prev
|
||||
assert job["status"] == "done"
|
||||
assert job["phase"] == "dry_run"
|
||||
assert job["comfy_prompt_id"] is None
|
||||
@@ -13,6 +13,7 @@ The shell is default-OFF; every guard branch here is a security contract:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -25,6 +26,23 @@ from calliope.agent.harness.registry import ToolContext
|
||||
PYTHON = Path(sys.executable).stem # bare name ("python") — the tool refuses paths
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def _interpreter_on_path():
|
||||
"""Make the bare interpreter name resolvable via PATH (HEL-619).
|
||||
|
||||
The guard resolves the command with ``shutil.which`` against PATH —
|
||||
running pytest as ``path/to/.venv/bin/python -m pytest`` (or on a box
|
||||
with no ``python`` alias) leaves the stem unresolvable and every
|
||||
exec-path test fails for environmental reasons only. Prepending the
|
||||
interpreter's own bin dir (restored afterwards) pins the lookup to the
|
||||
interpreter actually running the suite, on every OS.
|
||||
"""
|
||||
prev = os.environ.get("PATH", "")
|
||||
os.environ["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{prev}"
|
||||
yield
|
||||
os.environ["PATH"] = prev
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def _never_touch_real_db():
|
||||
"""Redirect data_dir before ANY write in this module (repo test rule)."""
|
||||
@@ -210,12 +228,19 @@ def test_structured_shell_approval_allows(enabled):
|
||||
|
||||
|
||||
def test_cwd_outside_workspace_denied(enabled):
|
||||
"""Guard denial tests use PYTHON + a structured approval card (not
|
||||
ffprobe): the interpreter resolves on every machine, and the card
|
||||
clears the approval gate so the LATER containment check is what fires
|
||||
(HEL-619 — ffprobe-absent boxes used to fail these for environmental
|
||||
reasons)."""
|
||||
registry, _ = build_harness()
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "cwd": str(Path(settings_data_dir_home()))},
|
||||
{"command": PYTHON, "cwd": str(settings_data_dir_home())},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_outside_workspace"
|
||||
@@ -230,11 +255,13 @@ def test_sensitive_arg_denied(enabled, tmp_path):
|
||||
registry, _ = build_harness()
|
||||
secret = tmp_path / "calliope_config.json"
|
||||
secret.write_text("{}", encoding="utf-8")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": [str(secret)]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", str(secret)]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_denied_path"
|
||||
@@ -244,11 +271,13 @@ def test_db_arg_denied(enabled, tmp_path):
|
||||
registry, _ = build_harness()
|
||||
db = tmp_path / "calliope.db"
|
||||
db.write_bytes(b"")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": [str(db)]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", str(db)]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_denied_path"
|
||||
@@ -256,11 +285,13 @@ def test_db_arg_denied(enabled, tmp_path):
|
||||
|
||||
def test_env_file_arg_denied(enabled):
|
||||
registry, _ = build_harness()
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": ["--", ".env"]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", "--", ".env"]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_denied_path"
|
||||
@@ -270,11 +301,13 @@ def test_outside_absolute_arg_denied(enabled, tmp_path):
|
||||
registry, _ = build_harness()
|
||||
outside = tmp_path / "movie.mp4"
|
||||
outside.write_bytes(b"00")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": [str(outside)]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", str(outside)]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_outside_workspace"
|
||||
@@ -287,16 +320,22 @@ def test_inside_asset_arg_allowed(enabled):
|
||||
registry, _ = build_harness()
|
||||
ref = settings.assets_dir / "ref.mp4"
|
||||
ref.write_bytes(b"00")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": ["-v", "error", str(ref)]},
|
||||
{
|
||||
# The asset path is a real argv entry the guard must allow
|
||||
# (and the interpreter actually reads it).
|
||||
"command": PYTHON,
|
||||
"args": ["-c", "import sys; print(open(sys.argv[1]).read())", str(ref)],
|
||||
},
|
||||
)
|
||||
)
|
||||
# Not a real mp4 — ffprobe fails, but the GUARD must not have denied it.
|
||||
assert out.get("reason_code") is None
|
||||
assert out.get("exit_code") != 0 or out.get("ok") is False or out["ok"] is True
|
||||
assert out["ok"] is True, out
|
||||
assert "00" in out["stdout"]
|
||||
|
||||
|
||||
def test_workspace_relative_cwd_allowed(enabled, tmp_path):
|
||||
|
||||
@@ -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()
|
||||
@@ -25,9 +25,19 @@ PNG_1PX = bytes.fromhex(
|
||||
|
||||
|
||||
class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
"""Minimal ComfyUI: /system_stats, /prompt, /history/{id}, /view, /upload/image."""
|
||||
"""Minimal ComfyUI: /system_stats, /prompt, /history/{id}, /view, /upload/image.
|
||||
|
||||
History outputs are keyed by the prompt's real output node id (what
|
||||
ComfyUI does); a known output class that is absent falls back to
|
||||
``server.forced_output_node`` (default "9"). Scripted tests can pin
|
||||
``server.forced_output_node`` or drop entries via ``server.drop_outputs``.
|
||||
"""
|
||||
|
||||
server_version = "FakeComfy/1.0"
|
||||
KNOWN_OUTPUT_CLASSES = {
|
||||
"SaveImage", "PreviewImage", "SaveImageAdvanced", "SaveImageWebsocket",
|
||||
"SaveVideo", "VideoOutput", "VHS_VideoCombine", "AnimateDiffCombine",
|
||||
}
|
||||
|
||||
def do_GET(self):
|
||||
self.server.requests.append(self.path)
|
||||
@@ -40,6 +50,9 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
},
|
||||
)
|
||||
return
|
||||
if self.path == "/object_info":
|
||||
self._json(200, dict(getattr(self.server, "object_info", None) or {}))
|
||||
return
|
||||
if self.path.startswith("/history/"):
|
||||
pid = self.path[len("/history/") :]
|
||||
entry = self.server.history.get(pid)
|
||||
@@ -64,11 +77,17 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
prompt_id = uuid.uuid4().hex
|
||||
outputs_key = self.server.outputs_key
|
||||
out_entry = {
|
||||
outputs_key: [{"filename": self.server.out_name, "subfolder": "", "type": "output"}]
|
||||
outputs_key: [
|
||||
{"filename": self.server.out_name, "subfolder": "", "type": "output"}
|
||||
]
|
||||
}
|
||||
outputs = (
|
||||
{} if getattr(self.server, "drop_outputs", False) else
|
||||
{self._output_node(body): out_entry}
|
||||
)
|
||||
self.server.history[prompt_id] = {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": {"9": out_entry},
|
||||
"outputs": outputs,
|
||||
"prompt": [],
|
||||
}
|
||||
self.server.prompts.append(json.loads(body.decode("utf-8") or "{}"))
|
||||
@@ -80,6 +99,19 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def _output_node(self, body: bytes) -> str:
|
||||
forced = getattr(self.server, "forced_output_node", None)
|
||||
if forced:
|
||||
return forced
|
||||
try:
|
||||
prompt = json.loads(body.decode("utf-8") or "{}").get("prompt") or {}
|
||||
except json.JSONDecodeError:
|
||||
prompt = {}
|
||||
for nid, node in prompt.items():
|
||||
if isinstance(node, dict) and node.get("class_type") in self.KNOWN_OUTPUT_CLASSES:
|
||||
return str(nid)
|
||||
return "9"
|
||||
|
||||
def _json(self, status: int, payload: dict) -> None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
@@ -109,6 +141,9 @@ def fake_comfy():
|
||||
server.fail_prompt = False
|
||||
server.out_name = "out-1.png"
|
||||
server.outputs_key = "images"
|
||||
server.forced_output_node = None
|
||||
server.object_info = {}
|
||||
server.drop_outputs = False
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,371 @@
|
||||
"""HEL-638: unknown custom output nodes close the loop — no name-based
|
||||
auto-typing, feature-less sinks become mappable candidates, a usage choice
|
||||
alone never bypasses the output designation, and generation harvests (and
|
||||
checks) exactly the nodes the user mapped.
|
||||
|
||||
Two brand-new class names drive the cases:
|
||||
|
||||
* ``NebulaGlaxVideoVault`` — screams "video" in its name and carries a
|
||||
filename_prefix; before HEL-638 the fuzzy name match auto-typed it video
|
||||
and enabled the workflow.
|
||||
* ``QuarkMuffinDisplay`` — no save widget, no media link name; before
|
||||
HEL-638 such a graph only reported ``no_output`` with no way to map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, migrate_db
|
||||
from tests.test_studio_generation import _real_run, _wait_done
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _vault_workflow() -> dict:
|
||||
return {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "GlaxSamplerNode",
|
||||
"inputs": {"prompt": ["1", 0], "length": 121},
|
||||
},
|
||||
"12": {
|
||||
"class_type": "NebulaGlaxVideoVault",
|
||||
"inputs": {"filename_prefix": "anim", "frames": ["7", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _muffin_workflow() -> dict:
|
||||
return {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "GlaxSamplerNode",
|
||||
"inputs": {"prompt": ["1", 0], "length": 121},
|
||||
},
|
||||
"15": {"class_type": "QuarkMuffinDisplay", "inputs": {"payload": ["7", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def _analyze(client, wf: dict) -> dict:
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": wf})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Unknown outputs stay pending — names never decide (gap 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_video_semantic_unknown_node_is_never_auto_typed(client):
|
||||
body = _analyze(client, _vault_workflow())
|
||||
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "12")
|
||||
assert entry["evidence"] == "unknown_node"
|
||||
assert entry["kind"] is None # the user picks the media type
|
||||
assert entry["suggested_kind"] == "video" # only a form default
|
||||
assert body["needs_mapping"] is True
|
||||
assert body["usage"] is None and body["usage_ambiguous"] is True
|
||||
assert "指定实际输出节点" in body["usage_problem"]
|
||||
codes = {(w["level"], w["code"]) for w in body["warnings"]}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_choosing_a_usage_alone_does_not_bypass_output_mapping(client):
|
||||
"""create/PATCH with a usage but no output map keeps the row disabled."""
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "未知·视频语义", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video", "usage_source": "user"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
|
||||
assert wf["is_enabled"] is False # usage set, outputs still unconfirmed
|
||||
assert wf["needs_mapping"] is True and not wf["output_map"]
|
||||
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
|
||||
# forcing the switch on is refused with the mapping reason
|
||||
r = client.patch(f"/api/workflows/{wf['id']}", json={"is_enabled": True})
|
||||
assert r.status_code == 400
|
||||
assert "指定" in r.json()["detail"] and "输出节点" in r.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Feature-less unknown sinks become mappable candidates (gap 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_featureless_unknown_node_is_a_candidate_not_a_hard_error(client):
|
||||
body = _analyze(client, _muffin_workflow())
|
||||
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "15")
|
||||
assert entry["evidence"] == "unknown_node" and entry["kind"] is None
|
||||
assert body["needs_mapping"] is True
|
||||
codes = {(w["level"], w["code"]) for w in body["warnings"]}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_truly_outputless_graph_still_reports_the_chinese_error(client):
|
||||
wf = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "p"}},
|
||||
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
||||
}
|
||||
body = _analyze(client, wf)
|
||||
assert body["usage"] is None and body["needs_mapping"] is False
|
||||
assert "未在图中找到输出节点" in body["usage_problem"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Manual mapping → enable → scoped generation (fake ComfyUI round trip)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manual_mapping_enables_and_extracts_from_the_mapped_node(client, fake_comfy):
|
||||
fake_comfy.outputs_key = "gifs"
|
||||
fake_comfy.out_name = "clip.mp4"
|
||||
fake_comfy.forced_output_node = "12" # the mapped node reports results
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·视频库",
|
||||
"workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}],
|
||||
"output_map_source": "user",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["is_enabled"] is True
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user"
|
||||
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "星空延时"},
|
||||
"source": "studio-video"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["output_paths"][0].endswith(".mp4")
|
||||
|
||||
|
||||
def test_mapped_node_returning_nothing_fails_for_real(client, fake_comfy):
|
||||
"""Results that only land on an unmapped node are NOT a completion."""
|
||||
fake_comfy.forced_output_node = "7" # something else reports results
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·空手而归",
|
||||
"workflow_json": _muffin_workflow(),
|
||||
"usage": "text2image",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "15", "kind": "image"}],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["is_enabled"] is True
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": r.json()["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "没有返回任何图片、视频、音频或文件" in job["error"]
|
||||
assert "15" in job["error"] # names the node it waited on
|
||||
assert job["output_paths"] == []
|
||||
|
||||
|
||||
def test_media_type_mismatch_fails_for_real(client, fake_comfy):
|
||||
"""A video mapping that only yields a png is a real failure."""
|
||||
fake_comfy.outputs_key = "images"
|
||||
fake_comfy.out_name = "still.png"
|
||||
fake_comfy.forced_output_node = "12"
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·类型不符",
|
||||
"workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}],
|
||||
},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "媒体类型与映射不符" in job["error"]
|
||||
assert job["output_paths"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Bad / cancelled mappings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invalid_mappings_are_rejected_in_chinese(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "999", "kind": "video"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "不存在" in r.json()["detail"]
|
||||
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射2", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "unsupported_media"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "媒体类型" in r.json()["detail"]
|
||||
|
||||
# a prompt/parameter node can never be an output
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射3", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "1", "kind": "video"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "不能作为输出节点" in r.json()["detail"]
|
||||
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "先好后坏", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
).json()["id"]
|
||||
r = client.patch(
|
||||
f"/api/workflows/{wid}", json={"output_map": [{"nodeId": "42", "kind": "image"}]}
|
||||
)
|
||||
assert r.status_code == 422 and "不存在" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_clearing_the_mapping_resets_to_pending(client):
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "映射后清空", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
).json()["id"]
|
||||
wf = client.get(f"/api/workflows/{wid}").json()
|
||||
assert wf["is_enabled"] is True
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"output_map": []})
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["is_enabled"] is False and not wf["output_map"]
|
||||
assert wf["usage"] == "text2video" # the usage decision survives
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Saved mappings survive reanalyze / field edits / migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_saved_mapping_survives_reanalyze_and_field_edits(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "重分析·保映射", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"name": "改名不影响映射",
|
||||
"description": "编辑其他字段"})
|
||||
wf = r.json()
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user" and wf["is_enabled"] is True
|
||||
|
||||
r = client.post(f"/api/workflows/{wid}/reanalyze")
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user"
|
||||
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
|
||||
assert wf["is_enabled"] is True
|
||||
|
||||
|
||||
def test_sql_forced_enabled_row_without_mapping_is_blocked(client):
|
||||
"""Backend-direct generation on an unconfirmed row is refused."""
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "强开", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video"},
|
||||
).json()["id"]
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute("UPDATE workflows SET is_enabled = 1 WHERE id = ?", (wid,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
assert "输出节点" in gen.json()["detail"]
|
||||
|
||||
|
||||
def test_migration_parks_unknown_output_rows_and_keeps_trusted_ones(client):
|
||||
import asyncio
|
||||
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
# Pre-HEL-638 shape: user confirmed a usage on an unknown-output
|
||||
# graph and the old PATCH re-enabled it.
|
||||
conn.execute(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
||||
" output_schema, is_enabled, usage, usage_source)"
|
||||
" VALUES (?, 'video', ?, '[]', '[]', 1, 'text2video', 'user')",
|
||||
("存量·未知输出", json.dumps(_vault_workflow())),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
asyncio.run(migrate_db(settings.db_path)) # idempotent re-run
|
||||
|
||||
wfs = {w["name"]: w for w in client.get("/api/workflows").json()}
|
||||
parked = wfs["存量·未知输出"]
|
||||
assert parked["is_enabled"] is False
|
||||
assert parked["usage"] == "text2video" # decision preserved
|
||||
assert not parked["output_map"] and parked["needs_mapping"] is True
|
||||
|
||||
# A trusted five-type import keeps working untouched.
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "存量·可信", "kind": "image",
|
||||
"workflow_json": json.loads(
|
||||
(FIXTURES / "qwen_image_t2i_API.json").read_text(encoding="utf-8")
|
||||
)},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
trusted = next(w for w in client.get("/api/workflows").json() if w["id"] == r.json()["id"])
|
||||
assert trusted["is_enabled"] is True
|
||||
assert trusted["output_map_source"] == "auto"
|
||||
assert any(e["nodeId"] == "60" and e["kind"] == "image" for e in trusted["output_map"])
|
||||
@@ -58,8 +58,9 @@ def test_native_qwen_edit_detects_image_and_mask_slots():
|
||||
assert set(media) == {"78", "95"}
|
||||
assert media["95"]["role"] == "mask"
|
||||
assert "mask" in media["95"]["label"].lower() or "遮罩" in media["95"]["label"]
|
||||
# the workflow's own image names stay valid, so the slots are optional
|
||||
assert not media["78"]["required"] and not media["95"]["required"]
|
||||
# HEL-633: a wired input image must be uploaded (the export's own file name
|
||||
# rarely exists on the target ComfyUI); the mask stays optional
|
||||
assert media["78"]["required"] and not media["95"]["required"]
|
||||
|
||||
|
||||
def test_untagged_h3_video_detects_prompt_duration_seed():
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""HEL-636 A1–A6: the five-usage classifier judges from graph evidence only.
|
||||
|
||||
Every case feeds real (or minimally mutated) ComfyUI API exports — never a
|
||||
file name, model name or fixed node id — and asserts the usage, the media
|
||||
slots it implies, and the failure paths for ambiguous / unknown graphs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.parser import (
|
||||
classify_usage,
|
||||
detect_untagged_inputs,
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
|
||||
def _load(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _rename_everything(wf: dict, id_shift: int = 7) -> dict:
|
||||
"""Change every node id, title and widget value the classifier must not
|
||||
depend on (model file names / prompt texts)."""
|
||||
def new_id(nid: str) -> str:
|
||||
return f"x{int(nid) * id_shift + 3}" if nid.isdigit() else "x" + nid.replace(":", "_")
|
||||
|
||||
out: dict = {}
|
||||
for nid, node in wf.items():
|
||||
out[new_id(nid)] = json.loads(json.dumps(node))
|
||||
for node in out.values():
|
||||
for val in (node.get("inputs") or {}).values():
|
||||
if isinstance(val, list) and len(val) == 2 and str(val[0]).isdigit():
|
||||
val[0] = new_id(str(val[0]))
|
||||
if node.get("_meta", {}).get("title"):
|
||||
node["_meta"]["title"] = f"改·{node['_meta']['title']}"
|
||||
for key, val in list((node.get("inputs") or {}).items()):
|
||||
if isinstance(val, str) and val.endswith((".safetensors", ".gguf")):
|
||||
node["inputs"][key] = "另一个模型_" + val
|
||||
return out
|
||||
|
||||
|
||||
def _media(inputs: list[dict]) -> list[dict]:
|
||||
return [
|
||||
i
|
||||
for i in inputs
|
||||
if i["kind"] in ("image", "image_list", "video", "audio")
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A1 / A2 / A3: the three real shapes, plus id/title/model renames
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a1_native_t2i_is_text2image_and_form_has_no_image_slot():
|
||||
wf = _load(FIXTURES / "qwen_image_t2i_API.json")
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "text2image"
|
||||
assert c["ambiguous"] is False and c["needs_mapping"] is False
|
||||
assert _media(parse_dynamic_inputs(wf)) == []
|
||||
outs = parse_dynamic_outputs(wf)
|
||||
assert [(o["nodeId"], o["kind"]) for o in outs] == [("60", "image")]
|
||||
|
||||
|
||||
def test_a2_qwen_edit_variant_with_new_ids_titles_and_model_stays_image2image():
|
||||
wf = _rename_everything(_load(FIXTURES / "qwen_image_edit_mask_API.json"))
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "image2image"
|
||||
inputs = parse_dynamic_inputs(wf)
|
||||
img = [i for i in _media(inputs) if i.get("role") == "image"]
|
||||
assert len(img) == 1 and img[0]["required"] is True
|
||||
mask = [i for i in _media(inputs) if i.get("role") == "mask"]
|
||||
assert len(mask) == 1 # mask never counts as an input image
|
||||
|
||||
|
||||
def test_a3_i2v_variant_renamed_still_image2video_with_optional_last_frame():
|
||||
wf = _rename_everything(_load(FIXTURES / "h3_i2v_04_API.json"))
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "image2video"
|
||||
media = _media(parse_dynamic_inputs(wf))
|
||||
# HEL-671: the FL2VA node's unwired last_frame is offered, but optional
|
||||
assert [(i.get("role"), i["required"]) for i in media] == [
|
||||
("first_frame", True),
|
||||
("last_frame", False),
|
||||
]
|
||||
assert blocking_problem(wf) is None
|
||||
|
||||
|
||||
def test_a4_ref2va_variant_with_renamed_class_and_prefix_still_groups():
|
||||
wf = _rename_everything(_load(FIXTURES / "h3_ref2va_05_API.json"))
|
||||
for node in wf.values():
|
||||
if node.get("class_type") == "MiniMaxH3ReferenceToVideo":
|
||||
node["class_type"] = "MyFancyRefToVideo"
|
||||
inputs = node["inputs"]
|
||||
for k in list(inputs):
|
||||
if k.startswith("ref_images.ref_image_"):
|
||||
inputs[k.replace("ref_images.ref_image_", "refs.pic_")] = inputs.pop(k)
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "ref2video"
|
||||
groups = [i for i in detect_untagged_inputs(wf) if i["kind"] == "image_list"]
|
||||
assert len(groups) == 1
|
||||
g = groups[0]
|
||||
assert g["role"] == "reference_image" and (g["min"], g["max"]) == (1, 9)
|
||||
# patch rewires the renamed group in order and refuses a 10th image
|
||||
key = g["nodeId"]
|
||||
node_id = key.split("@")[0]
|
||||
patched = patch_workflow(wf, {key: [f"/u/{i}.png" for i in range(4)]})
|
||||
names = sorted(
|
||||
(k for k in patched[node_id]["inputs"] if k.startswith("refs.pic_")),
|
||||
key=lambda k: int(k.rsplit("_", 1)[-1]) if k.rsplit("_", 1)[-1].isdigit() else int(k[-1]),
|
||||
)
|
||||
assert names == [f"refs.pic_{i}" for i in range(4)]
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
|
||||
_c, errs = validate_input_values(
|
||||
detect_untagged_inputs(wf), {key: [f"/u/{i}.png" for i in range(10)]}
|
||||
)
|
||||
assert any("最多 9 张" in e for e in errs)
|
||||
|
||||
|
||||
def test_qwen_single_numbered_encoder_input_is_not_mistaken_for_a_group():
|
||||
"""TextEncodeQwenImage21's ``images.image_1`` stays an ordinary input slot."""
|
||||
inputs = parse_dynamic_inputs(_load(FIXTURES / "qwen21_edit_sage_API.json"))
|
||||
assert not any(i["kind"] == "image_list" for i in inputs)
|
||||
assert any(i.get("role") == "image" for i in inputs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Judgment table rows from the real example library
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_all_real_exports_classify():
|
||||
expect = {
|
||||
"qwen_image_t2i_API.json": "text2image",
|
||||
"qwen_image_edit_mask_API.json": "image2image",
|
||||
"qwen21_edit_sage_API.json": "image2image",
|
||||
"h3_i2v_04_API.json": "image2video",
|
||||
"h3_ref2va_05_API.json": "ref2video",
|
||||
"minimax_h3_t2v_untagged_API.json": "text2video",
|
||||
"wen_sheng_tu_API.json": "text2image",
|
||||
"wen_sheng_shipin_API.json": "text2video",
|
||||
}
|
||||
for name, usage in expect.items():
|
||||
assert classify_usage(_load(FIXTURES / name))["usage"] == usage, name
|
||||
examples = {
|
||||
"Krea2_t2i_20260818_API.json": "text2image",
|
||||
"krea2_T2I_CharSheetV1_API.json": "text2image",
|
||||
"minimax_h3-Turbo_Text2Video_20260813_API.json": "text2video",
|
||||
"minimax_h3-Turbo_Image2Video_20260813_API.json": "text2video",
|
||||
"MiniMax-H3-Turbo-R2V-Extend-2Refs1Vid_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_2ref_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_3ref_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_4ref_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_5ref_API.json": "ref2video",
|
||||
}
|
||||
for name, usage in examples.items():
|
||||
assert classify_usage(_load(EXAMPLES / name))["usage"] == usage, name
|
||||
|
||||
|
||||
def test_r2v_extend_reports_its_video_input_slot():
|
||||
wf = _load(EXAMPLES / "MiniMax-H3-Turbo-R2V-Extend-2Refs1Vid_API.json")
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "ref2video"
|
||||
assert c["evidence"]["video_slots"], "the reference-video slot must be reported"
|
||||
assert any("视频输入槽" in line for line in c["evidence"]["summary"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A5: ambiguity — single loose image into a video graph; multi-output graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_image(nid: str) -> dict:
|
||||
return {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "x.png"},
|
||||
"_meta": {"title": f"图 {nid}"},
|
||||
}
|
||||
|
||||
|
||||
def test_single_loose_image_with_video_output_is_ambiguous():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] is None and c["ambiguous"] is True
|
||||
assert "首帧还是参考图" in c["problem"]
|
||||
|
||||
|
||||
def test_first_frame_role_decides_image2video_without_ambiguity():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "first_frame": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
assert classify_usage(wf)["usage"] == "image2video"
|
||||
|
||||
|
||||
def test_same_graph_both_image_and_video_outputs_is_ambiguous():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
"10": {"class_type": "SaveImage", "inputs": {"images": ["7", 1]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] is None and c["ambiguous"] is True
|
||||
assert "同时输出图片和视频" in c["problem"]
|
||||
|
||||
|
||||
def test_two_reference_images_into_a_custom_node_become_a_group_ref2video():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
|
||||
"6": {"class_type": "LoadImage", "inputs": {"image": "b.png"}},
|
||||
"7": {
|
||||
"class_type": "GenericRefToVideo",
|
||||
"inputs": {
|
||||
"prompt": ["1", 0],
|
||||
"refs.pic_1": ["5", 0],
|
||||
"refs.pic_2": ["6", 0],
|
||||
},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "ref2video"
|
||||
groups = [i for i in detect_untagged_inputs(wf) if i["kind"] == "image_list"]
|
||||
assert [g["nodeId"] for g in groups] == ["7@refs"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A6: failure paths — no output, unknown custom output nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_output_graph_reports_chinese_error_and_blocks():
|
||||
wf = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "p"}},
|
||||
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] is None and c["ambiguous"] is False
|
||||
assert "未在图中找到输出节点" in c["problem"]
|
||||
assert blocking_problem(wf) and "未在图中找到输出节点" in blocking_problem(wf)
|
||||
|
||||
|
||||
def test_unknown_custom_save_node_needs_mapping_and_keeps_a_runnable_path():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "MyCloudExporter",
|
||||
"inputs": {"filename_prefix": "out", "render": ["8", 0]},
|
||||
},
|
||||
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["needs_mapping"] is True
|
||||
assert c["usage"] is None and "无法识别" in (c["problem"] or "")
|
||||
# unknown savers are a warning (user maps them at import), not a block —
|
||||
# once the user confirms the usage the job can still run and results are
|
||||
# pulled from ComfyUI history's images/gifs/videos keys.
|
||||
codes = {(w["level"], w["code"]) for w in workflow_warnings(wf)}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_utility_sink_tail_is_not_an_output_and_does_not_break_detection():
|
||||
"""VideoCombine feeding a cache-cleanup tail still counts as the output."""
|
||||
wf = _load(FIXTURES / "minimax_h3_t2v_untagged_API.json")
|
||||
wf["9999"] = {
|
||||
"class_type": "easy cleanGpuUsed",
|
||||
"inputs": {"anything": ["5978", 0]},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "text2video"
|
||||
assert c["needs_mapping"] is False
|
||||
assert workflow_warnings(wf) == []
|
||||
|
||||
|
||||
def test_evidence_lists_output_nodes_and_slots():
|
||||
wf = _load(FIXTURES / "h3_ref2va_05_API.json")
|
||||
c = classify_usage(wf)
|
||||
ev = c["evidence"]
|
||||
assert any(o["nodeId"] == "92" and o["kind"] == "video" for o in ev["outputs"])
|
||||
assert any(s["kind"] == "image_list" for s in ev["image_slots"])
|
||||
assert any("输出" in line for line in ev["summary"])
|
||||
@@ -26,6 +26,15 @@ VIDEO_WORKFLOW = {
|
||||
"inputs": {"file": ""},
|
||||
"_meta": {"title": "Previous Clip (Input:video)"},
|
||||
},
|
||||
"30": {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {"images": ["40", 0]},
|
||||
"_meta": {"title": "Video Output (Output:video)"},
|
||||
},
|
||||
"40": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["10", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
# Same but the video slot is missing entirely — must be rejected at enqueue.
|
||||
@@ -35,6 +44,15 @@ NO_VIDEO_WORKFLOW = {
|
||||
"inputs": {"text": ""},
|
||||
"_meta": {"title": "Main Prompt (Input:prompt)"},
|
||||
},
|
||||
"30": {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {"images": ["40", 0]},
|
||||
"_meta": {"title": "Video Output (Output:video)"},
|
||||
},
|
||||
"40": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["10", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -250,12 +268,14 @@ def _scene_ids(pid: int) -> list[int]:
|
||||
|
||||
|
||||
async def _fake_history(_client, _prompt_id, *_args):
|
||||
"""A successful ComfyUI history with one video output named out.mp4.
|
||||
Extra positional args (job_id) are accepted for the cancel-check wiring."""
|
||||
"""A successful ComfyUI history with one video output named out.mp4,
|
||||
reported under the workflow's real output node (30 = the tagged
|
||||
VHS_VideoCombine). Extra positional args (job_id) are accepted for the
|
||||
cancel-check wiring."""
|
||||
return {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": {
|
||||
"99": {"videos": [{"filename": "out.mp4", "subfolder": "", "type": "output"}]}
|
||||
"30": {"videos": [{"filename": "out.mp4", "subfolder": "", "type": "output"}]}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -269,7 +289,11 @@ def _stub_comfy(monkeypatch, captured: dict) -> list[str]:
|
||||
prepare_media_inputs handed it; download_image writes out.mp4. Returns
|
||||
the list that collects uploaded local paths.
|
||||
"""
|
||||
queue_manager.paused = True
|
||||
# monkeypatch (not bare assignment): queue_manager is a process-level
|
||||
# singleton — a bare `paused = True` leaks into every later test and
|
||||
# stalls e.g. test_workbench's worker (HEL-619). monkeypatch restores
|
||||
# the previous value at teardown.
|
||||
monkeypatch.setattr(queue_manager, "paused", True)
|
||||
uploads: list[str] = []
|
||||
|
||||
async def fake_upload_video(self, path, subfolder="calliope"):
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""HEL-636: the two-step import closes the loop — analyze → confirm → save,
|
||||
user corrections stick (reanalyze never overwrites them), pending workflows
|
||||
cannot generate, and legacy rows migrate losslessly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, migrate_db
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
AMBIGUOUS = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def test_analyze_returns_usage_evidence_and_problem(client):
|
||||
wf = _load("qwen_image_t2i_API.json")
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": wf})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["usage"] == "text2image"
|
||||
assert body["usage_ambiguous"] is False and body["needs_mapping"] is False
|
||||
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})
|
||||
body = r.json()
|
||||
assert body["usage"] is None and body["usage_ambiguous"] is True
|
||||
assert "首帧还是参考图" in body["usage_problem"]
|
||||
assert body["usage_evidence"]["image_slots"][0]["nodeId"] == "5"
|
||||
|
||||
bare = {"1": {"class_type": "Note", "inputs": {}}}
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": bare})
|
||||
body = r.json()
|
||||
assert "未在图中找到输出节点" in body["usage_problem"]
|
||||
|
||||
|
||||
def test_create_stores_auto_usage_and_kind(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "自动·文生图", "workflow_json": _load("qwen_image_t2i_API.json")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "text2image" and wf["usage_source"] == "auto"
|
||||
assert wf["kind"] == "image" and wf["is_enabled"] is True
|
||||
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "自动·参考生视频", "workflow_json": _load("h3_ref2va_05_API.json")},
|
||||
)
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "ref2video" and wf["kind"] == "video"
|
||||
|
||||
|
||||
def test_ambiguous_import_stays_pending_and_cannot_generate(client):
|
||||
r = client.post("/api/workflows", json={"name": "歧义", "workflow_json": AMBIGUOUS})
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] is None and wf["is_enabled"] is False
|
||||
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
assert "已停用" in gen.json()["detail"]
|
||||
assert client.get("/api/playground/jobs").json() == []
|
||||
|
||||
|
||||
def test_confirm_page_correction_roundtrip(client):
|
||||
"""analyze → user picks usage + fixes the slot role → create carries both."""
|
||||
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": AMBIGUOUS}).json()
|
||||
assert analyzed["usage"] is None
|
||||
|
||||
slot = analyzed["usage_evidence"]["image_slots"][0]
|
||||
schema = [
|
||||
{**i, "role": "first_frame", "manual": True} if i["nodeId"] == slot["nodeId"] else i
|
||||
for i in analyzed["inputs"]
|
||||
]
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "确认·图生视频",
|
||||
"workflow_json": AMBIGUOUS,
|
||||
"usage": "image2video",
|
||||
"usage_source": "user",
|
||||
"input_schema": schema,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "image2video" and wf["usage_source"] == "user"
|
||||
assert wf["kind"] == "video" and wf["is_enabled"] is True
|
||||
# the manual role sticks on read (and through re-derivation)
|
||||
entry = next(i for i in wf["input_schema"] if i["nodeId"] == slot["nodeId"])
|
||||
assert entry["role"] == "first_frame"
|
||||
|
||||
# reanalyze refreshes the auto schema but never the user's usage
|
||||
r = client.post(f"/api/workflows/{wf['id']}/reanalyze")
|
||||
assert r.status_code == 200
|
||||
fresh = r.json()
|
||||
assert fresh["usage"] == "image2video" and fresh["usage_source"] == "user"
|
||||
entry = next(i for i in fresh["input_schema"] if i["nodeId"] == slot["nodeId"])
|
||||
assert entry["role"] == "first_frame" and entry.get("manual") is True
|
||||
|
||||
|
||||
def test_patch_usage_confirms_pending_workflow(client):
|
||||
r = client.post("/api/workflows", json={"name": "待确认", "workflow_json": AMBIGUOUS})
|
||||
wid = r.json()["id"]
|
||||
assert r.json()["is_enabled"] is False
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"usage": "ref2video"})
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "ref2video" and wf["usage_source"] == "user"
|
||||
assert wf["kind"] == "video" and wf["is_enabled"] is True
|
||||
|
||||
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wid)
|
||||
assert listed["usage"] == "ref2video"
|
||||
|
||||
|
||||
def test_reanalyze_upgrades_auto_usage_only(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "重分析", "workflow_json": _load("qwen_image_t2i_API.json")},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"usage": "image2image", "usage_source": "user"})
|
||||
assert r.json()["usage"] == "image2image"
|
||||
|
||||
r = client.post(f"/api/workflows/{wid}/reanalyze")
|
||||
assert r.json()["usage"] == "image2image" # user decision survives
|
||||
assert r.json()["usage_source"] == "user"
|
||||
|
||||
|
||||
def test_legacy_kind_only_callers_still_work(client):
|
||||
"""Old clients post kind without usage — determinate graphs classify, the
|
||||
row is enabled and generation proceeds as before."""
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "旧调用", "kind": "image", "workflow_json": _load("qwen_image_t2i_API.json")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "text2image" and wf["is_enabled"] is True
|
||||
|
||||
|
||||
def test_migration_backfills_usage_and_keeps_history(client):
|
||||
"""A8: pre-HEL-636 rows (direct SQL, no usage) settle on re-migration;
|
||||
determinate ones gain usage, ambiguous ones are parked disabled, and
|
||||
existing jobs/history stay readable with their original kind."""
|
||||
pid = client.post("/api/projects", json={"title": "存量项目", "idea": "x"}).json()["id"]
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
||||
" output_schema, is_enabled) VALUES (?, 'image', ?, '[]', '[]', 1)",
|
||||
("存量·文生图", json.dumps(_load("qwen_image_t2i_API.json"))),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
||||
" output_schema, is_enabled) VALUES (?, 'video', ?, '[]', '[]', 1)",
|
||||
("存量·歧义", json.dumps(AMBIGUOUS)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (project_id, kind, workflow_id, status, payload_json,"
|
||||
" output_paths_json) VALUES (?, 'image', NULL, 'done', '{}', ?)",
|
||||
(pid, json.dumps(["/old/result.png"])),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(migrate_db(settings.db_path)) # idempotent re-run
|
||||
|
||||
wfs = {w["name"]: w for w in client.get("/api/workflows").json()}
|
||||
assert wfs["存量·文生图"]["usage"] == "text2image"
|
||||
assert wfs["存量·文生图"]["is_enabled"] is True
|
||||
assert wfs["存量·歧义"]["usage"] is None
|
||||
assert wfs["存量·歧义"]["is_enabled"] is False
|
||||
assert wfs["存量·歧义"]["kind"] == "video" # old kind semantics untouched
|
||||
|
||||
jobs = client.get(f"/api/jobs?project_id={pid}").json()
|
||||
assert any(
|
||||
j["status"] == "done" and j["kind"] == "image" and j["output_paths"] == ["/old/result.png"]
|
||||
for j in jobs
|
||||
)
|
||||
|
||||
# a third run changes nothing (idempotent)
|
||||
asyncio.run(migrate_db(settings.db_path))
|
||||
wfs2 = {w["name"]: w for w in client.get("/api/workflows").json()}
|
||||
assert wfs2["存量·文生图"]["usage"] == "text2image"
|
||||
assert wfs2["存量·歧义"]["usage"] is None
|
||||
|
||||
|
||||
def test_broken_qwen_import_stays_blocked_but_recorded(client):
|
||||
"""The damaged Qwen export classifies fine (image2image); its structural
|
||||
lint errors still refuse generation — a broken file case, not a usage case."""
|
||||
wf_json = _load("qwen21_edit_sage_API.json")
|
||||
r = client.post(
|
||||
"/api/workflows/analyze", json={"workflow_json": wf_json}
|
||||
)
|
||||
assert r.json()["usage"] == "image2image"
|
||||
r = client.post(
|
||||
"/api/workflows", json={"name": "损坏·Qwen", "kind": "image", "workflow_json": wf_json}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "image2image" and wf["is_enabled"] is True
|
||||
assert any(w["level"] == "error" for w in wf["warnings"])
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"4@prompt": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400 and "重新导出" in gen.json()["detail"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,272 @@
|
||||
"""L2 连接核查(HEL-643 批次 1):导入/复查时用目标 ComfyUI 的 /object_info
|
||||
预检节点与模型可用性。全部基于 fake ComfyUI 的脚本化 /object_info,
|
||||
不针对任何固定 JSON 或模型名写死判定。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.client import clear_object_info_cache
|
||||
from calliope.comfyui.preflight import check_workflow_against
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import _url
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _load_05() -> dict:
|
||||
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def build_object_info(workflow: dict) -> dict:
|
||||
"""A catalogue where every widget value the JSON carries is legal.
|
||||
|
||||
String widgets become combos whose candidates include the workflow's own
|
||||
value (merged across nodes of the same class); numbers/booleans are typed
|
||||
inputs; wired inputs are link types — none of those can mismatch.
|
||||
"""
|
||||
info: dict = {}
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
ct = str(node.get("class_type") or "")
|
||||
if not ct:
|
||||
continue
|
||||
spec = info.setdefault(ct, {"input": {"required": {}}, "output": ["*"]})
|
||||
required = spec["input"]["required"]
|
||||
for name, val in (node.get("inputs") or {}).items():
|
||||
if isinstance(val, str):
|
||||
# Merge: two nodes of one class with different values → both legal.
|
||||
if name in required and isinstance(required[name][0], list):
|
||||
if val not in required[name][0]:
|
||||
required[name][0] = [*required[name][0], val]
|
||||
continue
|
||||
required[name] = [[val], {}]
|
||||
elif name in required:
|
||||
continue
|
||||
elif isinstance(val, list):
|
||||
required[name] = ["*", {}]
|
||||
elif isinstance(val, bool):
|
||||
required[name] = ["BOOLEAN", {}]
|
||||
elif isinstance(val, (int, float)):
|
||||
required[name] = ["NUMBER", {}]
|
||||
return info
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_cache():
|
||||
clear_object_info_cache()
|
||||
yield
|
||||
clear_object_info_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def endpoint_at(client, fake_comfy): # noqa: F811
|
||||
"""Register a connection pointing at the fake server; yields its id."""
|
||||
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
||||
prev_active = settings.comfyui_active_id
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "预检桩", "base_url": _url(fake_comfy), "auth_type": "none"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
endpoints = r.json()["endpoints"]
|
||||
ep = next(e for e in endpoints if e["base_url"] == _url(fake_comfy))
|
||||
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["base_url"] == _url(fake_comfy)]
|
||||
settings.comfyui_active_id = ep["id"]
|
||||
settings.apply_active_comfyui()
|
||||
try:
|
||||
yield ep["id"]
|
||||
finally:
|
||||
settings.comfyui_endpoints = prev_eps
|
||||
settings.comfyui_active_id = prev_active
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unit_missing_node_reported_with_node_context():
|
||||
wf = {"900": {"class_type": "H3SLAAttention", "inputs": {"enabled": True},
|
||||
"_meta": {"title": "H3 SLA Attention"}}}
|
||||
findings = check_workflow_against({"OtherNode": {}}, wf)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f["code"] == "missing_node" and f["level"] == "error"
|
||||
msg = f["message"]
|
||||
assert "H3SLAAttention" in msg and "900" in msg and "H3 SLA Attention" in msg
|
||||
assert "重新检查" in f["hint"]
|
||||
|
||||
|
||||
def test_unit_combo_value_not_in_list():
|
||||
wf = {"127": {"class_type": "UNETLoader",
|
||||
"inputs": {"unet_name": "missing_model.safetensors", "weight_dtype": "default"}}}
|
||||
info = {"UNETLoader": {"input": {"required": {
|
||||
"unet_name": [["installed_model.safetensors"], {}],
|
||||
"weight_dtype": [["default", "fp8"], {"upload": False}],
|
||||
}}, "output": ["MODEL"]}}
|
||||
findings = check_workflow_against(info, wf)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f["code"] == "value_not_in_list" and f["node_id"] == "127"
|
||||
assert "missing_model.safetensors" in f["message"] and "unet_name" in f["message"]
|
||||
assert "模型文件" in f["message"]
|
||||
assert "installed_model.safetensors" in f["hint"]
|
||||
|
||||
|
||||
def test_unit_upload_backed_and_wired_inputs_skipped():
|
||||
wf = {
|
||||
"1": {"class_type": "LoadImage", "inputs": {"image": "placeholder.png"}},
|
||||
"2": {"class_type": "LoadVideo", "inputs": {"file": "clip.mp4"}},
|
||||
"3": {"class_type": "LoadAudio", "inputs": {"audio": "voice.wav"}},
|
||||
"4": {"class_type": "KSampler", "inputs": {"seed": 7, "model": ["9", 0]}},
|
||||
}
|
||||
info = {
|
||||
"LoadImage": {"input": {"required": {"image": [["other.png"], {"image_upload": True}]}},
|
||||
"output": ["IMAGE"]},
|
||||
"LoadVideo": {"input": {"required": {"file": [[], {}]}}, "output": ["IMAGE"]},
|
||||
"LoadAudio": {"input": {"required": {"audio": [[], {}]}}, "output": ["AUDIO"]},
|
||||
"KSampler": {"input": {"required": {"seed": ["INT", {}], "model": ["MODEL", {}]}},
|
||||
"output": ["IMAGE"]},
|
||||
}
|
||||
assert check_workflow_against(info, wf) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analyze with a target connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_analyze_all_green_against_fake_object_info(client, endpoint_at, fake_comfy):
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
check = r.json()["connection_check"]
|
||||
assert check["reachable"] is True and check["ok"] is True
|
||||
assert check["findings"] == []
|
||||
|
||||
|
||||
def test_analyze_reports_missing_custom_node(client, endpoint_at, fake_comfy):
|
||||
info = build_object_info(_load_05())
|
||||
info.pop("H3SLAAttention")
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["ok"] is False
|
||||
missing = [f for f in check["findings"] if f["code"] == "missing_node"]
|
||||
assert missing and "H3SLAAttention" in missing[0]["message"]
|
||||
assert "900" in missing[0]["message"]
|
||||
|
||||
|
||||
def test_analyze_reports_missing_model_value(client, endpoint_at, fake_comfy):
|
||||
info = build_object_info(_load_05())
|
||||
# The server has a DIFFERENT unet installed than the one the JSON names.
|
||||
info["UNETLoader"]["input"]["required"]["unet_name"] = [["another_unet.safetensors"], {}]
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["ok"] is False
|
||||
bad = [f for f in check["findings"] if f["code"] == "value_not_in_list"]
|
||||
assert bad and bad[0]["node_id"] == "127"
|
||||
assert "minimax_h3_fused_refdelta" in bad[0]["message"]
|
||||
|
||||
|
||||
def test_unreachable_connection_degrades_to_unchecked(client):
|
||||
# A port that nothing listens on: the endpoint resolves, the server doesn't.
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
||||
prev_active = settings.comfyui_active_id
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "断连桩", "base_url": f"http://127.0.0.1:{port}", "auth_type": "none"},
|
||||
)
|
||||
endpoints = r.json()["endpoints"]
|
||||
ep = next(e for e in endpoints if e["base_url"] == f"http://127.0.0.1:{port}")
|
||||
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["id"] == ep["id"]]
|
||||
settings.comfyui_active_id = ep["id"]
|
||||
settings.apply_active_comfyui()
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": ep["id"]},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["reachable"] is False and check["ok"] is False
|
||||
assert "无法核查" in check["message"]
|
||||
assert check["findings"] == []
|
||||
finally:
|
||||
settings.comfyui_endpoints = prev_eps
|
||||
settings.comfyui_active_id = prev_active
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Saved-workflow re-check + cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_workflow(client, workflow: dict, endpoint_id: str | None = None) -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "预检工作流",
|
||||
"workflow_json": workflow,
|
||||
"usage": "ref2video",
|
||||
**({"endpoint_id": endpoint_id} if endpoint_id else {}),
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_saved_workflow_recheck_and_refresh(client, endpoint_at, fake_comfy):
|
||||
wf_id = _create_workflow(client, _load_05(), endpoint_at)
|
||||
info = build_object_info(_load_05())
|
||||
info.pop("UNETLoader")
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(f"/api/workflows/{wf_id}/check", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is False and body["endpoint_id"] == endpoint_at
|
||||
assert any(f["code"] == "missing_node" for f in body["findings"])
|
||||
|
||||
# User installs the node package, comes back and re-checks with refresh.
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
r = client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
|
||||
body = r.json()
|
||||
assert body["ok"] is True and body["findings"] == []
|
||||
|
||||
|
||||
def test_object_info_cached_within_ttl(client, endpoint_at, fake_comfy):
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
for _ in range(2):
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
assert r.json()["connection_check"]["ok"] is True
|
||||
hits = [p for p in fake_comfy.requests if p == "/object_info"]
|
||||
assert len(hits) == 1 # second call served from the 5-minute cache
|
||||
|
||||
wf_id = _create_workflow(client, _load_05())
|
||||
client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
|
||||
hits = [p for p in fake_comfy.requests if p == "/object_info"]
|
||||
assert len(hits) == 2 # manual refresh bypasses the TTL
|
||||
@@ -0,0 +1,197 @@
|
||||
"""「测试工作流」(HEL-643 批次 2)端到端:真实队列 → fake ComfyUI → 产物隔离。
|
||||
|
||||
测试任务与普通任务区分(jobs.origin)、dry-run 拒绝、幂等复用、
|
||||
保留策略与级联清理,全部对着 fake ComfyUI 跑真实链路。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.queue.workflow_test import workflow_test_dir
|
||||
from tests.test_studio_generation import (
|
||||
IMAGE_WORKFLOW,
|
||||
PNG_1PX,
|
||||
_real_run,
|
||||
)
|
||||
|
||||
|
||||
def _wait_terminal(client, job_id: int, timeout: float = 25.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
r = client.get(f"/api/jobs/{job_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
if job["status"] not in ("pending", "running"):
|
||||
return job
|
||||
time.sleep(0.4)
|
||||
raise AssertionError(f"test job {job_id} did not finish in {timeout}s")
|
||||
|
||||
|
||||
def _create_workflow(client, workflow: dict, name: str = "可测工作流") -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": name, "workflow_json": workflow, "usage": "text2image"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_passing_test_downloads_real_output_to_scratch_dir(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="通过用例")
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "测试提示词"}}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["reused"] is False
|
||||
job = _wait_terminal(client, body["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["origin"] == "workflow_test"
|
||||
assert job["payload"]["source"] == "workflow_test"
|
||||
assert job["phase"] == "finished"
|
||||
# 真实拿到可下载输出才算通过:产物落在独立 scratch 目录
|
||||
assert len(job["output_paths"]) >= 1
|
||||
out = Path(job["output_paths"][0])
|
||||
assert out.is_file() and out.read_bytes() == PNG_1PX
|
||||
assert workflow_test_dir(wf_id) in out.parents
|
||||
# 提交给 fake ComfyUI 的是补丁后的真实提示词
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
assert queued["1"]["inputs"]["text"] == "测试提示词"
|
||||
|
||||
|
||||
def test_tasks_list_shows_origin_but_playground_history_hides_it(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="可见性用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job_id = r.json()["job"]["id"]
|
||||
_wait_terminal(client, job_id)
|
||||
|
||||
listed = client.get("/api/jobs?limit=50").json()
|
||||
mine = [j for j in listed if j["id"] == job_id]
|
||||
assert mine and mine[0]["origin"] == "workflow_test"
|
||||
|
||||
pg = client.get("/api/playground/jobs").json()
|
||||
assert all(j["id"] != job_id for j in pg), "test jobs must stay out of studio history"
|
||||
|
||||
|
||||
def test_failed_prompt_reports_chinese_reason(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="排队被拒用例")
|
||||
fake_comfy.fail_prompt = True
|
||||
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 "")
|
||||
|
||||
|
||||
def test_no_output_does_not_pass(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="无输出用例")
|
||||
fake_comfy.drop_outputs = True
|
||||
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 "")
|
||||
|
||||
|
||||
def test_dry_run_setting_refuses_real_test(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
settings.dry_run = True # conftest default; _real_run clears it — set again
|
||||
try:
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="dry 拒绝用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
assert r.status_code == 400
|
||||
assert "Dry-run" in r.json()["detail"]
|
||||
finally:
|
||||
settings.dry_run = False
|
||||
|
||||
|
||||
def test_repeat_click_reuses_inflight_job(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="幂等用例")
|
||||
assert client.post("/api/jobs/pause").status_code == 200
|
||||
try:
|
||||
r1 = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "一次就好"}}
|
||||
)
|
||||
assert r1.status_code == 200 and r1.json()["reused"] is False
|
||||
r2 = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "重复点击"}}
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["reused"] is True
|
||||
assert r2.json()["job"]["id"] == r1.json()["job"]["id"]
|
||||
finally:
|
||||
client.post("/api/jobs/resume")
|
||||
client.post(f"/api/jobs/{r1.json()['job']['id']}/cancel")
|
||||
_wait_terminal(client, r1.json()["job"]["id"])
|
||||
|
||||
|
||||
def test_retention_keeps_newest_records_and_prunes_artifacts(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="保留策略用例")
|
||||
for i in range(7):
|
||||
fake_comfy.out_name = f"t{i}.png"
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test",
|
||||
json={"input_values": {"1": f"第{i}次"}},
|
||||
)
|
||||
_wait_terminal(client, r.json()["job"]["id"])
|
||||
rows = client.get(f"/api/workflows/{wf_id}/tests").json()
|
||||
assert len(rows) <= 5
|
||||
kept_ids = {row["id"] for row in rows}
|
||||
# 被清掉的旧记录其产物文件也一并删除
|
||||
leftovers = [p for p in workflow_test_dir(wf_id).iterdir() if p.suffix == ".png"]
|
||||
assert all(p.stem.lstrip("t").isdigit() and int(p.stem[1:]) >= 2 for p in leftovers)
|
||||
assert len(kept_ids) == 5
|
||||
|
||||
|
||||
def test_clear_tests_and_workflow_delete_cascade(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="清理用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
_wait_terminal(client, r.json()["job"]["id"])
|
||||
assert workflow_test_dir(wf_id).is_dir()
|
||||
|
||||
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
|
||||
assert r.status_code == 200 and r.json()["removed"] >= 1
|
||||
assert not workflow_test_dir(wf_id).exists()
|
||||
assert client.get(f"/api/workflows/{wf_id}/tests").json() == []
|
||||
|
||||
# 进行中的测试:先拒绝清除,取消后删除工作流级联清理
|
||||
assert client.post("/api/jobs/pause").status_code == 200
|
||||
try:
|
||||
job_id = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}}
|
||||
).json()["job"]["id"]
|
||||
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
|
||||
assert r.status_code == 409
|
||||
finally:
|
||||
client.post("/api/jobs/resume")
|
||||
client.post(f"/api/jobs/{job_id}/cancel")
|
||||
_wait_terminal(client, job_id)
|
||||
|
||||
# 工作流列表带出「上次测试」实时状态
|
||||
listed = client.get("/api/workflows").json()
|
||||
mine = [w for w in listed if w["id"] == wf_id][0]
|
||||
assert mine["last_test"]["id"] == job_id
|
||||
|
||||
assert client.delete(f"/api/workflows/{wf_id}").status_code == 200
|
||||
assert not workflow_test_dir(wf_id).exists()
|
||||
assert client.get(f"/api/workflows/{wf_id}/tests").status_code == 404
|
||||
|
||||
|
||||
def test_last_test_summary_live_on_list(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_a = _create_workflow(client, IMAGE_WORKFLOW, name="A")
|
||||
wf_b = _create_workflow(client, IMAGE_WORKFLOW, name="B")
|
||||
r = client.post(f"/api/workflows/{wf_a}/test", json={"input_values": {"1": "p"}})
|
||||
done = _wait_terminal(client, r.json()["job"]["id"])
|
||||
listed = {w["id"]: w for w in client.get("/api/workflows").json()}
|
||||
assert listed[wf_a]["last_test"]["status"] == "done"
|
||||
assert listed[wf_a]["last_test"]["output_count"] == len(done["output_paths"])
|
||||
assert listed[wf_b]["last_test"] is None
|
||||
@@ -7,7 +7,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs",
|
||||
"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",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pure-logic checks for 导出尾帧 (lib/media/exportFrame.ts): which media
|
||||
* times get tried near the end, how the PNG is named, and when a sampled
|
||||
* frame counts as black / empty. The decode + download path itself runs in
|
||||
* a real browser (e2e/lastframe.mjs).
|
||||
*
|
||||
* Run with `npm test` (or `node scripts/check-export-frame.mjs`).
|
||||
*/
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const MODULE = join(HERE, "..", "src", "lib", "media", "exportFrame.ts");
|
||||
|
||||
const {
|
||||
tailSeekTimes,
|
||||
videoBaseName,
|
||||
frameFileName,
|
||||
blankKind,
|
||||
statusErrorCode,
|
||||
TAIL_OFFSETS,
|
||||
} = await import(pathToFileURL(MODULE).href);
|
||||
|
||||
let failures = 0;
|
||||
function check(name, cond, extra = "") {
|
||||
if (cond) console.log(`ok ${name}`);
|
||||
else {
|
||||
failures += 1;
|
||||
console.error(`FAIL ${name}${extra ? ` — ${extra}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- seek targets -------------------------------------------------------
|
||||
|
||||
const t6 = tailSeekTimes(6.04);
|
||||
check(
|
||||
"first try is ~1 frame before the end, never the end itself",
|
||||
t6[0] < 6.04 && 6.04 - t6[0] <= 0.05 + 1e-9,
|
||||
JSON.stringify(t6),
|
||||
);
|
||||
check(
|
||||
"later tries step strictly further back",
|
||||
t6.every((t, i) => i === 0 || t < t6[i - 1]),
|
||||
JSON.stringify(t6),
|
||||
);
|
||||
check(
|
||||
"tries stay within ~1s of the end",
|
||||
t6.length === TAIL_OFFSETS.length && 6.04 - t6[t6.length - 1] <= 1 + 1e-9,
|
||||
);
|
||||
check(
|
||||
"short clip clamps to 0 without duplicates",
|
||||
JSON.stringify(tailSeekTimes(0.1)) === "[0.05,0]",
|
||||
);
|
||||
check(
|
||||
"unknown / zero duration falls back to t=0",
|
||||
JSON.stringify(tailSeekTimes(NaN)) === "[0]" &&
|
||||
JSON.stringify(tailSeekTimes(0)) === "[0]",
|
||||
);
|
||||
|
||||
// ---- file names ---------------------------------------------------------
|
||||
|
||||
const api =
|
||||
"/api/file?path=" +
|
||||
encodeURIComponent(
|
||||
"/opt/aigc-home/data/assets/1/video/05_ref2va_4step_sla_00035_.mp4",
|
||||
);
|
||||
check(
|
||||
"base name from /api/file?path=…",
|
||||
videoBaseName(api) === "05_ref2va_4step_sla_00035_",
|
||||
);
|
||||
check(
|
||||
"base name from a Windows path",
|
||||
videoBaseName(
|
||||
"/api/file?path=" +
|
||||
encodeURIComponent("D:\\calliope\\assets\\镜头 01.webm"),
|
||||
) === "镜头 01",
|
||||
);
|
||||
check(
|
||||
"base name from a plain URL",
|
||||
videoBaseName("http://x.test/media/clip-9.mov?x=1") === "clip-9",
|
||||
);
|
||||
check(
|
||||
"unsafe characters are replaced",
|
||||
!/[:*?"<>|]/.test(
|
||||
videoBaseName("/api/file?path=" + encodeURIComponent('a:b*c?"d<e>f|g.mp4')),
|
||||
),
|
||||
);
|
||||
check(
|
||||
"blob URL without a name falls back to 'video'",
|
||||
videoBaseName("blob:http://x.test/") === "video",
|
||||
);
|
||||
const fname = frameFileName(api, 5.958, "尾帧");
|
||||
check(
|
||||
"PNG name names the video, the tag and the frame time",
|
||||
fname === "05_ref2va_4step_sla_00035__尾帧_5.958s.png",
|
||||
fname,
|
||||
);
|
||||
check(
|
||||
"spaces in the tag become dashes",
|
||||
frameFileName(api, 1, "last frame").includes("_last-frame_1.000s.png"),
|
||||
);
|
||||
|
||||
// ---- black / empty detection -------------------------------------------
|
||||
|
||||
function frame(n, rgba) {
|
||||
const out = new Uint8ClampedArray(n * 4);
|
||||
for (let i = 0; i < n; i++) out.set(rgba(i), i * 4);
|
||||
return out;
|
||||
}
|
||||
const N = 64 * 64;
|
||||
check(
|
||||
"fully transparent sample → empty",
|
||||
blankKind(frame(N, () => [0, 0, 0, 0])) === "empty",
|
||||
);
|
||||
check(
|
||||
"pure black → black",
|
||||
blankKind(frame(N, () => [0, 0, 0, 255])) === "black",
|
||||
);
|
||||
check(
|
||||
"near-black encoder noise (16..18) → black",
|
||||
blankKind(frame(N, (i) => [16 + (i % 3), 16, 16, 255])) === "black",
|
||||
);
|
||||
check(
|
||||
"mid-grey picture → not blank",
|
||||
blankKind(frame(N, () => [128, 128, 128, 255])) === null,
|
||||
);
|
||||
check(
|
||||
"dim but visible picture (≈50) → not blank",
|
||||
blankKind(frame(N, () => [50, 48, 52, 255])) === null,
|
||||
);
|
||||
check(
|
||||
"dark night scene with a bright highlight → not blank",
|
||||
blankKind(
|
||||
frame(N, (i) => (i === 100 ? [250, 240, 200, 255] : [4, 4, 6, 255])),
|
||||
) === null,
|
||||
);
|
||||
|
||||
// ---- HTTP status classification ----------------------------------------
|
||||
|
||||
check("404 → not-found", statusErrorCode(404) === "not-found");
|
||||
check(
|
||||
"403 → forbidden",
|
||||
statusErrorCode(403) === "forbidden" && statusErrorCode(401) === "forbidden",
|
||||
);
|
||||
check("500 → network", statusErrorCode(500) === "network");
|
||||
check(
|
||||
"206 / 200 are not errors",
|
||||
statusErrorCode(206) === null && statusErrorCode(200) === null,
|
||||
);
|
||||
|
||||
if (failures) {
|
||||
console.error(`\n${failures} export-frame check(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("export-frame checks passed.");
|
||||
@@ -133,8 +133,13 @@ import type {
|
||||
SceneVideoSettings,
|
||||
Clip,
|
||||
Workflow,
|
||||
WorkflowUsage,
|
||||
WorkflowAnalyzeResult,
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
} from "./comfy/types";
|
||||
export type {
|
||||
Job,
|
||||
@@ -142,8 +147,13 @@ export type {
|
||||
SceneVideoSettings,
|
||||
Clip,
|
||||
Workflow,
|
||||
WorkflowUsage,
|
||||
WorkflowAnalyzeResult,
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
};
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -452,21 +462,59 @@ export const comfy = {
|
||||
|
||||
export const workflows = {
|
||||
list: () => api<Workflow[]>("/api/workflows"),
|
||||
analyze: (workflow_json: Record<string, unknown>) =>
|
||||
api<{
|
||||
inputs: ComfyDynamicInput[];
|
||||
outputs: ComfyDynamicOutput[];
|
||||
suggested_profile?: string;
|
||||
}>("/api/workflows/analyze", {
|
||||
analyze: (
|
||||
workflow_json: Record<string, unknown>,
|
||||
endpoint_id?: string | null,
|
||||
refresh = false,
|
||||
) =>
|
||||
api<WorkflowAnalyzeResult>("/api/workflows/analyze", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workflow_json }),
|
||||
body: JSON.stringify({
|
||||
workflow_json,
|
||||
...(endpoint_id ? { endpoint_id } : {}),
|
||||
...(refresh ? { refresh } : {}),
|
||||
}),
|
||||
}),
|
||||
reanalyze: (id: number) =>
|
||||
api<Workflow>(`/api/workflows/${id}/reanalyze`, { method: "POST" }),
|
||||
/** L2 re-check against a live ComfyUI (HEL-643): node/model availability. */
|
||||
check: (
|
||||
id: number,
|
||||
payload?: { endpoint_id?: string | null; refresh?: boolean },
|
||||
) =>
|
||||
api<WorkflowConnectionCheck>(`/api/workflows/${id}/check`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
}),
|
||||
/** Launch (or re-attach to) one REAL test run (HEL-643 L3). */
|
||||
test: (
|
||||
id: number,
|
||||
payload: {
|
||||
endpoint_id?: string | null;
|
||||
input_values?: Record<string, unknown>;
|
||||
},
|
||||
) =>
|
||||
api<{ ok: boolean; job: Job; reused: boolean }>(
|
||||
`/api/workflows/${id}/test`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
),
|
||||
tests: (id: number) => api<Job[]>(`/api/workflows/${id}/tests`),
|
||||
clearTests: (id: number) =>
|
||||
api<{ ok: boolean; removed: number }>(`/api/workflows/${id}/clear-tests`, {
|
||||
method: "POST",
|
||||
}),
|
||||
create: (payload: {
|
||||
name: string;
|
||||
kind: "image" | "video";
|
||||
workflow_json: Record<string, unknown>;
|
||||
kind?: "image" | "video";
|
||||
usage?: WorkflowUsage;
|
||||
usage_source?: "auto" | "user";
|
||||
input_schema?: ComfyDynamicInput[];
|
||||
output_map?: WorkflowOutputMapEntry[];
|
||||
output_map_source?: "auto" | "user";
|
||||
description?: string;
|
||||
prompt_profile?: string;
|
||||
endpoint_id?: string | null;
|
||||
@@ -484,6 +532,11 @@ export const workflows = {
|
||||
description: string;
|
||||
prompt_profile: string;
|
||||
endpoint_id: string | null;
|
||||
usage: WorkflowUsage;
|
||||
usage_source: "auto" | "user";
|
||||
input_schema: ComfyDynamicInput[];
|
||||
output_map: WorkflowOutputMapEntry[];
|
||||
output_map_source: "auto" | "user";
|
||||
}>,
|
||||
) =>
|
||||
api<Workflow>(`/api/workflows/${id}`, {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
/** Studio image slots (ref-upload-spec §3): PNG / JPG / WebP, ≤ 20MB each. */
|
||||
export const IMAGE_SLOT_ACCEPT =
|
||||
".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp";
|
||||
export const IMAGE_SLOT_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp"];
|
||||
|
||||
/** Reference video slots (HEL-643): MP4 / WebM / MOV, ≤ 500MB (upload cap). */
|
||||
export const VIDEO_SLOT_ACCEPT =
|
||||
"video/mp4,video/webm,video/quicktime,.mp4,.webm,.mov";
|
||||
export const VIDEO_SLOT_MAX_BYTES = 500 * 1024 * 1024;
|
||||
const VIDEO_EXTS = [".mp4", ".webm", ".mov"];
|
||||
|
||||
/** Reference audio slots (HEL-643): MP3 / WAV / M4A, ≤ 100MB. */
|
||||
export const AUDIO_SLOT_ACCEPT =
|
||||
"audio/mpeg,audio/wav,audio/mp4,.mp3,.wav,.m4a";
|
||||
export const AUDIO_SLOT_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const AUDIO_EXTS = [".mp3", ".wav", ".m4a"];
|
||||
|
||||
export type MediaSlotKind = "image" | "video" | "audio";
|
||||
|
||||
export function mediaSlotAccept(kind: MediaSlotKind): string {
|
||||
if (kind === "video") return VIDEO_SLOT_ACCEPT;
|
||||
if (kind === "audio") return AUDIO_SLOT_ACCEPT;
|
||||
return IMAGE_SLOT_ACCEPT;
|
||||
}
|
||||
|
||||
/** Why this file cannot fill a media slot of this kind (null = fine). */
|
||||
export function mediaFileProblem(
|
||||
kind: MediaSlotKind,
|
||||
file: { name: string; size: number },
|
||||
): string | null {
|
||||
const dot = file.name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : "";
|
||||
const max =
|
||||
kind === "video"
|
||||
? VIDEO_SLOT_MAX_BYTES
|
||||
: kind === "audio"
|
||||
? AUDIO_SLOT_MAX_BYTES
|
||||
: IMAGE_SLOT_MAX_BYTES;
|
||||
if (kind === "video" && !VIDEO_EXTS.includes(ext))
|
||||
return t("studio.errVideoFormat", { name: file.name });
|
||||
if (kind === "audio" && !AUDIO_EXTS.includes(ext))
|
||||
return t("studio.errAudioFormat", { name: file.name });
|
||||
if (kind === "image" && !IMAGE_EXTS.includes(ext))
|
||||
return t("studio.errImageFormat", { name: file.name });
|
||||
if (file.size > max)
|
||||
return kind === "image"
|
||||
? t("studio.errImageSize", { name: file.name })
|
||||
: t("studio.errMediaSize", { name: file.name });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Why this file cannot fill an image slot (null = fine). */
|
||||
export function imageFileProblem(file: {
|
||||
name: string;
|
||||
size: number;
|
||||
}): string | null {
|
||||
return mediaFileProblem("image", file);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
/** Display name of an uploaded path (strips the uuid8- prefix the upload endpoint writes). */
|
||||
export function uploadBaseName(p: string): string {
|
||||
const raw = p.split(/[/\\]/).pop() ?? p;
|
||||
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export function compactInputValues(
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
if (v === null || v === undefined) continue;
|
||||
if (typeof v === 'string' && !v.trim()) continue;
|
||||
if (Array.isArray(v) && !v.length) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* ComfyUI ResolutionSelector (comfy_extras/nodes_resolution.py) — same
|
||||
* ratios and rounding, so the size shown is the size ComfyUI renders.
|
||||
*/
|
||||
const RATIOS: Record<string, [number, number]> = {
|
||||
'1:1 (Square)': [1, 1],
|
||||
'2:3 (Portrait Photo)': [2, 3],
|
||||
'3:2 (Photo)': [3, 2],
|
||||
'3:4 (Portrait Standard)': [3, 4],
|
||||
'4:3 (Standard)': [4, 3],
|
||||
'9:16 (Portrait Widescreen)': [9, 16],
|
||||
'16:9 (Widescreen)': [16, 9],
|
||||
'21:9 (Ultrawide)': [21, 9],
|
||||
};
|
||||
|
||||
/** Python's round(): halves go to the even neighbour. */
|
||||
function roundHalfEven(x: number): number {
|
||||
const f = Math.floor(x);
|
||||
const diff = x - f;
|
||||
if (diff > 0.5) return f + 1;
|
||||
if (diff < 0.5) return f;
|
||||
return f % 2 === 0 ? f : f + 1;
|
||||
}
|
||||
|
||||
export function resolutionSelectorSize(
|
||||
aspectRatio: string,
|
||||
megapixels: number,
|
||||
multiple: number,
|
||||
): { width: number; height: number } | null {
|
||||
const ratio = RATIOS[aspectRatio];
|
||||
if (!ratio || !Number.isFinite(megapixels) || megapixels <= 0) return null;
|
||||
const [w, h] = ratio;
|
||||
const m = Math.max(1, Math.floor(multiple) || 8);
|
||||
const scale = Math.sqrt((megapixels * 1024 * 1024) / (w * h));
|
||||
return { width: roundHalfEven((w * scale) / m) * m, height: roundHalfEven((h * scale) / m) * m };
|
||||
}
|
||||
|
||||
/** "16:9 (Widescreen)" → "16:9". */
|
||||
export function ratioShort(option: string): string {
|
||||
return option.split(' ')[0] ?? option;
|
||||
}
|
||||
@@ -1,6 +1,27 @@
|
||||
export type ComfyInputKind = 'text' | 'textarea' | 'number' | 'image' | 'image_url' | 'audio' | 'video';
|
||||
export type ComfyInputKind =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'number'
|
||||
| 'select'
|
||||
| 'image'
|
||||
| 'image_url'
|
||||
| 'audio'
|
||||
| 'video'
|
||||
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
|
||||
| 'image_list'
|
||||
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
|
||||
| 'video_list'
|
||||
| 'audio_list';
|
||||
export type ComfyOutputKind = 'image' | 'video' | 'other';
|
||||
|
||||
/** Structural finding on an imported workflow; `error` blocks generation. */
|
||||
export interface WorkflowWarning {
|
||||
level: 'error' | 'warning';
|
||||
code: string;
|
||||
node_id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ComfyDynamicInput {
|
||||
nodeId: string;
|
||||
label: string;
|
||||
@@ -11,14 +32,116 @@ export interface ComfyDynamicInput {
|
||||
required: boolean;
|
||||
/** Inferred from node classes/wiring — the workflow carries no (Input) tags. */
|
||||
auto?: boolean;
|
||||
/** ComfyUI widget limits (number kind). */
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
integer?: boolean;
|
||||
/** Allowed values (select kind). */
|
||||
options?: string[];
|
||||
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
|
||||
resolution?: { megapixels: string; multiple: number };
|
||||
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
|
||||
media_hint?: string;
|
||||
}
|
||||
|
||||
export interface ComfyDynamicOutput {
|
||||
nodeId: string;
|
||||
label: string;
|
||||
/** Role from title tag, e.g. (Output:image) → "image". */
|
||||
/** Role from title tag, e.g. (Output:image). */
|
||||
role?: string | null;
|
||||
kind: ComfyOutputKind;
|
||||
/** Null while an unknown custom node awaits the user's media-type pick. */
|
||||
kind: ComfyOutputKind | null;
|
||||
/** Untagged detection: node class + which rule typed it. */
|
||||
class_type?: string;
|
||||
evidence?: 'registry' | 'class_name_match' | 'unknown_node';
|
||||
/** Form default for unknown candidates — never auto-applied. */
|
||||
suggested_kind?: 'image' | 'video' | null;
|
||||
}
|
||||
|
||||
/** One entry of a workflow's confirmed output mapping (HEL-638). */
|
||||
export interface WorkflowOutputMapEntry {
|
||||
nodeId: string;
|
||||
kind: 'image' | 'video';
|
||||
label?: string;
|
||||
class_type?: string;
|
||||
}
|
||||
|
||||
/** The five workflow usages the studio distinguishes (HEL-636). */
|
||||
export type WorkflowUsage =
|
||||
| 'text2image'
|
||||
| 'image2image'
|
||||
| 'text2video'
|
||||
| 'image2video'
|
||||
| 'ref2video';
|
||||
|
||||
export const WORKFLOW_USAGES: WorkflowUsage[] = [
|
||||
'text2image',
|
||||
'image2image',
|
||||
'text2video',
|
||||
'image2video',
|
||||
'ref2video',
|
||||
];
|
||||
|
||||
/** Which usages each generate page serves. */
|
||||
export const USAGE_BY_KIND: Record<'image' | 'video', WorkflowUsage[]> = {
|
||||
image: ['text2image', 'image2image'],
|
||||
video: ['text2video', 'image2video', 'ref2video'],
|
||||
};
|
||||
|
||||
/** One L2 connection-check finding against a live ComfyUI (HEL-643). */
|
||||
export interface PreflightFinding {
|
||||
level: 'error' | 'warn' | 'info' | 'ok';
|
||||
code: string;
|
||||
node_id?: string;
|
||||
node_title?: string | null;
|
||||
message: string;
|
||||
hint?: string | null;
|
||||
}
|
||||
|
||||
/** Result of checking a workflow against a target connection's /object_info. */
|
||||
export interface WorkflowConnectionCheck {
|
||||
endpoint_id: string | null;
|
||||
endpoint_name: string | null;
|
||||
base_url?: string | null;
|
||||
/** False when any error-level finding exists (or the server was unreachable). */
|
||||
ok: boolean;
|
||||
/** False → 无法核查 (server down): honest, non-blocking. */
|
||||
reachable: boolean;
|
||||
findings: PreflightFinding[];
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
/** Slim live record of a workflow's latest test run (HEL-643). */
|
||||
export interface WorkflowLastTest {
|
||||
id: number;
|
||||
status: string;
|
||||
phase: string | null;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
output_count: number;
|
||||
}
|
||||
|
||||
/** Result of POST /api/workflows/analyze (import confirm step 1). */
|
||||
export interface WorkflowAnalyzeResult {
|
||||
inputs: ComfyDynamicInput[];
|
||||
outputs: ComfyDynamicOutput[];
|
||||
suggested_profile?: string;
|
||||
warnings?: WorkflowWarning[];
|
||||
usage: WorkflowUsage | null;
|
||||
usage_ambiguous: boolean;
|
||||
needs_mapping: boolean;
|
||||
usage_problem: string | null;
|
||||
usage_evidence: {
|
||||
outputs: ComfyDynamicOutput[];
|
||||
image_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
|
||||
video_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
|
||||
summary: string[];
|
||||
};
|
||||
usable_usage: WorkflowUsage[];
|
||||
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
|
||||
connection_check?: WorkflowConnectionCheck;
|
||||
}
|
||||
|
||||
export interface WorkflowNode {
|
||||
@@ -27,10 +150,74 @@ 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;
|
||||
usage_source?: 'auto' | 'user' | null;
|
||||
workflow_json: Record<string, WorkflowNode>;
|
||||
input_schema: ComfyDynamicInput[];
|
||||
output_schema: ComfyDynamicOutput[];
|
||||
@@ -39,6 +226,14 @@ export interface Workflow {
|
||||
is_enabled: boolean;
|
||||
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
|
||||
endpoint_id?: string | null;
|
||||
warnings?: WorkflowWarning[];
|
||||
/** HEL-638: confirmed output mapping ({nodeId, kind}); null/empty = pending. */
|
||||
output_map?: WorkflowOutputMapEntry[] | null;
|
||||
output_map_source?: 'auto' | 'user' | null;
|
||||
/** True when the graph has unknown output nodes needing designation. */
|
||||
needs_mapping?: boolean;
|
||||
/** Latest test-run record, queried live (HEL-643); null = never tested. */
|
||||
last_test?: WorkflowLastTest | null;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
@@ -57,6 +252,12 @@ export interface Job {
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
retry_count: number;
|
||||
/** Prompt id ComfyUI accepted this job under (null until /prompt succeeds). */
|
||||
comfy_prompt_id?: string | null;
|
||||
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
|
||||
phase?: string | null;
|
||||
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface SceneVideoSettings {
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { jobsApi, type Job } from '$lib/api';
|
||||
import type { CalliopeEvent, EventConnectionState } from '$lib/events';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import JobRow from './JobRow.svelte';
|
||||
import Button from './ui/Button.svelte';
|
||||
import Icon from './ui/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
logs: CalliopeEvent[];
|
||||
projectId: number;
|
||||
/** Real SSE connection state from events.ts — drives the live pip. */
|
||||
connState: EventConnectionState;
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { logs, projectId, connState, collapsed = false, onToggle }: Props = $props();
|
||||
|
||||
type Tone = 'info' | 'ok' | 'warn' | 'err' | 'work';
|
||||
|
||||
type LogEntry = {
|
||||
key: string;
|
||||
ts: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
tone: Tone;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function str(v: unknown): string {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatEvent(ev: CalliopeEvent, index: number): LogEntry | null {
|
||||
// Poll spam — progress lives in the jobProgress store, not the log
|
||||
if (ev.type === 'job.progress') return null;
|
||||
|
||||
const d = ev.data ?? {};
|
||||
const msg = str(d.message).trim();
|
||||
const kind = str(d.kind);
|
||||
const jobId = d.job_id != null ? `#${d.job_id}` : '';
|
||||
const err = str(d.error).trim();
|
||||
const paths = Array.isArray(d.paths) ? d.paths.length : 0;
|
||||
const outputs = Array.isArray(d.outputs) ? d.outputs.length : 0;
|
||||
const time = ev.ts?.includes('T') ? ev.ts.slice(11, 19) : ev.ts?.slice(0, 8) || '';
|
||||
|
||||
let title = ev.type;
|
||||
let detail = msg;
|
||||
let tone: Tone = 'info';
|
||||
|
||||
switch (ev.type) {
|
||||
case 'agent.thinking':
|
||||
title = t('activity.agentWorking');
|
||||
detail = msg || t('activity.working');
|
||||
tone = 'work';
|
||||
break;
|
||||
case 'story.ready':
|
||||
title = t('activity.storyReady');
|
||||
detail = msg || t('activity.storylineDrafted');
|
||||
tone = 'ok';
|
||||
break;
|
||||
case 'job.created':
|
||||
title = t('activity.queued');
|
||||
detail = msg || [kind, jobId].filter(Boolean).join(' ');
|
||||
tone = 'info';
|
||||
break;
|
||||
case 'job.started':
|
||||
title = t('activity.running');
|
||||
detail = msg || [kind, jobId].filter(Boolean).join(' ');
|
||||
tone = 'work';
|
||||
break;
|
||||
case 'job.completed':
|
||||
title = t('activity.finished');
|
||||
detail =
|
||||
msg ||
|
||||
[kind, jobId, outputs ? t('activity.files', { count: outputs }) : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
tone = 'ok';
|
||||
break;
|
||||
case 'job.failed':
|
||||
title = t('activity.failed');
|
||||
detail = err || msg || [kind, jobId].filter(Boolean).join(' ');
|
||||
tone = 'err';
|
||||
break;
|
||||
case 'asset.ready':
|
||||
title = t('activity.assetReady');
|
||||
detail = msg || (paths ? t('activity.filesSaved', { count: paths }) : t('activity.outputSaved'));
|
||||
tone = 'ok';
|
||||
break;
|
||||
case 'job.deleted':
|
||||
title = t('activity.removed');
|
||||
detail = msg || jobId || t('activity.jobDeleted');
|
||||
tone = 'warn';
|
||||
break;
|
||||
default:
|
||||
title = ev.type.replace(/\./g, ' · ');
|
||||
detail = msg || err || kind;
|
||||
tone = 'info';
|
||||
}
|
||||
|
||||
return {
|
||||
key: `${ev.ts}-${ev.type}-${index}`,
|
||||
ts: time,
|
||||
title,
|
||||
detail,
|
||||
tone,
|
||||
type: ev.type,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = $derived.by(() => {
|
||||
const raw = logs
|
||||
.slice(-120)
|
||||
.map((ev, i) => formatEvent(ev, i))
|
||||
.filter((e): e is LogEntry => e != null);
|
||||
|
||||
// Collapse consecutive identical agent lines
|
||||
const collapsed: LogEntry[] = [];
|
||||
for (const e of raw) {
|
||||
const prev = collapsed[collapsed.length - 1];
|
||||
if (prev && prev.type === e.type && prev.detail === e.detail) {
|
||||
continue;
|
||||
}
|
||||
collapsed.push(e);
|
||||
}
|
||||
return collapsed.slice(-60).reverse();
|
||||
});
|
||||
|
||||
// Same query key as QueueStage — refreshed by SSE invalidation from the
|
||||
// project page (job.* events), not by polling.
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs', projectId],
|
||||
queryFn: () => jobsApi.list(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
const allJobs = $derived(($jobsQuery.data ?? []) as Job[]);
|
||||
|
||||
function activeWeight(job: Job): number {
|
||||
if (job.status === 'running') return 0;
|
||||
if (job.status === 'pending') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Active jobs first, then most recent — capped so the log keeps room.
|
||||
const queueRows = $derived(
|
||||
[...allJobs].sort((a, b) => activeWeight(a) - activeWeight(b) || b.id - a.id).slice(0, 6),
|
||||
);
|
||||
|
||||
const stats = $derived({
|
||||
running: allJobs.filter((j) => j.status === 'running').length,
|
||||
queued: allJobs.filter((j) => j.status === 'pending').length,
|
||||
done: allJobs.filter((j) => j.status === 'done').length,
|
||||
failed: allJobs.filter((j) => j.status === 'failed').length,
|
||||
});
|
||||
const busyCount = $derived(stats.running + stats.queued);
|
||||
|
||||
const connected = $derived(connState === 'open');
|
||||
</script>
|
||||
|
||||
<aside class="activity" class:collapsed aria-label={t('activity.title')}>
|
||||
{#if collapsed}
|
||||
<button
|
||||
type="button"
|
||||
class="rail-toggle"
|
||||
onclick={onToggle}
|
||||
aria-expanded="false"
|
||||
title={t('activity.show')}
|
||||
>
|
||||
<span class="pip rail-pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
|
||||
{#if busyCount > 0}
|
||||
<span class="rail-count" title={t('activity.busyCount', { count: busyCount })}>{busyCount}</span>
|
||||
{/if}
|
||||
<span class="rail-label">{t('activity.title')}</span>
|
||||
<span class="rail-chevron" aria-hidden="true">‹</span>
|
||||
</button>
|
||||
{:else}
|
||||
<header class="head">
|
||||
<div class="head-title">
|
||||
<span class="pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
|
||||
<strong>{t('activity.title')}</strong>
|
||||
{#if connState === 'reconnecting'}
|
||||
<span class="conn-state warn">{t('activity.reconnecting')}</span>
|
||||
{:else if connState === 'connecting'}
|
||||
<span class="conn-state">{t('activity.connecting')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onclick={onToggle} title={t('activity.hide')}>
|
||||
{t('activity.hide')} <Icon name="chevron-right" size={13} />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<section class="queue" aria-label={t('activity.queue')}>
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">{t('activity.queue')}</p>
|
||||
</div>
|
||||
{#if queueRows.length === 0}
|
||||
<p class="queue-empty">{t('activity.queueIdle')}</p>
|
||||
{:else}
|
||||
<div class="job-list">
|
||||
{#each queueRows as job (job.id)}
|
||||
<JobRow {job} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="stats-strip" aria-label={t('activity.queueStats')}>
|
||||
<span><span class="sd sd-running" aria-hidden="true"></span>{stats.running} {t('activity.running').toLowerCase()}</span>
|
||||
<span><span class="sd sd-queued" aria-hidden="true"></span>{stats.queued} {t('activity.queued').toLowerCase()}</span>
|
||||
<span><span class="sd sd-done" aria-hidden="true"></span>{stats.done} {t('activity.done')}</span>
|
||||
<span><span class="sd sd-failed" aria-hidden="true"></span>{stats.failed} {t('activity.failed').toLowerCase()}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="log-section" aria-label={t('activity.agentLog')}>
|
||||
<p class="eyebrow">{t('activity.agentLog')}</p>
|
||||
{#if entries.length === 0}
|
||||
<div class="empty">
|
||||
<p>{t('activity.quiet')}</p>
|
||||
<p class="hint">{t('activity.quietHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="log">
|
||||
{#each entries as ev (ev.key)}
|
||||
<li class="entry tone-{ev.tone}">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<div class="meta">
|
||||
<div class="row">
|
||||
<span class="title">{ev.title}</span>
|
||||
<time class="ts" datetime={ev.ts}>{ev.ts}</time>
|
||||
</div>
|
||||
{#if ev.detail}
|
||||
<p class="detail">{ev.detail}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.activity {
|
||||
--panel-w: 320px;
|
||||
--rail-w: 44px;
|
||||
width: var(--panel-w);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--border);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(139, 92, 246, 0.06) 0%, transparent 120px),
|
||||
var(--bg-surface);
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.activity.collapsed {
|
||||
width: var(--rail-w);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.rail-toggle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 16px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rail-toggle:hover {
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rail-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.rail-label {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rail-chevron {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rail-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pip {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pip.live {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
animation: live-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pip.reconnecting {
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.18);
|
||||
}
|
||||
|
||||
@keyframes live-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.65;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.activity {
|
||||
transition: none;
|
||||
}
|
||||
.pip.live {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.head-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.head-title strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.conn-state {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conn-state.warn {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 10px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.queue {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.job-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 216px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.queue-empty {
|
||||
margin: 0 6px 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stats-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
margin: 10px 6px 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stats-strip > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sd {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sd-running {
|
||||
background: var(--info);
|
||||
}
|
||||
.sd-queued {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.sd-done {
|
||||
background: var(--success);
|
||||
}
|
||||
.sd-failed {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.log-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 8px 6px;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty .hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.log {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 4px 8px 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 8px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.entry:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.tone-ok .dot {
|
||||
background: var(--success);
|
||||
}
|
||||
.tone-err .dot {
|
||||
background: var(--error);
|
||||
}
|
||||
.tone-warn .dot {
|
||||
background: var(--warning);
|
||||
}
|
||||
.tone-work .dot {
|
||||
background: var(--accent);
|
||||
}
|
||||
.tone-info .dot {
|
||||
background: var(--info);
|
||||
}
|
||||
|
||||
.meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ts {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tone-err .detail {
|
||||
color: #fca5a5;
|
||||
}
|
||||
</style>
|
||||
@@ -15,8 +15,8 @@ interface Props {
|
||||
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<a class="logo" href="/canvas" title="Calliope Lab">
|
||||
Calliope <span>Lab</span>
|
||||
<a class="logo" href="/" title={t('shell.brand')}>
|
||||
{t('shell.brand')}
|
||||
</a>
|
||||
{#if crumb}
|
||||
<span class="crumb">{crumb}</span>
|
||||
@@ -28,7 +28,7 @@ interface Props {
|
||||
{/if}
|
||||
<nav class="top-nav" aria-label={t('nav.primary')}>
|
||||
<a class="nav-link" class:active={active === 'canvas'} href="/canvas">{t('nav.canvas')}</a>
|
||||
<a class="nav-link" class:active={active === 'projects'} href="/projects">{t('nav.projects')}</a>
|
||||
<a class="nav-link" class:active={active === 'projects'} href="/">{t('nav.projects')}</a>
|
||||
<a class="nav-link" class:active={active === 'build-scene'} href="/build-scene">{t('nav.buildScene')}</a>
|
||||
<a class="nav-link" class:active={active === 'library'} href="/library">{t('nav.library')}</a>
|
||||
<a class="nav-link" class:active={active === 'agents'} href="/agents">{t('nav.agents')}</a>
|
||||
@@ -69,18 +69,14 @@ interface Props {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-family: var(--font);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logo span {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.crumb {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
@@ -122,7 +118,7 @@ interface Props {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 760px) {
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
|
||||
interface Props {
|
||||
src: string | null;
|
||||
alt: string;
|
||||
placeholder: string;
|
||||
wide?: boolean;
|
||||
/** Taller 4:3 media-first crop (asset card grids). */
|
||||
tall?: boolean;
|
||||
/** Latest generation-job state for this asset, if the parent tracks jobs. */
|
||||
jobState?: 'generating' | 'failed' | null;
|
||||
onPreview?: (src: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
src,
|
||||
alt,
|
||||
placeholder,
|
||||
wide = false,
|
||||
tall = false,
|
||||
jobState = null,
|
||||
onPreview,
|
||||
}: Props = $props();
|
||||
|
||||
let available = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
void src;
|
||||
available = false;
|
||||
});
|
||||
|
||||
// Job state (live from the queue) wins over the media-derived state.
|
||||
const chip = $derived.by((): { status: string; label: string } => {
|
||||
if (jobState === 'generating') return { status: 'generating', label: t('common.generating') };
|
||||
if (jobState === 'failed') return { status: 'failed', label: t('common.failed') };
|
||||
if (available) return { status: 'ready', label: t('common.ready') };
|
||||
if (src) return { status: 'missing', label: t('common.missing') };
|
||||
return { status: 'pending', label: t('common.pending') };
|
||||
});
|
||||
|
||||
function open() {
|
||||
if (available && src && onPreview) onPreview(src);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="thumb"
|
||||
class:wide
|
||||
class:tall
|
||||
class:clickable={available && !!onPreview}
|
||||
role={available && onPreview ? 'button' : undefined}
|
||||
tabindex={available && onPreview ? 0 : undefined}
|
||||
onclick={open}
|
||||
onkeydown={available && onPreview ? onKey : undefined}
|
||||
>
|
||||
<SafeMedia
|
||||
class="fill"
|
||||
{src}
|
||||
{alt}
|
||||
label={placeholder}
|
||||
onAvailable={() => (available = true)}
|
||||
onUnavailable={() => (available = false)}
|
||||
/>
|
||||
<span class="thumb-chip">
|
||||
<StatusChip status={chip.status} label={chip.label} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.thumb {
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.thumb.wide {
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
.thumb.tall {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
.thumb.clickable {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.thumb.clickable:hover {
|
||||
border-color: var(--accent, #a78bfa);
|
||||
}
|
||||
.thumb.clickable:focus-visible {
|
||||
outline: 2px solid var(--accent, #a78bfa);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.thumb :global(.fill) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.thumb :global(.safe-media-ph.fill) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.thumb-chip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -136,9 +136,9 @@
|
||||
|
||||
const projectList = $derived(($projectsQuery.data ?? []) as Project[]);
|
||||
|
||||
// Stage-URL contract: images land on Assets, clips on Video.
|
||||
// Five-stage workbench URL contract: images land on 素材资产, clips on 镜头脚本.
|
||||
const stageLink = $derived(
|
||||
attached ? `/project/${attached.projectId}?stage=${isVideo ? 'video' : 'assets'}` : '',
|
||||
attached ? `/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}` : '',
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -267,13 +267,13 @@
|
||||
.attached {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: var(--ok-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent);
|
||||
border-radius: var(--r-md);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.attached-check {
|
||||
display: flex;
|
||||
@@ -282,8 +282,8 @@
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
color: #052e12;
|
||||
background: var(--ok);
|
||||
color: var(--bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.attached-text {
|
||||
@@ -293,23 +293,26 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.attached-text strong {
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 普通链接走 ghost(§1.2):默认 --text-2,悬停 --text,不用金色文字链接。 */
|
||||
.attached-link {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.attached-link:hover {
|
||||
color: var(--text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.attached-link:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.attached-dismiss {
|
||||
display: flex;
|
||||
@@ -319,15 +322,15 @@
|
||||
height: 22px;
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.attached-dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.attached-dismiss:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -336,20 +339,20 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-5);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-1);
|
||||
}
|
||||
.err {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
margin: var(--sp-1) 0 0;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--err);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
allowUpload?: boolean;
|
||||
/** Force required-field error rings visible (e.g. after a blocked Generate). */
|
||||
showErrors?: boolean;
|
||||
/** Show each field's workflow value (原值) and mark fields that differ from it. */
|
||||
showOrig?: boolean;
|
||||
/** Extra per-field messages keyed by nodeId (e.g. range checks); shown with showErrors. */
|
||||
errors?: Record<string, string>;
|
||||
/** Emits labels of required-but-empty inputs whenever the set changes. */
|
||||
onValidityChange?: (missing: string[]) => void;
|
||||
onChange?: (values: Record<string, string | number>) => void;
|
||||
@@ -30,10 +34,23 @@
|
||||
assetOptions = [],
|
||||
allowUpload = false,
|
||||
showErrors = false,
|
||||
showOrig = false,
|
||||
errors = {},
|
||||
onValidityChange,
|
||||
onChange,
|
||||
}: Props = $props();
|
||||
|
||||
function hasOrig(inp: ComfyDynamicInput): boolean {
|
||||
return inp.defaultValue !== undefined && inp.defaultValue !== '';
|
||||
}
|
||||
|
||||
function isChanged(inp: ComfyDynamicInput): boolean {
|
||||
if (!showOrig) return false;
|
||||
const v = values[inp.nodeId];
|
||||
if (isBlank(v)) return hasOrig(inp);
|
||||
return !hasOrig(inp) || String(v) !== String(inp.defaultValue);
|
||||
}
|
||||
|
||||
// Unique per mounted form so label/id pairs never collide across instances.
|
||||
const uid = Math.random().toString(36).slice(2, 8);
|
||||
let touched = $state<Record<string, boolean>>({});
|
||||
@@ -80,6 +97,11 @@
|
||||
return isMissing(inp) && (showErrors || !!touched[inp.nodeId]);
|
||||
}
|
||||
|
||||
function extraError(inp: ComfyDynamicInput): string | null {
|
||||
const msg = errors[inp.nodeId]?.trim();
|
||||
return msg && !isMissing(inp) && (showErrors || !!touched[inp.nodeId]) ? msg : null;
|
||||
}
|
||||
|
||||
function thumbFor(current: string): string | null {
|
||||
if (!current) return null;
|
||||
const opt = assetOptions.find((o) => o.path === current);
|
||||
@@ -224,7 +246,9 @@
|
||||
{#each inputs as inp (inp.nodeId)}
|
||||
{@const current = String(values[inp.nodeId] ?? '')}
|
||||
{@const id = inputId(inp.nodeId)}
|
||||
{@const errored = showError(inp)}
|
||||
{@const extra = extraError(inp)}
|
||||
{@const errored = showError(inp) || !!extra}
|
||||
{@const changed = isChanged(inp)}
|
||||
<div class="field">
|
||||
<label class="label" for={id}>
|
||||
{inp.label}
|
||||
@@ -235,6 +259,9 @@
|
||||
{#if inp.role}<span class="role">{inp.role}</span>{/if}
|
||||
<span class="kind">{inp.kind}</span>
|
||||
{/if}
|
||||
{#if showOrig && hasOrig(inp)}
|
||||
<span class="orig">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{#if readonly}
|
||||
<div class="readonly">{values[inp.nodeId] ?? inp.defaultValue ?? '—'}</div>
|
||||
@@ -243,6 +270,7 @@
|
||||
{id}
|
||||
rows="3"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={current}
|
||||
@@ -254,12 +282,26 @@
|
||||
{id}
|
||||
type="number"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={values[inp.nodeId] ?? ''}
|
||||
oninput={(e) => setNumberValue(inp.nodeId, e.currentTarget.value)}
|
||||
onblur={() => markTouched(inp.nodeId)}
|
||||
/>
|
||||
{:else if inp.kind === 'select'}
|
||||
<select
|
||||
{id}
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
value={current || String(inp.defaultValue ?? '')}
|
||||
onchange={(e) => setValue(inp.nodeId, e.currentTarget.value)}
|
||||
onblur={() => markTouched(inp.nodeId)}
|
||||
>
|
||||
{#each inp.options ?? [] as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if allowUpload && isUploadKind(inp.kind)}
|
||||
{@const uploadingName = uploading[inp.nodeId]}
|
||||
{@const isUpload = !!current && !assetOptions.some((o) => o.path === current)}
|
||||
@@ -433,6 +475,7 @@
|
||||
{id}
|
||||
type="text"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={current}
|
||||
@@ -441,7 +484,7 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if errored}
|
||||
<span class="error-msg" role="alert">{t('cdf.requiredMsg', { label: inp.label })}</span>
|
||||
<span class="error-msg" role="alert">{extra ?? t('cdf.requiredMsg', { label: inp.label })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -504,11 +547,23 @@
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
input.changed,
|
||||
textarea.changed,
|
||||
select.changed {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
input.invalid,
|
||||
textarea.invalid,
|
||||
select.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.orig {
|
||||
float: right;
|
||||
font-weight: 400;
|
||||
font-size: var(--fs-mini);
|
||||
font-family: var(--mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
input.invalid:focus-visible,
|
||||
textarea.invalid:focus-visible,
|
||||
select.invalid:focus-visible {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
|
||||
interface Props {
|
||||
src: string | null;
|
||||
@@ -61,6 +63,17 @@
|
||||
}
|
||||
return alt ? `${alt.trim().replace(/\s+/g, '-').toLowerCase()}.png` : 'media';
|
||||
});
|
||||
|
||||
let frameBusy = $state(false);
|
||||
async function exportTail() {
|
||||
if (!src || frameBusy) return;
|
||||
frameBusy = true;
|
||||
try {
|
||||
await exportLastFrame(src);
|
||||
} finally {
|
||||
frameBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={src ? onKeydown : undefined} />
|
||||
@@ -90,6 +103,26 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
<Icon name="zoom-in" size={17} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if isVideo}
|
||||
<button
|
||||
class="tool"
|
||||
type="button"
|
||||
aria-label={frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
|
||||
title={frameBusy ? t('lastFrame.exporting') : `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
|
||||
disabled={frameBusy}
|
||||
aria-busy={frameBusy}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
exportTail();
|
||||
}}
|
||||
>
|
||||
{#if frameBusy}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<Icon name="image" size={17} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<a
|
||||
class="tool"
|
||||
href={src}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { jobsApi, type Job } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { toast } from '$lib/toast';
|
||||
import Button from './ui/Button.svelte';
|
||||
import Icon from './ui/Icon.svelte';
|
||||
import type { IconName } from './ui/icons';
|
||||
import ProgressBar from './ui/ProgressBar.svelte';
|
||||
import StatusChip from './ui/StatusChip.svelte';
|
||||
|
||||
interface Props {
|
||||
job: Job;
|
||||
/** Optional override for the leading label (defaults to "Kind #id"). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { job, label }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
let busy = $state(false);
|
||||
|
||||
const kindIcon: IconName = $derived(
|
||||
job.kind === 'video'
|
||||
? 'video'
|
||||
: job.kind === 'image'
|
||||
? 'image'
|
||||
: job.kind === 'export'
|
||||
? 'film'
|
||||
: 'queue',
|
||||
);
|
||||
const kindLabel = $derived(
|
||||
job.kind === 'export'
|
||||
? t('job.exportFilm')
|
||||
: job.kind.charAt(0).toUpperCase() + job.kind.slice(1),
|
||||
);
|
||||
const title = $derived(label ?? t('job.title', { kind: kindLabel, id: String(job.id) }));
|
||||
const active = $derived(job.status === 'pending' || job.status === 'running');
|
||||
const entry = $derived(progressFor(job.id));
|
||||
|
||||
async function refresh() {
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
busy = true;
|
||||
try {
|
||||
await jobsApi.cancel(job.id);
|
||||
await refresh();
|
||||
toast.info(t('job.cancelled', { id: String(job.id) }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('job.cancelFailed'));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
busy = true;
|
||||
try {
|
||||
await jobsApi.retry(job.id);
|
||||
await refresh();
|
||||
toast.success(t('job.requeued', { id: String(job.id) }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('job.retryFailed'));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="job-row">
|
||||
<span class="kind" aria-hidden="true"><Icon name={kindIcon} size={14} /></span>
|
||||
<div class="main">
|
||||
<div class="top">
|
||||
<span class="label" {title}>{title}</span>
|
||||
<StatusChip status={job.status} />
|
||||
</div>
|
||||
{#if active}
|
||||
<div class="bar">
|
||||
<ProgressBar
|
||||
size="sm"
|
||||
value={entry?.progress ?? 0}
|
||||
indeterminate={job.status === 'running' && entry == null}
|
||||
label={entry?.message}
|
||||
/>
|
||||
</div>
|
||||
{:else if job.status === 'failed' && job.error}
|
||||
<p class="err" title={job.error}>{job.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if active}
|
||||
<Button size="sm" variant="ghost" loading={busy} title={t('job.cancelJob')} onclick={cancel}>
|
||||
<Icon name="close" size={13} />
|
||||
</Button>
|
||||
{:else if job.status === 'failed'}
|
||||
<Button size="sm" variant="ghost" loading={busy} title={t('job.retryJob')} onclick={retry}>
|
||||
<Icon name="retry" size={13} />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.job-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.kind {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 20px;
|
||||
}
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bar {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.err {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--error);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,137 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
|
||||
const items = $derived(
|
||||
[
|
||||
{ id: 'story', key: 'nav.story', icon: 'story' },
|
||||
{ id: 'assets', key: 'nav.assets', icon: 'assets' },
|
||||
{ id: 'script', key: 'nav.script', icon: 'script' },
|
||||
{ id: 'video', key: 'nav.video', icon: 'video' },
|
||||
] as { id: string; key: string; icon: IconName }[],
|
||||
);
|
||||
|
||||
interface Props {
|
||||
active?: string;
|
||||
onSelect?: (id: string) => void;
|
||||
/** Optional per-stage counts — a nonzero value shows a small pill on the icon. */
|
||||
badges?: Record<string, number>;
|
||||
}
|
||||
|
||||
let { active = 'story', onSelect, badges = {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<nav class="nav" aria-label={t('nav.projectStages')}>
|
||||
<button
|
||||
class="nav-item home"
|
||||
title={t('nav.projects')}
|
||||
aria-label={t('nav.backProjects')}
|
||||
onclick={() => goto('/projects')}
|
||||
>
|
||||
<span class="icon"><Icon name="home" size={20} /></span>
|
||||
<span class="label">{t('nav.home')}</span>
|
||||
</button>
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
{#each items as item (item.id)}
|
||||
{@const count = badges[item.id] ?? 0}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={active === item.id}
|
||||
title={t(item.key)}
|
||||
aria-current={active === item.id ? 'page' : undefined}
|
||||
onclick={() => onSelect?.(item.id)}
|
||||
>
|
||||
<span class="icon">
|
||||
<Icon name={item.icon} size={20} />
|
||||
{#if count > 0}
|
||||
<span class="badge" aria-label={t('nav.countActive', { count })}>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="label">{t(item.key)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.nav {
|
||||
width: 64px;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-md) 0;
|
||||
gap: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.divider {
|
||||
width: 32px;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 2px 0 4px;
|
||||
}
|
||||
.nav-item {
|
||||
width: 56px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
box-shadow: 0 0 16px var(--accent-glow);
|
||||
}
|
||||
.nav-item.home:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.nav-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
line-height: 1;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -12px;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 0 2px var(--bg-surface);
|
||||
}
|
||||
.nav-item.active .badge {
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -346,6 +346,16 @@
|
||||
value={values[nodeId] ?? ctrl.input.defaultValue ?? ''}
|
||||
oninput={(e) => setValue(nodeId, Number(e.currentTarget.value) || 0)}
|
||||
/>
|
||||
{:else if ctrl.input.kind === 'select'}
|
||||
<select
|
||||
class="adv-input"
|
||||
value={String(values[nodeId] ?? ctrl.input.defaultValue ?? '')}
|
||||
onchange={(e) => setValue(nodeId, e.currentTarget.value)}
|
||||
>
|
||||
{#each ctrl.input.options ?? [] as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
class="adv-input"
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { assetUrl, projects, type Project } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
const { project }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
const stats = $derived(
|
||||
project.stats ?? {
|
||||
scene_count: 0,
|
||||
character_count: 0,
|
||||
asset_ready_count: 0,
|
||||
asset_total_count: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// Explicit project cover wins; otherwise fall back to the first asset image.
|
||||
// The project prop is stable for the lifetime of a card, so the eager
|
||||
// queryKey capture below is intentional.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const assetsQuery = createQuery({
|
||||
queryKey: ['assets', project.id],
|
||||
queryFn: () => projects.getAssets(project.id),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const thumbUrl = $derived.by(() => {
|
||||
if (project.cover_path) return assetUrl(project.cover_path);
|
||||
const data = $assetsQuery.data;
|
||||
if (!data) return null;
|
||||
const char = data.characters.find((c) => c.portrait_path || c.sheet_path);
|
||||
const path =
|
||||
char?.portrait_path ??
|
||||
char?.sheet_path ??
|
||||
data.locations.find((l) => l.reference_image_path)?.reference_image_path ??
|
||||
null;
|
||||
return assetUrl(path);
|
||||
});
|
||||
|
||||
// A broken cover (deleted file, 404) falls back to the gradient tile.
|
||||
let thumbFailedFor = $state<string | null>(null);
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
|
||||
if (mins < 1) return t('projectCard.timeJustNow');
|
||||
if (mins < 60) return t('projectCard.timeM', { count: mins });
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return t('projectCard.timeH', { count: hours });
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 30) return t('projectCard.timeD', { count: days });
|
||||
const months = Math.round(days / 30);
|
||||
if (months < 12) return t('projectCard.timeMo', { count: months });
|
||||
return t('projectCard.timeY', { count: Math.round(months / 12) });
|
||||
}
|
||||
|
||||
const updatedLabel = $derived(relativeTime(project.updated_at));
|
||||
const updatedTitle = $derived(new Date(project.updated_at).toLocaleString());
|
||||
|
||||
// Overflow menu (⋯) — closes on outside click, focus leaving, or Esc.
|
||||
let menuOpen = $state(false);
|
||||
let menuEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') menuOpen = false;
|
||||
};
|
||||
const onFocusIn = (e: FocusEvent) => {
|
||||
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
|
||||
};
|
||||
window.addEventListener('click', onDocClick);
|
||||
window.addEventListener('keydown', onKey);
|
||||
document.addEventListener('focusin', onFocusIn);
|
||||
return () => {
|
||||
window.removeEventListener('click', onDocClick);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('focusin', onFocusIn);
|
||||
};
|
||||
});
|
||||
|
||||
let renameOpen = $state(false);
|
||||
let renameValue = $state('');
|
||||
|
||||
const renameMutation = createMutation({
|
||||
mutationFn: (title: string) => projects.update(project.id, { title }),
|
||||
onSuccess: (updated) => {
|
||||
client.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success(t('projectCard.renamedTo', { title: updated.title }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.renameFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
function openRename() {
|
||||
menuOpen = false;
|
||||
renameValue = project.title;
|
||||
renameOpen = true;
|
||||
}
|
||||
|
||||
function submitRename() {
|
||||
const title = renameValue.trim();
|
||||
renameOpen = false;
|
||||
if (!title || title === project.title) return;
|
||||
$renameMutation.mutate(title);
|
||||
}
|
||||
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
const deleteMutation = createMutation({
|
||||
mutationFn: () => projects.delete(project.id),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success(t('projectCard.deleted', { title: project.title }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.deleteFailed'));
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<article class="project-card">
|
||||
<button
|
||||
type="button"
|
||||
class="card-main"
|
||||
aria-label={t('projectCard.open', { title: project.title })}
|
||||
onclick={() => goto(`/project/${project.id}`)}
|
||||
>
|
||||
<div class="card-thumb">
|
||||
{#if thumbUrl && thumbFailedFor !== thumbUrl}
|
||||
<img
|
||||
class="thumb-img"
|
||||
src={thumbUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror={() => (thumbFailedFor = thumbUrl)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="card-thumb-icon"><Icon name="video" size={26} /></div>
|
||||
{/if}
|
||||
<div class="card-status">
|
||||
<StatusChip status={project.status} label={t(`projectCard.status.${project.status}`)} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
{project.title}
|
||||
<span class="open-arrow">→</span>
|
||||
</div>
|
||||
<p class="card-desc">{project.idea || t('projectCard.noIdea')}</p>
|
||||
<div class="card-meta">
|
||||
<div class="card-stats">
|
||||
<span class="card-stat">
|
||||
<Icon name="script" size={13} />
|
||||
{t('projectCard.scenes', { count: stats.scene_count })}
|
||||
</span>
|
||||
<span class="card-stat">
|
||||
<Icon name="assets" size={13} />
|
||||
{t('projectCard.characters', { count: stats.character_count })}
|
||||
</span>
|
||||
<span class="card-stat">
|
||||
<Icon name="image" size={13} />
|
||||
{t('projectCard.assets', {
|
||||
ready: stats.asset_ready_count,
|
||||
total: stats.asset_total_count,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<span title={updatedTitle}>{updatedLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="menu-wrap" bind:this={menuEl}>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-btn"
|
||||
aria-label={t('projectCard.actions', { title: project.title })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div class="menu" role="menu" aria-label={t('projectCard.menuLabel')}>
|
||||
<button type="button" role="menuitem" class="menu-item" onclick={openRename}>
|
||||
<Icon name="edit" size={14} />
|
||||
{t('common.rename')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="menu-item"
|
||||
onclick={() => {
|
||||
menuOpen = false;
|
||||
goto(`/project/${project.id}?stage=assets`);
|
||||
}}
|
||||
>
|
||||
<Icon name="image" size={14} />
|
||||
{t('projectCard.changeCover')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="menu-item danger"
|
||||
onclick={() => {
|
||||
menuOpen = false;
|
||||
deleteOpen = true;
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<Modal bind:open={renameOpen} title={t('projectCard.renameTitle')}>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitRename();
|
||||
}}
|
||||
>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('common.title')}</span>
|
||||
<input class="field-input" bind:value={renameValue} required />
|
||||
</label>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (renameOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!renameValue.trim() || $renameMutation.isPending}
|
||||
onclick={submitRename}
|
||||
>
|
||||
{t('common.rename')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('projectCard.deleteTitle')}
|
||||
message={t('projectCard.deleteMessage', { title: project.title })}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onconfirm={() => $deleteMutation.mutate()}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.project-card {
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.project-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.project-card:hover .open-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.card-main {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-main:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.card-thumb {
|
||||
height: 170px;
|
||||
background: linear-gradient(135deg, var(--bg-elevated) 0%, #1a1625 100%);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-thumb::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(139, 92, 246, 0.12) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, rgba(59, 130, 246, 0.08) 0%, transparent 40%);
|
||||
}
|
||||
.thumb-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.card-thumb-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.card-status {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
.card-body {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-xs);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.open-arrow {
|
||||
color: var(--accent);
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
}
|
||||
.card-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 var(--space-md);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.menu-wrap {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 3;
|
||||
}
|
||||
.menu-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(10, 10, 12, 0.55);
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.project-card:hover .menu-btn,
|
||||
.menu-btn:focus-visible,
|
||||
.menu-btn[aria-expanded='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
.menu-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.menu-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
left: 0;
|
||||
min-width: 140px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.menu-item.danger {
|
||||
color: var(--error);
|
||||
}
|
||||
.menu-item.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.project-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,867 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { assetUrl, jobsApi, projects, type Job, type Scene, type Clip } from '$lib/api';
|
||||
import { estimateTargetSeconds } from '$lib/durationBudget';
|
||||
import { agentDeepLink } from '$lib/agentTasks';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
let { projectId }: Props = $props();
|
||||
const client = useQueryClient();
|
||||
let editing = $state<Scene | null>(null);
|
||||
let editOpen = $state(false);
|
||||
let highlightId = $state<number | null>(null);
|
||||
let adding = $state(false);
|
||||
let deletingId = $state<number | null>(null);
|
||||
let sceneToDelete = $state<Scene | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
const scenesQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['scenes', projectId],
|
||||
queryFn: () => projects.getScenes(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
// Shared cache key with QueueStage — clip job state shows up on scene cards
|
||||
// too. Refreshed via SSE invalidation from the project page, not polling.
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs', projectId],
|
||||
queryFn: () => jobsApi.list(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
// Cache-shared with the parent's story query — only used for the target-length hint.
|
||||
const storyQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['story', projectId],
|
||||
queryFn: () => projects.getStory(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
// Cache-shared with AssetsStage — resolves scene location chips.
|
||||
const assetsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['assets', projectId],
|
||||
queryFn: () => projects.getAssets(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
const scenes = $derived($scenesQuery.data?.scenes ?? []);
|
||||
const sceneCount = $derived(scenes.length);
|
||||
const totalClips = $derived(scenes.reduce((n, s) => n + (s.clips?.length ?? 0), 0));
|
||||
const totalSec = $derived($scenesQuery.data?.estimated_duration_sec ?? 0);
|
||||
const targetSec = $derived.by(() => {
|
||||
const target = $storyQuery.data?.project?.target_duration;
|
||||
return target ? estimateTargetSeconds(target) : 0;
|
||||
});
|
||||
|
||||
const busy = $derived(adding || deletingId != null);
|
||||
|
||||
// ── Break into shots (coverage expansion) ────────────────────────────
|
||||
let expandingIds = $state<number[]>([]);
|
||||
let expandingAll = $state(false);
|
||||
let expandedIds = $state<number[]>([]);
|
||||
|
||||
function isExpanded(scene: Scene): boolean {
|
||||
return (scene.clips?.length ?? 0) > 1 || expandedIds.includes(scene.id);
|
||||
}
|
||||
|
||||
async function breakIntoShots(scene: Scene) {
|
||||
if (expandingIds.length > 0 || expandingAll) return;
|
||||
expandingIds = [...expandingIds, scene.id];
|
||||
try {
|
||||
const res = await projects.expandClips(projectId, { scene_ids: [scene.id] });
|
||||
expandedIds = [...new Set([...expandedIds, scene.id])];
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(
|
||||
t('script.toast.breakIntoShots', {
|
||||
num: scene.order_index,
|
||||
clips: res.scenes?.[0]?.clip_count ?? '?',
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.breakFail'));
|
||||
} finally {
|
||||
expandingIds = expandingIds.filter((id) => id !== scene.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function breakAllIntoShots() {
|
||||
if (expandingIds.length > 0 || expandingAll) return;
|
||||
if (
|
||||
!window.confirm(t('script.toast.breakAllConfirm', { count: sceneCount }))
|
||||
)
|
||||
return;
|
||||
expandingAll = true;
|
||||
try {
|
||||
const res = await projects.expandClips(projectId, { all: true });
|
||||
expandedIds = scenes.map((s) => s.id);
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.boardExpanded', { count: res.total_clips }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.breakAllFail'));
|
||||
} finally {
|
||||
expandingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClip(clip: Clip, scene: Scene) {
|
||||
try {
|
||||
await projects.deleteClip(projectId, clip.id);
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.deletedShot', { num: `${scene.order_index}.${clip.order_index}` }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.deleteClipFail'));
|
||||
}
|
||||
}
|
||||
|
||||
const saveMutation = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateScene(projectId, editing!.id, {
|
||||
heading: editing!.heading,
|
||||
action: editing!.action,
|
||||
dialog: editing!.dialog,
|
||||
duration_sec: editing!.duration_sec,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editing = null;
|
||||
editOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.sceneSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.saveSceneFail'));
|
||||
},
|
||||
});
|
||||
|
||||
// Chain-from-previous toggle — persists on the scene; consumed at render time.
|
||||
let chainPendingId = $state<number | null>(null);
|
||||
async function toggleChain(scene: Scene) {
|
||||
if (chainPendingId != null) return;
|
||||
chainPendingId = scene.id;
|
||||
try {
|
||||
await projects.updateScene(projectId, scene.id, {
|
||||
chain_from_prev: !scene.chain_from_prev,
|
||||
});
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.updateSceneFail'));
|
||||
} finally {
|
||||
chainPendingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function jobForScene(sceneId: number): Job | undefined {
|
||||
const jobs = ($jobsQuery.data ?? []).filter(
|
||||
(j) => j.scene_id === sceneId && j.kind === 'video',
|
||||
);
|
||||
if (jobs.length === 0) return undefined;
|
||||
return [...jobs].sort((a, b) => b.id - a.id)[0];
|
||||
}
|
||||
|
||||
function jobForClip(clipId: number): Job | undefined {
|
||||
const jobs = ($jobsQuery.data ?? []).filter(
|
||||
(j) => (j as Job & { clip_id?: number | null }).clip_id === clipId && j.kind === 'video',
|
||||
);
|
||||
if (jobs.length === 0) return undefined;
|
||||
return [...jobs].sort((a, b) => b.id - a.id)[0];
|
||||
}
|
||||
|
||||
/** Per-scene production status — only uses fields the API actually exposes. */
|
||||
function sceneStatus(scene: Scene): { status: string; label: string } {
|
||||
const job = jobForScene(scene.id);
|
||||
if (job && (job.status === 'pending' || job.status === 'running')) {
|
||||
return { status: 'generating', label: t('script.status.rendering') };
|
||||
}
|
||||
if (job?.status === 'failed') return { status: 'failed', label: t('script.status.renderFailed') };
|
||||
if (scene.video_path || job?.status === 'done') {
|
||||
return { status: 'ready', label: t('script.status.clipReady') };
|
||||
}
|
||||
return { status: 'idle', label: t('script.status.noClip') };
|
||||
}
|
||||
|
||||
function locationName(scene: Scene): string | null {
|
||||
if (scene.location_id == null) return null;
|
||||
const locs = $assetsQuery.data?.locations ?? [];
|
||||
return locs.find((l) => l.id === scene.location_id)?.name ?? null;
|
||||
}
|
||||
|
||||
function avatarFor(char: { portrait_path: string | null; sheet_path: string | null }) {
|
||||
return assetUrl(char.portrait_path ?? char.sheet_path);
|
||||
}
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0].toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Hide a broken avatar image (portrait moved/deleted on disk), revealing initials. */
|
||||
function hideBrokenAvatar(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement | null;
|
||||
if (img) img.style.display = 'none';
|
||||
}
|
||||
|
||||
function formatClock(sec: number): string {
|
||||
const s = Math.max(0, Math.round(sec));
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${r.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function move(sceneId: number, dir: -1 | 1) {
|
||||
if (busy) return;
|
||||
const list = $scenesQuery.data?.scenes ?? [];
|
||||
const idx = list.findIndex((s) => s.id === sceneId);
|
||||
const swap = idx + dir;
|
||||
if (idx < 0 || swap < 0 || swap >= list.length) return;
|
||||
const ids = list.map((s) => s.id);
|
||||
[ids[idx], ids[swap]] = [ids[swap], ids[idx]];
|
||||
await projects.reorderScenes(projectId, ids);
|
||||
client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
}
|
||||
|
||||
/** Hand off script regeneration to a fresh, project-linked agent chat. */
|
||||
function requestRegenerate() {
|
||||
if (busy) return;
|
||||
goto(agentDeepLink(projectId, 'script'));
|
||||
}
|
||||
|
||||
function openEdit(scene: Scene) {
|
||||
editing = { ...scene };
|
||||
editOpen = true;
|
||||
}
|
||||
|
||||
async function addScene() {
|
||||
if (busy) return;
|
||||
adding = true;
|
||||
try {
|
||||
const existing = $scenesQuery.data?.scenes ?? [];
|
||||
const nextIndex = existing.length + 1;
|
||||
const created = await projects.createScene(projectId, {
|
||||
order_index: nextIndex,
|
||||
heading: `NEW SCENE ${nextIndex}`,
|
||||
action: '',
|
||||
dialog: '',
|
||||
duration_sec: 5,
|
||||
});
|
||||
// Keep order_index contiguous 1..n so Regenerate sees the real board size
|
||||
const ids = [...existing.map((s) => s.id), created.id];
|
||||
await projects.reorderScenes(projectId, ids);
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
await client.refetchQueries({ queryKey: ['scenes', projectId] });
|
||||
editing = { ...created, order_index: nextIndex, heading: `NEW SCENE ${nextIndex}` };
|
||||
editOpen = true;
|
||||
highlightId = created.id;
|
||||
await tick();
|
||||
document
|
||||
.getElementById(`scene-${created.id}`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
toast.success(t('script.toast.sceneAdded', { num: nextIndex }));
|
||||
window.setTimeout(() => {
|
||||
if (highlightId === created.id) highlightId = null;
|
||||
}, 2800);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.addSceneFail'));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestDelete(scene: Scene) {
|
||||
if (busy) return;
|
||||
sceneToDelete = scene;
|
||||
deleteOpen = true;
|
||||
}
|
||||
|
||||
async function confirmDeleteScene() {
|
||||
const scene = sceneToDelete;
|
||||
sceneToDelete = null;
|
||||
if (!scene || busy) return;
|
||||
const label = scene.heading?.trim() || `Scene #${scene.order_index}`;
|
||||
deletingId = scene.id;
|
||||
try {
|
||||
await projects.deleteScene(projectId, scene.id);
|
||||
if (editing?.id === scene.id) {
|
||||
editing = null;
|
||||
editOpen = false;
|
||||
}
|
||||
const remaining = ($scenesQuery.data?.scenes ?? [])
|
||||
.filter((s) => s.id !== scene.id)
|
||||
.map((s) => s.id);
|
||||
if (remaining.length > 0) {
|
||||
await projects.reorderScenes(projectId, remaining);
|
||||
}
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.deleted', { name: label }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.deleteFailed'));
|
||||
} finally {
|
||||
deletingId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="stage-header">
|
||||
<div>
|
||||
<h2>3. Script</h2>
|
||||
<p class="muted">
|
||||
Estimated duration: {formatClock(totalSec)}
|
||||
{#if targetSec > 0}
|
||||
/ target ~{formatClock(targetSec)}
|
||||
{/if}
|
||||
{#if sceneCount > 0}
|
||||
· {sceneCount} scenes
|
||||
{#if totalClips > 0}
|
||||
/ {totalClips} shot clips
|
||||
{/if}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="stage-actions">
|
||||
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
|
||||
<Icon name="plus" size={14} /> {t('script.addScene')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy || expandingAll || expandingIds.length > 0 || sceneCount === 0}
|
||||
loading={expandingAll}
|
||||
onclick={breakAllIntoShots}
|
||||
title={t('script.breakAllTitle')}
|
||||
>
|
||||
<Icon name="film" size={14} /> {t('script.breakAllIntoShots')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={busy} onclick={requestRegenerate}>
|
||||
<Icon name="sparkle" size={15} /> {t('script.regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="script-panel">
|
||||
{#if $scenesQuery.isLoading}
|
||||
<div class="card">{t('script.loadingScenes')}</div>
|
||||
{:else if sceneCount === 0}
|
||||
<EmptyState
|
||||
title={t('script.noScenesTitle')}
|
||||
body={t('script.noScenesBody')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Icon name="script" size={28} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
|
||||
<Icon name="plus" size={14} /> {t('script.addScene')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
{#each scenes as scene, i (scene.id)}
|
||||
{@const st = sceneStatus(scene)}
|
||||
{@const locName = locationName(scene)}
|
||||
<article
|
||||
id="scene-{scene.id}"
|
||||
class="card scene"
|
||||
class:fresh={highlightId === scene.id}
|
||||
class:deleting={deletingId === scene.id}
|
||||
>
|
||||
<div class="scene-head">
|
||||
<div class="scene-title">
|
||||
<span class="grip" aria-hidden="true"><Icon name="drag" size={14} /></span>
|
||||
<span class="num">#{scene.order_index}</span>
|
||||
<strong>{scene.heading || t('script.untitledScene')}</strong>
|
||||
{#if highlightId === scene.id}
|
||||
<span class="fresh-tag">{t('script.justAdded')}</span>
|
||||
{/if}
|
||||
<StatusChip status={st.status} label={st.label} />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('script.moveSceneUp')}
|
||||
title={t('script.moveSceneUp')}
|
||||
disabled={busy || i === 0}
|
||||
onclick={() => move(scene.id, -1)}
|
||||
>
|
||||
<Icon name="chevron-up" size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('script.moveSceneDown')}
|
||||
title={t('script.moveSceneDown')}
|
||||
disabled={busy || i === sceneCount - 1}
|
||||
onclick={() => move(scene.id, 1)}
|
||||
>
|
||||
<Icon name="chevron-down" size={15} />
|
||||
</button>
|
||||
<Button variant="secondary" size="sm" disabled={busy} onclick={() => openEdit(scene)}>
|
||||
{t('script.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
loading={deletingId === scene.id}
|
||||
onclick={() => requestDelete(scene)}
|
||||
>
|
||||
{t('script.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if scene.action}
|
||||
<p class="muted">{scene.action}</p>
|
||||
{:else}
|
||||
<p class="muted empty-line">{t('script.noActionYet')}</p>
|
||||
{/if}
|
||||
{#if scene.dialog}
|
||||
<pre class="dialog">{scene.dialog}</pre>
|
||||
{/if}
|
||||
<div class="clip-block">
|
||||
<div class="clip-head">
|
||||
<span class="clip-title">
|
||||
<Icon name="film" size={12} />
|
||||
{(scene.clips ?? []).length}
|
||||
{((scene.clips ?? []).length === 1 ? t('script.shotClipSingular') : t('script.shotClipPlural', { n: (scene.clips ?? []).length }))}
|
||||
{#if (scene.clips ?? []).some((c) => c.clip_path)}
|
||||
· {t('script.renderedCount', { count: (scene.clips ?? []).filter((c) => c.clip_path).length })}
|
||||
{/if}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={busy || expandingIds.length > 0 || expandingAll}
|
||||
loading={expandingIds.includes(scene.id)}
|
||||
onclick={() => breakIntoShots(scene)}
|
||||
title={isExpanded(scene) ? t('script.rebreakTitle') : t('script.breakTitle')}
|
||||
>
|
||||
{isExpanded(scene) ? t('script.rebreak') : t('script.break')}
|
||||
</Button>
|
||||
</div>
|
||||
{#if isExpanded(scene) && (scene.clips ?? []).length > 1}
|
||||
<ul class="clip-list">
|
||||
{#each scene.clips ?? [] as clip (clip.id)}
|
||||
{@const clipJob = jobForClip(clip.id)}
|
||||
<li class="clip-row">
|
||||
<span class="clip-num" class:ready={Boolean(clip.clip_path)}>
|
||||
#{scene.order_index}.{clip.order_index}
|
||||
</span>
|
||||
<span class="clip-desc">{clip.description || t('script.untitledShot')}</span>
|
||||
{#if clip.shot_size}
|
||||
<span class="chip">{clip.shot_size}</span>
|
||||
{/if}
|
||||
{#if clipJob && (clipJob.status === 'pending' || clipJob.status === 'running')}
|
||||
<span class="chip chip-render">{t('script.rendering')}</span>
|
||||
{:else if clip.clip_path}
|
||||
<span class="chip chip-ok">✓ {t('script.clipDone')}</span>
|
||||
{/if}
|
||||
{#if clip.duration_sec}
|
||||
<span class="clip-dur">{clip.duration_sec}s</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('script.deleteShotAria', { id: `${scene.order_index}.${clip.order_index}` })}
|
||||
title={t('script.deleteShotTitle')}
|
||||
onclick={() => deleteClip(clip, scene)}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="chips">
|
||||
{#each scene.characters ?? [] as c (c.id)}
|
||||
{@const avatar = avatarFor(c)}
|
||||
<span class="chip">
|
||||
<span class="avatar">
|
||||
<span class="initials" aria-hidden="true">{initialsOf(c.name)}</span>
|
||||
{#if avatar}
|
||||
<img src={avatar} alt="" loading="lazy" onerror={hideBrokenAvatar} />
|
||||
{/if}
|
||||
</span>
|
||||
{c.name}
|
||||
</span>
|
||||
{/each}
|
||||
{#if locName}
|
||||
<span class="chip"><Icon name="folder" size={12} /> {locName}</span>
|
||||
{/if}
|
||||
{#if scene.duration_sec}
|
||||
<span class="chip"><Icon name="clock" size={12} /> {formatClock(scene.duration_sec)}</span>
|
||||
{/if}
|
||||
{#if i > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="chip chip-toggle"
|
||||
class:chip-on={Boolean(scene.chain_from_prev)}
|
||||
disabled={chainPendingId === scene.id}
|
||||
title={t('script.chainTitle')}
|
||||
onclick={() => toggleChain(scene)}
|
||||
>
|
||||
<Icon name="film" size={12} />
|
||||
{Boolean(scene.chain_from_prev) ? t('script.chainOn') : t('script.chainOff')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
bind:open={editOpen}
|
||||
title={editing ? t('script.editSceneNumbered', { n: editing.order_index }) : t('script.editScene')}
|
||||
onclose={() => (editing = null)}
|
||||
>
|
||||
{#if editing}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.headingField')}</span>
|
||||
<input class="field-input" bind:value={editing.heading} placeholder={t('script.headingPlaceholder')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.actionField')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={editing.action}
|
||||
rows="4"
|
||||
placeholder={t('script.actionPlaceholder')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.dialogField')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={editing.dialog}
|
||||
rows="4"
|
||||
placeholder={t('script.dialogPlaceholder')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.durationField')}</span>
|
||||
<input class="field-input" type="number" bind:value={editing.duration_sec} min="1" />
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (editOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={$saveMutation.isPending}
|
||||
onclick={() => $saveMutation.mutate()}
|
||||
>
|
||||
{t('script.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('script.deleteSceneTitle')}
|
||||
message={sceneToDelete
|
||||
? t('script.deleteSceneMessage', {
|
||||
heading: sceneToDelete.heading?.trim() || t('script.sceneNumber', { n: sceneToDelete.order_index }),
|
||||
})
|
||||
: ''}
|
||||
confirmLabel={t('script.delete')}
|
||||
danger
|
||||
onconfirm={() => void confirmDeleteScene()}
|
||||
oncancel={() => (sceneToDelete = null)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.stage-header {
|
||||
/* Pins inside the stage scroll container so Add Scene / Regenerate
|
||||
stay reachable while scrolling long scripts. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
padding: 8px 0 12px;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.stage-header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stage-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.script-panel {
|
||||
position: relative;
|
||||
min-height: 220px;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: var(--space-md);
|
||||
scroll-margin-top: 84px;
|
||||
scroll-margin-bottom: 24px;
|
||||
}
|
||||
.scene {
|
||||
transition:
|
||||
border-color 0.25s ease,
|
||||
box-shadow 0.25s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
.scene.fresh {
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--accent),
|
||||
0 0 24px var(--accent-glow);
|
||||
animation: scene-in 0.45s ease-out;
|
||||
}
|
||||
.scene.deleting {
|
||||
opacity: 0.45;
|
||||
}
|
||||
@keyframes scene-in {
|
||||
from {
|
||||
opacity: 0.35;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scene.fresh {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.scene-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.scene-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.grip {
|
||||
display: inline-flex;
|
||||
color: var(--text-muted);
|
||||
cursor: grab;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.num {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.fresh-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
background: var(--accent-glow);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.icon-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.empty-line {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dialog {
|
||||
background: var(--bg-elevated);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.chip-toggle {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.chip-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: #52525b;
|
||||
}
|
||||
.chip-toggle:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.chip-on {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-elevated));
|
||||
}
|
||||
.clip-block {
|
||||
margin-top: 10px;
|
||||
border-top: 1px dashed var(--border);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.clip-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.clip-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.clip-list {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.clip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.clip-num {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.clip-num.ready {
|
||||
color: var(--accent);
|
||||
}
|
||||
.clip-desc {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.clip-dur {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-ok {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.chip-render {
|
||||
color: var(--warning, #eab308);
|
||||
border-color: var(--warning, #eab308);
|
||||
}
|
||||
.avatar {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.avatar img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.initials {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,947 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
projects,
|
||||
type Beat,
|
||||
type Character,
|
||||
type Item,
|
||||
type Location,
|
||||
type StoryData,
|
||||
} from '$lib/api';
|
||||
import {
|
||||
buildStoryDraftPromptPreview,
|
||||
estimateTargetSeconds,
|
||||
recommendBeatCount,
|
||||
recommendSceneCount,
|
||||
} from '$lib/durationBudget';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { agentDeepLink } from '$lib/agentTasks';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
story: StoryData;
|
||||
configured: boolean;
|
||||
onGoAssets?: () => void;
|
||||
}
|
||||
|
||||
let { projectId, story, configured, onGoAssets }: Props = $props();
|
||||
const client = useQueryClient();
|
||||
|
||||
const GENRES = [
|
||||
'Adventure / Mystery',
|
||||
'Drama',
|
||||
'Sci-Fi',
|
||||
'Fantasy',
|
||||
'Horror',
|
||||
'Romance',
|
||||
'Thriller',
|
||||
];
|
||||
const LENGTHS_HINT = $derived(t('story.lengthsHint'));
|
||||
const TONES = [
|
||||
'Cinematic, atmospheric',
|
||||
'Dark, tense',
|
||||
'Whimsical, warm',
|
||||
'Gritty, realistic',
|
||||
'Epic, sweeping',
|
||||
];
|
||||
|
||||
const GENRE_KEYS: Record<string, string> = {
|
||||
'Adventure / Mystery': 'genres.adventure',
|
||||
Drama: 'genres.drama',
|
||||
'Sci-Fi': 'genres.scifi',
|
||||
Fantasy: 'genres.fantasy',
|
||||
Horror: 'genres.horror',
|
||||
Romance: 'genres.romance',
|
||||
Thriller: 'genres.thriller',
|
||||
};
|
||||
const TONE_KEYS: Record<string, string> = {
|
||||
'Cinematic, atmospheric': 'tones.cinematic',
|
||||
'Dark, tense': 'tones.dark',
|
||||
'Whimsical, warm': 'tones.whimsical',
|
||||
'Gritty, realistic': 'tones.gritty',
|
||||
'Epic, sweeping': 'tones.epic',
|
||||
};
|
||||
function genreLabel(v: string): string {
|
||||
return t(GENRE_KEYS[v] ?? v);
|
||||
}
|
||||
function toneLabel(v: string): string {
|
||||
return t(TONE_KEYS[v] ?? v);
|
||||
}
|
||||
|
||||
const EXAMPLE = {
|
||||
idea: 'A lone cartographer discovers a map that rewrites itself every midnight, leading her into a forgotten city beneath the desert.',
|
||||
genre: 'Adventure / Mystery',
|
||||
tone: 'Cinematic, atmospheric',
|
||||
target_duration: '2 minutes',
|
||||
};
|
||||
|
||||
let editingBeat = $state<Beat | null>(null);
|
||||
let editingChar = $state<Character | null>(null);
|
||||
let editingLoc = $state<Location | null>(null);
|
||||
let editingItem = $state<Item | null>(null);
|
||||
let beatModalOpen = $state(false);
|
||||
let charModalOpen = $state(false);
|
||||
let locModalOpen = $state(false);
|
||||
let itemModalOpen = $state(false);
|
||||
let confirmExampleOpen = $state(false);
|
||||
let deletingBeat = $state<Beat | null>(null);
|
||||
let beatDeleteOpen = $state(false);
|
||||
let addingBeat = $state(false);
|
||||
// Static defaults — the $effect below syncs all drafts from the story prop.
|
||||
let ideaDraft = $state('');
|
||||
let genreDraft = $state(GENRES[0]);
|
||||
let toneDraft = $state(TONES[0]);
|
||||
let lengthDraft = $state('2 minutes');
|
||||
let showDraftPrompt = $state(false);
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
let saveState = $state<SaveState>('idle');
|
||||
let savedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const beatBudget = $derived(recommendBeatCount(lengthDraft));
|
||||
const sceneBudget = $derived(recommendSceneCount(lengthDraft));
|
||||
const secsBudget = $derived(estimateTargetSeconds(lengthDraft));
|
||||
const draftPromptPreview = $derived(
|
||||
buildStoryDraftPromptPreview({
|
||||
title: story.project.title,
|
||||
idea: ideaDraft,
|
||||
genre: genreDraft,
|
||||
tone: toneDraft,
|
||||
targetDuration: lengthDraft,
|
||||
}),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
ideaDraft = story.project.idea ?? '';
|
||||
genreDraft = story.project.genre || GENRES[0];
|
||||
toneDraft = story.project.tone || TONES[0];
|
||||
lengthDraft = story.project.target_duration || '2 minutes';
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (savedTimer) clearTimeout(savedTimer);
|
||||
};
|
||||
});
|
||||
|
||||
function armSavedTimer() {
|
||||
if (savedTimer) clearTimeout(savedTimer);
|
||||
savedTimer = setTimeout(() => {
|
||||
if (saveState === 'saved') saveState = 'idle';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const saveProject = createMutation({
|
||||
mutationFn: (payload: {
|
||||
idea?: string;
|
||||
genre?: string;
|
||||
tone?: string;
|
||||
target_duration?: string;
|
||||
}) => projects.update(projectId, payload),
|
||||
onMutate: () => {
|
||||
saveState = 'saving';
|
||||
},
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
saveState = 'saved';
|
||||
armSavedTimer();
|
||||
},
|
||||
onError: (err) => {
|
||||
saveState = 'error';
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveSettingsFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
/** Autosave path — returns false on failure (mutation onError already toasted). */
|
||||
async function persistSettings(): Promise<boolean> {
|
||||
try {
|
||||
await $saveProject.mutateAsync({
|
||||
idea: ideaDraft,
|
||||
genre: genreDraft || undefined,
|
||||
tone: toneDraft || undefined,
|
||||
target_duration: lengthDraft || undefined,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExample() {
|
||||
ideaDraft = EXAMPLE.idea;
|
||||
genreDraft = EXAMPLE.genre;
|
||||
toneDraft = EXAMPLE.tone;
|
||||
lengthDraft = EXAMPLE.target_duration;
|
||||
const saved = await persistSettings();
|
||||
if (saved) toast.success(t('story.exampleLoaded'));
|
||||
}
|
||||
|
||||
function requestLoadExample() {
|
||||
if (ideaDraft.trim()) confirmExampleOpen = true;
|
||||
else void loadExample();
|
||||
}
|
||||
|
||||
/** Hand off to a fresh, project-linked agent chat with the composer pre-filled. */
|
||||
async function draftStoryline() {
|
||||
if (!configured) return;
|
||||
const saved = await persistSettings();
|
||||
if (!saved) return; // persistSettings already toasted the failure
|
||||
goto(agentDeepLink(projectId, 'story'));
|
||||
}
|
||||
|
||||
function openBeat(beat: Beat) {
|
||||
editingBeat = { ...beat };
|
||||
beatModalOpen = true;
|
||||
}
|
||||
|
||||
async function addBeat() {
|
||||
if (addingBeat) return;
|
||||
addingBeat = true;
|
||||
try {
|
||||
const nextIndex =
|
||||
story.beats.reduce((max, b) => Math.max(max, b.order_index), 0) + 1;
|
||||
// No api.ts binding exists yet for POST /beats — call the endpoint directly.
|
||||
const res = await fetch(`/api/projects/${projectId}/beats`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
order_index: nextIndex,
|
||||
title: t('story.newBeat', { n: nextIndex }),
|
||||
description: '',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => 'unknown error');
|
||||
throw new Error(`${res.status}: ${body}`);
|
||||
}
|
||||
const created = (await res.json()) as Beat;
|
||||
await client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.beatAdded', { index: nextIndex }));
|
||||
openBeat(created);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('story.addBeatFailed'));
|
||||
} finally {
|
||||
addingBeat = false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestDeleteBeat(beat: Beat) {
|
||||
deletingBeat = beat;
|
||||
beatDeleteOpen = true;
|
||||
}
|
||||
|
||||
const deleteBeatMutation = createMutation({
|
||||
mutationFn: (beatId: number) => projects.deleteBeat(projectId, beatId),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.beatDeleted'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.deleteBeatFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveBeat = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateBeat(projectId, editingBeat!.id, {
|
||||
title: editingBeat!.title,
|
||||
description: editingBeat!.description,
|
||||
order_index: editingBeat!.order_index,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingBeat = null;
|
||||
beatModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.beatSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveBeatFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveChar = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateCharacter(projectId, editingChar!.id, {
|
||||
name: editingChar!.name,
|
||||
role: editingChar!.role,
|
||||
age: editingChar!.age,
|
||||
appearance: editingChar!.appearance,
|
||||
personality: editingChar!.personality,
|
||||
consistency_prompt: editingChar!.consistency_prompt,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingChar = null;
|
||||
charModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.characterSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveCharacterFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveLoc = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateLocation(projectId, editingLoc!.id, {
|
||||
name: editingLoc!.name,
|
||||
description: editingLoc!.description,
|
||||
consistency_prompt: editingLoc!.consistency_prompt,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingLoc = null;
|
||||
locModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.locationSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveLocationFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveItem = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateItem(projectId, editingItem!.id, {
|
||||
name: editingItem!.name,
|
||||
description: editingItem!.description,
|
||||
consistency_prompt: editingItem!.consistency_prompt,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingItem = null;
|
||||
itemModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.itemSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveItemFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
function goToAssets() {
|
||||
goto('?stage=assets', { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
function beatCountLabel(n: number): string {
|
||||
return t('story.beats', { n });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet saveIndicator()}
|
||||
{#if saveState === 'saving'}
|
||||
<span class="save-ind"><Spinner size="sm" /> {t('common.saving')}</span>
|
||||
{:else if saveState === 'saved'}
|
||||
<span class="save-ind saved"><Icon name="check" size={13} /> {t('common.saved')}</span>
|
||||
{:else if saveState === 'error'}
|
||||
<span class="save-ind err">
|
||||
<Icon name="alert" size={13} /> {t('common.saveFailed')}
|
||||
<Button variant="ghost" size="sm" onclick={() => void persistSettings()}>{t('common.retry')}</Button>
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<header class="stage-header">
|
||||
<h2>{t('story.stageTitle')}</h2>
|
||||
<div class="stage-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={$saveProject.isPending}
|
||||
onclick={requestLoadExample}
|
||||
>
|
||||
{t('story.loadExample')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!configured} onclick={() => void draftStoryline()}>
|
||||
<Icon name="sparkle" size={15} /> {t('story.draftStoryline')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if !configured}
|
||||
<div class="banner">{t('story.llmBanner')}</div>
|
||||
{/if}
|
||||
|
||||
<div class="stack-col">
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.ideaTitle')}</h3>
|
||||
{@render saveIndicator()}
|
||||
{/snippet}
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={ideaDraft}
|
||||
rows="5"
|
||||
placeholder={t('story.ideaPlaceholder')}
|
||||
onblur={() => void persistSettings()}
|
||||
></textarea>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.settingsTitle')}</h3>
|
||||
{@render saveIndicator()}
|
||||
{/snippet}
|
||||
<div class="grid-3">
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.genre')}</span>
|
||||
<select
|
||||
class="field-select"
|
||||
bind:value={genreDraft}
|
||||
onchange={() => void persistSettings()}
|
||||
>
|
||||
{#each GENRES as g}
|
||||
<option value={g}>{genreLabel(g)}</option>
|
||||
{/each}
|
||||
{#if genreDraft && !GENRES.includes(genreDraft)}
|
||||
<option value={genreDraft}>{genreLabel(genreDraft)}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.targetLength')}</span>
|
||||
<input
|
||||
class="field-input"
|
||||
type="text"
|
||||
bind:value={lengthDraft}
|
||||
placeholder={LENGTHS_HINT}
|
||||
onblur={() => void persistSettings()}
|
||||
/>
|
||||
<span class="field-hint beat-hint">
|
||||
{t('story.lengthHint', { secs: secsBudget, beats: beatBudget, scenes: sceneBudget })}
|
||||
</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.tone')}</span>
|
||||
<select
|
||||
class="field-select"
|
||||
bind:value={toneDraft}
|
||||
onchange={() => void persistSettings()}
|
||||
>
|
||||
{#each TONES as tone}
|
||||
<option value={tone}>{toneLabel(tone)}</option>
|
||||
{/each}
|
||||
{#if toneDraft && !TONES.includes(toneDraft)}
|
||||
<option value={toneDraft}>{toneLabel(toneDraft)}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.draftPromptTitle')}</h3>
|
||||
<Button variant="ghost" size="sm" onclick={() => (showDraftPrompt = !showDraftPrompt)}>
|
||||
{showDraftPrompt ? t('story.hidePrompt') : t('story.showPrompt')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#if showDraftPrompt}
|
||||
<pre class="prompt-preview">{draftPromptPreview}</pre>
|
||||
<p class="field-hint">
|
||||
{t('story.promptExplain')}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="field-hint prompt-collapsed">
|
||||
{t('story.promptCollapsedHint')}
|
||||
</p>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.beatsTitle')}</h3>
|
||||
<div class="head-actions">
|
||||
{#if story.beats.length}
|
||||
<StatusChip status="ready" label={beatCountLabel(story.beats.length)} />
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
|
||||
<Icon name="plus" size={14} /> {t('story.addBeat')}
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#if story.beats.length === 0}
|
||||
<EmptyState
|
||||
title={t('story.noBeats')}
|
||||
body={t('story.noBeatsBody')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Icon name="story" size={28} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
|
||||
<Icon name="plus" size={14} /> {t('story.addBeatManual')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="beat-list">
|
||||
{#each story.beats as beat (beat.id)}
|
||||
<div class="beat-row">
|
||||
<button type="button" class="beat" onclick={() => openBeat(beat)}>
|
||||
<div class="beat-number">{beat.order_index}</div>
|
||||
<div class="beat-body">
|
||||
<h4>{beat.title}</h4>
|
||||
<p>{beat.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
<div class="beat-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={t('story.deleteBeat', { index: beat.order_index })}
|
||||
onclick={() => requestDeleteBeat(beat)}
|
||||
>
|
||||
<Icon name="trash" size={14} /><span class="sr-only">{t('story.deleteBeat', { index: beat.order_index })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="beats-footer">
|
||||
<Button variant="primary" onclick={goToAssets}>
|
||||
{t('story.continueAssets')} <Icon name="chevron-right" size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
{#if story.characters.length || story.locations.length || story.items.length}
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.extracted')}</h3>
|
||||
<Button variant="ghost" size="sm" onclick={goToAssets}>
|
||||
{t('story.openAssets')} <Icon name="chevron-right" size={14} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="chips">
|
||||
{#each story.characters as char (char.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => {
|
||||
editingChar = { ...char };
|
||||
charModalOpen = true;
|
||||
}}
|
||||
title={char.appearance ?? ''}
|
||||
>
|
||||
<span class="chip-tag">{t('story.charTag')}</span>
|
||||
{char.name}
|
||||
</button>
|
||||
{/each}
|
||||
{#each story.locations as loc (loc.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => {
|
||||
editingLoc = { ...loc };
|
||||
locModalOpen = true;
|
||||
}}
|
||||
title={loc.description ?? ''}
|
||||
>
|
||||
<span class="chip-tag">{t('story.envTag')}</span>
|
||||
{loc.name}
|
||||
</button>
|
||||
{/each}
|
||||
{#each story.items as item (item.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => {
|
||||
editingItem = { ...item };
|
||||
itemModalOpen = true;
|
||||
}}
|
||||
title={item.description ?? ''}
|
||||
>
|
||||
<span class="chip-tag">{t('story.itemTag')}</span>
|
||||
{item.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:open={beatModalOpen} title={t('story.editBeatTitle')} onclose={() => (editingBeat = null)}>
|
||||
{#if editingBeat}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldTitle')}</span>
|
||||
<input class="field-input" bind:value={editingBeat.title} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldDescription')}</span>
|
||||
<textarea class="field-textarea" rows="4" bind:value={editingBeat.description}></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (beatModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveBeat.isPending} onclick={() => $saveBeat.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={charModalOpen} title={t('story.editCharTitle')} onclose={() => (editingChar = null)}>
|
||||
{#if editingChar}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldName')}</span>
|
||||
<input class="field-input" bind:value={editingChar.name} placeholder={t('story.fieldName')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldRole')}</span>
|
||||
<input class="field-input" bind:value={editingChar.role} placeholder={t('story.fieldRole')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldAge')}</span>
|
||||
<input class="field-input" bind:value={editingChar.age} placeholder={t('story.fieldAge')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldAppearance')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
bind:value={editingChar.appearance}
|
||||
placeholder={t('story.fieldAppearance')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldPersonality')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingChar.personality}
|
||||
placeholder={t('story.fieldPersonality')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldConsistency')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingChar.consistency_prompt}
|
||||
placeholder={t('story.fieldConsistency')}
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (charModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveChar.isPending} onclick={() => $saveChar.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={locModalOpen} title={t('story.editLocTitle')} onclose={() => (editingLoc = null)}>
|
||||
{#if editingLoc}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldName')}</span>
|
||||
<input class="field-input" bind:value={editingLoc.name} placeholder={t('story.fieldName')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldDescription')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
bind:value={editingLoc.description}
|
||||
placeholder={t('story.fieldDescription')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldConsistency')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingLoc.consistency_prompt}
|
||||
placeholder={t('story.fieldConsistency')}
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (locModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveLoc.isPending} onclick={() => $saveLoc.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={itemModalOpen} title={t('story.editItemTitle')} onclose={() => (editingItem = null)}>
|
||||
{#if editingItem}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldName')}</span>
|
||||
<input class="field-input" bind:value={editingItem.name} placeholder={t('story.fieldName')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldDescription')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
bind:value={editingItem.description}
|
||||
placeholder={t('story.fieldDescription')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldConsistency')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingItem.consistency_prompt}
|
||||
placeholder={t('story.fieldConsistency')}
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (itemModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveItem.isPending} onclick={() => $saveItem.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmExampleOpen}
|
||||
title={t('story.confirmExampleTitle')}
|
||||
message={t('story.confirmExampleMsg')}
|
||||
confirmLabel={t('story.loadExample')}
|
||||
onconfirm={() => void loadExample()}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={beatDeleteOpen}
|
||||
title={t('story.confirmDeleteBeatTitle')}
|
||||
message={deletingBeat
|
||||
? t('story.confirmDeleteBeatMsg', { index: deletingBeat.order_index, title: deletingBeat.title })
|
||||
: ''}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onconfirm={() => {
|
||||
const id = deletingBeat?.id;
|
||||
deletingBeat = null;
|
||||
if (id != null) $deleteBeatMutation.mutate(id);
|
||||
}}
|
||||
oncancel={() => (deletingBeat = null)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.stage-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.stage-header h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stage-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.banner {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
color: var(--warning);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stack-col {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.card-h {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.save-ind {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.save-ind.saved {
|
||||
color: var(--success);
|
||||
animation: saved-fade 2s ease forwards;
|
||||
}
|
||||
.save-ind.err {
|
||||
color: var(--error);
|
||||
}
|
||||
@keyframes saved-fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
12% {
|
||||
opacity: 1;
|
||||
}
|
||||
78% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.grid-3 .field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.beat-hint {
|
||||
display: block;
|
||||
}
|
||||
.beat-hint strong {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.prompt-preview {
|
||||
margin: 0 0 var(--space-sm);
|
||||
padding: 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
color: var(--text-secondary);
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
.prompt-collapsed {
|
||||
margin: 0;
|
||||
}
|
||||
.beat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.beat-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.beat {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: var(--space-md);
|
||||
align-items: flex-start;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.beat:hover {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.beat:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.beat-actions {
|
||||
flex-shrink: 0;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.beat-number {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.beat-body h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.beat-body p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.beats-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-md);
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.chip-tag {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -37,8 +37,13 @@
|
||||
<span class="dot"></span>
|
||||
<Icon name={kindIcon[toast.kind]} size={15} />
|
||||
</span>
|
||||
<span class="msg">{toast.message}</span>
|
||||
<button
|
||||
<span class="msg">{toast.message}</span>
|
||||
{#if toast.action}
|
||||
<button type="button" class="action" onclick={() => (toast.action?.run(), dismissToast(toast.id))}>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="dismiss"
|
||||
aria-label={t('toast.dismiss')}
|
||||
@@ -52,15 +57,17 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* §四.5 Toast:右下角堆叠,220ms 上移入场;成功绿点 / 失败红点。 */
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
/* Above every overlay, incl. the 10000 lightbox, so feedback stays visible. */
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
max-width: min(400px, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
@@ -69,23 +76,10 @@
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
animation: toast-in 0.22s ease-out;
|
||||
}
|
||||
.toast.success {
|
||||
border-color: rgba(34, 197, 94, 0.45);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.45),
|
||||
0 0 0 1px rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.toast.error {
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
.toast.info {
|
||||
border-color: rgba(59, 130, 246, 0.45);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--float-bg);
|
||||
box-shadow: var(--shadow-float);
|
||||
animation: toast-in var(--d-enter) var(--ease-out);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
@@ -101,21 +95,44 @@
|
||||
background: currentColor;
|
||||
}
|
||||
.toast.success .badge {
|
||||
color: var(--success);
|
||||
color: var(--ok);
|
||||
}
|
||||
.toast.error .badge {
|
||||
color: var(--error);
|
||||
color: var(--err);
|
||||
}
|
||||
.toast.info .badge {
|
||||
color: var(--info);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.msg {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* §四.5: Toast 动作(如撤销)为金色文字 */
|
||||
.action {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.action:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
}
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.dismiss {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -127,10 +144,12 @@
|
||||
padding: 0 2px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
.dismiss:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -139,7 +158,7 @@
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -151,10 +170,12 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
:global(:root[data-reduce='1']) .toast {
|
||||
animation: none;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.toast-host {
|
||||
top: auto;
|
||||
bottom: 20px;
|
||||
bottom: calc(var(--tabbar-h) + var(--sp-5) + env(safe-area-inset-bottom));
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
max-width: none;
|
||||
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,827 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import {
|
||||
apiErrorMessage,
|
||||
assetUrl,
|
||||
playgroundApi,
|
||||
jobsApi,
|
||||
workflows,
|
||||
type ComfyEndpoint,
|
||||
type Job,
|
||||
type Workflow,
|
||||
} from '$lib/api';
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ProgressBar from '$lib/components/ui/ProgressBar.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import { mediaFileProblem, type MediaSlotKind } from '$lib/comfy/imageFile';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 测试工作流 (HEL-643 L3, E 批抽屉化): fill the workflow's REAL input
|
||||
* slots, submit one REAL run to the chosen ComfyUI through the ordinary
|
||||
* job queue, and call it a pass only when at least one downloadable
|
||||
* output of the right media kind came back. Test artifacts are isolated
|
||||
* from projects. Presentation follows HEL-649 §3.8: right-side drawer,
|
||||
* cyan progress + mono stage log while running, result thumbnails in a
|
||||
* horizontal row.
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
workflow: Workflow | null;
|
||||
endpoints: ComfyEndpoint[];
|
||||
onchanged?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
workflow,
|
||||
endpoints,
|
||||
onchanged,
|
||||
}: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
let endpointId = $state('');
|
||||
let values = $state<Record<string, string | number>>({});
|
||||
let listValues = $state<Record<string, string[]>>({});
|
||||
let sizes = $state<Record<string, number>>({});
|
||||
let uploadingName = $state<Record<string, string>>({});
|
||||
let rejects = $state<Record<string, { name: string; message: string }[]>>({});
|
||||
let submitError = $state('');
|
||||
let confirmOpen = $state(false);
|
||||
let submitting = $state(false);
|
||||
let testJobId = $state<number | null>(null);
|
||||
let errorExpanded = $state(false);
|
||||
let logBox = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const wf = $derived(workflow);
|
||||
const schema = $derived(wf?.input_schema ?? []);
|
||||
const listEntries = $derived(
|
||||
schema.filter(
|
||||
(i) =>
|
||||
i.kind === 'image_list' ||
|
||||
i.kind === 'video_list' ||
|
||||
i.kind === 'audio_list',
|
||||
),
|
||||
);
|
||||
const formEntries = $derived(
|
||||
schema.filter(
|
||||
(i) =>
|
||||
i.kind !== 'image_list' &&
|
||||
i.kind !== 'video_list' &&
|
||||
i.kind !== 'audio_list',
|
||||
),
|
||||
);
|
||||
const hasH3Hint = $derived(
|
||||
listEntries.some((e) => e.media_hint === 'minimax_h3_ref2va'),
|
||||
);
|
||||
const outputKind = $derived<'image' | 'video'>(
|
||||
wf?.kind === 'video' ? 'video' : 'image',
|
||||
);
|
||||
|
||||
const testsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['workflow-tests', wf?.id ?? 0],
|
||||
queryFn: () => workflows.tests(wf!.id),
|
||||
enabled: !!wf && open,
|
||||
refetchInterval: 2000,
|
||||
})),
|
||||
);
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs'],
|
||||
queryFn: () => jobsApi.list(),
|
||||
enabled: !!wf && open,
|
||||
refetchInterval: 5000,
|
||||
})),
|
||||
);
|
||||
const queuedCount = $derived(
|
||||
($jobsQuery.data ?? []).filter(
|
||||
(j) => j.status === 'pending' || j.status === 'running',
|
||||
).length,
|
||||
);
|
||||
|
||||
const history = $derived(($testsQuery.data ?? []) as Job[]);
|
||||
const activeJob = $derived.by(() => {
|
||||
if (testJobId != null) {
|
||||
const mine = history.find((j) => j.id === testJobId);
|
||||
if (mine && (mine.status === 'pending' || mine.status === 'running'))
|
||||
return mine;
|
||||
}
|
||||
return (
|
||||
history.find((j) => j.status === 'pending' || j.status === 'running') ??
|
||||
null
|
||||
);
|
||||
});
|
||||
const finishedJob = $derived.by(() => {
|
||||
if (testJobId != null)
|
||||
return history.find((j) => j.id === testJobId) ?? null;
|
||||
return null;
|
||||
});
|
||||
|
||||
const queueBusy = $derived(!!activeJob || submitting);
|
||||
const liveProgress = $derived(
|
||||
activeJob ? progressFor(activeJob.id) : undefined,
|
||||
);
|
||||
const activeInfo = $derived(activeJob ? jobStatusInfo(activeJob) : null);
|
||||
|
||||
// Rolling mono stage log (§3.8): one line per distinct worker message.
|
||||
let stageLog = $state<string[]>([]);
|
||||
$effect(() => {
|
||||
if (!activeJob || !activeInfo) {
|
||||
stageLog = [];
|
||||
return;
|
||||
}
|
||||
const line = liveProgress?.message || activeInfo.detail;
|
||||
if (!line) return;
|
||||
if (stageLog[stageLog.length - 1] !== line) stageLog = [...stageLog, line];
|
||||
});
|
||||
$effect(() => {
|
||||
stageLog.length;
|
||||
if (logBox) logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open || !wf) return;
|
||||
endpointId = wf.endpoint_id ?? '';
|
||||
values = Object.fromEntries(
|
||||
schema
|
||||
.filter((i) => i.defaultValue !== undefined && i.defaultValue !== null)
|
||||
.map((i) => [i.nodeId, i.defaultValue as string | number]),
|
||||
);
|
||||
listValues = {};
|
||||
sizes = {};
|
||||
rejects = {};
|
||||
uploadingName = {};
|
||||
submitError = '';
|
||||
errorExpanded = false;
|
||||
stageLog = [];
|
||||
testJobId =
|
||||
wf.last_test &&
|
||||
(wf.last_test.status === 'pending' || wf.last_test.status === 'running')
|
||||
? wf.last_test.id
|
||||
: null;
|
||||
});
|
||||
|
||||
function kindOf(entry: { kind: string }): MediaSlotKind {
|
||||
if (entry.kind === 'video_list') return 'video';
|
||||
if (entry.kind === 'audio_list') return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function unitOf(kind: MediaSlotKind): string {
|
||||
return kind === 'image' ? '张' : '段';
|
||||
}
|
||||
|
||||
function setRejects(key: string, next: { name: string; message: string }[]) {
|
||||
rejects = { ...rejects, [key]: next };
|
||||
}
|
||||
|
||||
async function uploadOne(file: File): Promise<string | null> {
|
||||
const res = await playgroundApi.upload(file);
|
||||
sizes = { ...sizes, [res.path]: file.size };
|
||||
return res.path;
|
||||
}
|
||||
|
||||
async function mediaAdd(entry: (typeof listEntries)[number], files: File[]) {
|
||||
const kind = kindOf(entry);
|
||||
const key = entry.nodeId;
|
||||
const max = entry.max ?? 9;
|
||||
const current = listValues[key] ?? [];
|
||||
const ok: string[] = [];
|
||||
const bad: { name: string; message: string }[] = [];
|
||||
for (const f of files) {
|
||||
const problem = mediaFileProblem(kind, f);
|
||||
if (problem) bad.push({ name: f.name, message: problem });
|
||||
else ok.push(f.name);
|
||||
}
|
||||
if (current.length + ok.length > max) {
|
||||
toast.error(
|
||||
t('studio.errRefMax', { max, n: current.length, k: ok.length }),
|
||||
);
|
||||
if (bad.length) setRejects(key, [...(rejects[key] ?? []), ...bad]);
|
||||
return;
|
||||
}
|
||||
setRejects(key, [...(rejects[key] ?? []), ...bad]);
|
||||
for (const f of files) {
|
||||
if (!ok.includes(f.name)) continue;
|
||||
uploadingName = { ...uploadingName, [key]: f.name };
|
||||
try {
|
||||
const path = await uploadOne(f);
|
||||
if (path)
|
||||
listValues = {
|
||||
...listValues,
|
||||
[key]: [...(listValues[key] ?? []), path],
|
||||
};
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
uploadingName = { ...uploadingName, [key]: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mediaReplace(
|
||||
entry: (typeof listEntries)[number],
|
||||
index: number,
|
||||
file: File,
|
||||
) {
|
||||
const kind = kindOf(entry);
|
||||
const key = entry.nodeId;
|
||||
const problem = mediaFileProblem(kind, file);
|
||||
if (problem) {
|
||||
setRejects(key, [
|
||||
...(rejects[key] ?? []),
|
||||
{ name: file.name, message: problem },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
uploadingName = { ...uploadingName, [key]: file.name };
|
||||
try {
|
||||
const path = await uploadOne(file);
|
||||
if (path) {
|
||||
const next = [...(listValues[key] ?? [])];
|
||||
next[index] = path;
|
||||
listValues = { ...listValues, [key]: next };
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
uploadingName = { ...uploadingName, [key]: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function mediaRemove(entry: (typeof listEntries)[number], index: number) {
|
||||
const key = entry.nodeId;
|
||||
listValues = {
|
||||
...listValues,
|
||||
[key]: (listValues[key] ?? []).filter((_, i) => i !== index),
|
||||
};
|
||||
}
|
||||
|
||||
/** Mirrors the backend's validate_input_values for immediate feedback. */
|
||||
function validate(): string[] {
|
||||
const errs: string[] = [];
|
||||
for (const e of schema) {
|
||||
const label = e.label;
|
||||
if (
|
||||
e.kind === 'image_list' ||
|
||||
e.kind === 'video_list' ||
|
||||
e.kind === 'audio_list'
|
||||
) {
|
||||
const n = (listValues[e.nodeId] ?? []).length;
|
||||
const unit = unitOf(kindOf(e));
|
||||
if (e.required && n < (e.min ?? 1))
|
||||
errs.push(t('wft.errListMin', { n: e.min ?? 1, unit, label }));
|
||||
} else if (e.auto && e.required && e.role === 'prompt') {
|
||||
const v = values[e.nodeId];
|
||||
if (
|
||||
!(typeof v === 'string' && v.trim()) &&
|
||||
!String(e.defaultValue ?? '').trim()
|
||||
) {
|
||||
errs.push(t('wft.errRequired', { label }));
|
||||
}
|
||||
} else if (
|
||||
e.required &&
|
||||
(e.kind === 'image' || e.kind === 'video' || e.kind === 'audio') &&
|
||||
e.role !== 'mask'
|
||||
) {
|
||||
if (!values[e.nodeId]) errs.push(t('wft.errRequired', { label }));
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
async function submitTest() {
|
||||
if (!wf) return;
|
||||
const errs = validate();
|
||||
if (errs.length) {
|
||||
submitError = errs.join(';');
|
||||
return;
|
||||
}
|
||||
submitError = '';
|
||||
confirmOpen = true;
|
||||
}
|
||||
|
||||
async function reallyRunTest() {
|
||||
if (!wf) return;
|
||||
submitting = true;
|
||||
try {
|
||||
const inputValues: Record<string, unknown> = { ...values };
|
||||
for (const [k, v] of Object.entries(listValues)) {
|
||||
if (v.length) inputValues[k] = v;
|
||||
}
|
||||
const res = await workflows.test(wf.id, {
|
||||
...(endpointId ? { endpoint_id: endpointId } : {}),
|
||||
input_values: inputValues as Record<string, string | number | string[]>,
|
||||
});
|
||||
testJobId = res.job.id;
|
||||
if (res.reused) toast.info(t('wft.reusedToast'));
|
||||
void $testsQuery.refetch();
|
||||
} catch (err) {
|
||||
submitError = apiErrorMessage(err);
|
||||
} finally {
|
||||
submitting = false;
|
||||
confirmOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelTest() {
|
||||
if (!activeJob) return;
|
||||
try {
|
||||
await jobsApi.cancel(activeJob.id);
|
||||
void $testsQuery.refetch();
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function clearRecords() {
|
||||
if (!wf) return;
|
||||
try {
|
||||
await workflows.clearTests(wf.id);
|
||||
testJobId = null;
|
||||
await $testsQuery.refetch();
|
||||
onchanged?.();
|
||||
toast.success(t('wft.cleared'));
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function resultLine(job: Job): string {
|
||||
if (job.status === 'done' && (job.output_paths?.length ?? 0) > 0) {
|
||||
return t('wft.passedShort', { n: job.output_paths.length });
|
||||
}
|
||||
if (job.error === 'cancelled') return t('wft.cancelledShort');
|
||||
return job.error ? job.error.slice(0, 120) : t('wft.failedShort');
|
||||
}
|
||||
|
||||
function timeOf(at: string | null): string {
|
||||
if (!at) return '—';
|
||||
const ts = Date.parse(at.replace(' ', 'T') + 'Z');
|
||||
const d = new Date(Number.isFinite(ts) ? ts : at);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const endpointLabel = $derived(
|
||||
endpoints.find((e) => e.id === endpointId)?.name ??
|
||||
t('conn.wfConnectionActive'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<Drawer bind:open title={t('wft.title')} width={420}>
|
||||
{#if wf}
|
||||
<div class="wft">
|
||||
<p class="wft-sub">
|
||||
{wf.name}
|
||||
<span class="mono wft-id">#{wf.id}</span>
|
||||
</p>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">{t('wft.connection')}</span>
|
||||
<select
|
||||
class="a-select full"
|
||||
bind:value={endpointId}
|
||||
disabled={queueBusy}
|
||||
>
|
||||
<option value="">{t('conn.wfConnectionActive')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="field-hint">{t('wft.costNote')}</span>
|
||||
</label>
|
||||
|
||||
{#if hasH3Hint}
|
||||
<div class="wft-note" role="note">
|
||||
<Icon name="info" size={14} />
|
||||
<p>{t('wfMedia.h3Limits')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeJob && activeInfo}
|
||||
<div class="wft-active">
|
||||
<div class="wft-active-head">
|
||||
<span class="chip run">
|
||||
<span class="chip-dot" aria-hidden="true"></span>
|
||||
{t('wft.running')}
|
||||
</span>
|
||||
<span class="wft-active-detail">{activeInfo.detail}</span>
|
||||
<Button variant="secondary" size="sm" onclick={cancelTest}
|
||||
>{t('wft.cancel')}</Button
|
||||
>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={liveProgress?.progress ?? 0}
|
||||
indeterminate={activeJob.status === 'pending' && !liveProgress}
|
||||
label={activeInfo.label}
|
||||
/>
|
||||
{#if stageLog.length}
|
||||
<div class="wft-log mono" bind:this={logBox} aria-live="polite">
|
||||
{#each stageLog as line, i (i)}
|
||||
<p>{line}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if finishedJob && finishedJob.status === 'done' && (finishedJob.output_paths?.length ?? 0) > 0}
|
||||
<div class="wft-result ok">
|
||||
<p class="wft-verdict">
|
||||
{t('wft.passed', { n: finishedJob.output_paths.length })}
|
||||
</p>
|
||||
<div class="wft-outputs">
|
||||
{#each finishedJob.output_paths as path (path)}
|
||||
<a
|
||||
class="wft-out"
|
||||
href={assetUrl(path)}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t('wft.download')}
|
||||
>
|
||||
<SafeMedia
|
||||
kind={outputKind}
|
||||
src={assetUrl(path)}
|
||||
controls={outputKind === 'video'}
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if finishedJob && finishedJob.status !== 'pending' && finishedJob.status !== 'running'}
|
||||
<div class="wft-result bad">
|
||||
<p class="wft-verdict">{t('wft.failed')}</p>
|
||||
<p class="wft-error">
|
||||
{finishedJob.error === 'cancelled'
|
||||
? t('wft.cancelledShort')
|
||||
: errorExpanded || (finishedJob.error ?? '').length <= 160
|
||||
? finishedJob.error
|
||||
: (finishedJob.error ?? '').slice(0, 160) + '…'}
|
||||
</p>
|
||||
{#if finishedJob.error && finishedJob.error !== 'cancelled' && finishedJob.error.length > 160}
|
||||
<button
|
||||
type="button"
|
||||
class="wft-expand"
|
||||
onclick={() => (errorExpanded = !errorExpanded)}
|
||||
>
|
||||
{errorExpanded ? t('wfd.hideDetails') : t('wfd.showDetails')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !queueBusy}
|
||||
<div class="wft-form">
|
||||
{#each listEntries as entry (entry.nodeId)}
|
||||
{@const kind = kindOf(entry)}
|
||||
<RefImageGrid
|
||||
label={entry.label}
|
||||
mediaKind={kind}
|
||||
paths={listValues[entry.nodeId] ?? []}
|
||||
max={entry.max ?? 9}
|
||||
{sizes}
|
||||
uploadingName={uploadingName[entry.nodeId] || undefined}
|
||||
rejects={rejects[entry.nodeId] ?? []}
|
||||
onAdd={(files) => mediaAdd(entry, files)}
|
||||
onReplace={(i, file) => mediaReplace(entry, i, file)}
|
||||
onRemove={(i) => mediaRemove(entry, i)}
|
||||
onDismissReject={(i) =>
|
||||
setRejects(
|
||||
entry.nodeId,
|
||||
(rejects[entry.nodeId] ?? []).filter((_, j) => j !== i),
|
||||
)}
|
||||
/>
|
||||
{/each}
|
||||
{#if formEntries.length}
|
||||
<ComfyDynamicForm
|
||||
inputs={formEntries}
|
||||
bind:values
|
||||
allowUpload
|
||||
showErrors={!!submitError}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if submitError}<span class="field-error" role="alert"
|
||||
>{submitError}</span
|
||||
>{/if}
|
||||
<div class="wft-actions">
|
||||
<Button variant="primary" loading={submitting} onclick={submitTest}>
|
||||
{t('wft.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="wft-history">
|
||||
<div class="wft-history-head">
|
||||
<span class="field-label"
|
||||
>{t('wft.history', { n: history.length })}</span
|
||||
>
|
||||
{#if history.length}
|
||||
<button type="button" class="link-danger" onclick={clearRecords}>
|
||||
{t('wft.clear')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if history.length === 0}
|
||||
<p class="wft-empty">{t('wft.never')}</p>
|
||||
{:else}
|
||||
<ul class="wft-rows">
|
||||
{#each history as job (job.id)}
|
||||
<li class:active={job.id === activeJob?.id}>
|
||||
<span class="mono wft-row-id">#{job.id}</span>
|
||||
<span class="wft-row-time">{timeOf(job.created_at)}</span>
|
||||
<span
|
||||
class="wft-row-result"
|
||||
class:ok={job.status === 'done' &&
|
||||
(job.output_paths?.length ?? 0) > 0}
|
||||
>
|
||||
{resultLine(job)}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Drawer>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmOpen}
|
||||
title={t('wft.confirmTitle')}
|
||||
message={t('wft.confirmBody', { endpoint: endpointLabel, n: queuedCount })}
|
||||
confirmLabel={t('wft.submit')}
|
||||
onconfirm={reallyRunTest}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.wft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wft-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-id {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.a-select.full {
|
||||
width: 100%;
|
||||
}
|
||||
.wft-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--run-soft);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--run-soft);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wft-note :global(.icon) {
|
||||
color: var(--run);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.wft-note p {
|
||||
margin: 0;
|
||||
}
|
||||
.wft-active,
|
||||
.wft-result {
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-active {
|
||||
border-color: var(--run-soft);
|
||||
}
|
||||
.wft-active-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip.run {
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.chip-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--run);
|
||||
animation: wft-breathe 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes wft-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
:global([data-reduce='1']) .chip-dot {
|
||||
animation: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chip-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.wft-active-detail {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-log {
|
||||
max-height: 110px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-log p {
|
||||
margin: 0;
|
||||
}
|
||||
.wft-result.ok {
|
||||
border-color: var(--ok);
|
||||
}
|
||||
.wft-result.bad {
|
||||
border-color: var(--err);
|
||||
}
|
||||
.wft-verdict {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.wft-result.ok .wft-verdict {
|
||||
color: var(--ok);
|
||||
}
|
||||
.wft-result.bad .wft-verdict {
|
||||
color: var(--err);
|
||||
}
|
||||
.wft-error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--err);
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
.wft-expand {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.wft-expand:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
.wft-outputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.wft-out {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: 148px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.wft-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.wft-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-history {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.wft-history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-history-head .field-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.link-danger {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--err);
|
||||
cursor: pointer;
|
||||
border-radius: var(--r-sm);
|
||||
opacity: 0.85;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.link-danger:hover {
|
||||
opacity: 1;
|
||||
background: var(--err-soft);
|
||||
}
|
||||
}
|
||||
.wft-empty {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wft-rows {
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wft-rows li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.wft-rows li.active {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.wft-row-id {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.wft-row-time {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wft-row-result {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wft-row-result.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
</style>
|
||||
@@ -357,16 +357,13 @@
|
||||
.empty-mark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--r-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
background:
|
||||
radial-gradient(circle at 30% 25%, rgba(139, 92, 246, 0.35) 0%, transparent 60%),
|
||||
var(--bg-elevated);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
box-shadow: 0 0 32px var(--accent-glow);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.empty-title {
|
||||
@@ -391,21 +388,24 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
.suggestion {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-2);
|
||||
border-radius: var(--r-pill);
|
||||
padding: var(--sp-3) var(--sp-6);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
border-color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.suggestion:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface));
|
||||
color: var(--text);
|
||||
border-color: var(--line-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.suggestion:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -430,11 +430,11 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-size: var(--fs-mini);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--agent, var(--accent));
|
||||
color: var(--agent, var(--text-2));
|
||||
margin-bottom: 4px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
@@ -443,7 +443,7 @@
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--accent)) 25%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--text-2)) 25%, transparent);
|
||||
}
|
||||
.reasoning-wrap {
|
||||
margin-bottom: 4px;
|
||||
@@ -451,28 +451,29 @@
|
||||
}
|
||||
.bubble {
|
||||
max-width: 86%;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 10px 14px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* 对话气泡按规范 §3.2/§3.6:用户 --bg-3,Agent --bg-2 + --line 描边。 */
|
||||
.user-bubble {
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--bg-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
.user-bubble.steering {
|
||||
border-style: dashed;
|
||||
}
|
||||
.steer-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-size: var(--fs-mini);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
color: var(--text-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.user-thumbs {
|
||||
@@ -486,36 +487,37 @@
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.user-thumb.file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.user-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/* 工作流提及为次级内容:中性底 + --text,不占金色。 */
|
||||
.wf-chip {
|
||||
display: inline;
|
||||
padding: 1px 7px;
|
||||
margin: 0 1px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg-elevated));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
color: var(--accent);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-4);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.assistant-bubble {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--agent, var(--accent));
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--agent, var(--text-2));
|
||||
color: var(--text);
|
||||
}
|
||||
.question-card {
|
||||
display: flex;
|
||||
@@ -525,15 +527,15 @@
|
||||
max-width: 420px;
|
||||
margin: 2px 0 6px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--accent) 7%, var(--bg-surface));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.question-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.question-options {
|
||||
@@ -542,40 +544,39 @@
|
||||
gap: 6px;
|
||||
}
|
||||
.question-option {
|
||||
border: 1px solid color-mix(in srgb, var(--agent, var(--accent)) 45%, transparent);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease;
|
||||
background-color var(--d-press) ease,
|
||||
border-color var(--d-press) ease;
|
||||
}
|
||||
.question-option:hover:enabled {
|
||||
background: color-mix(in srgb, var(--agent, var(--accent)) 18%, var(--bg-elevated));
|
||||
border-color: var(--agent, var(--accent));
|
||||
background: var(--bg-4);
|
||||
border-color: var(--agent, var(--text-2));
|
||||
}
|
||||
.question-option:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.question-option.affirmative {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.question-hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.assistant-bubble.err {
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
border-left-color: rgba(239, 68, 68, 0.6);
|
||||
color: var(--error);
|
||||
border-color: var(--err-line);
|
||||
border-left-color: var(--err);
|
||||
color: var(--err);
|
||||
}
|
||||
.bubble.live {
|
||||
display: flex;
|
||||
@@ -604,7 +605,7 @@
|
||||
width: 7px;
|
||||
height: 14px;
|
||||
margin-left: 2px;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: blink 1s step-end infinite;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
@@ -617,14 +618,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
/* 进行中指示 = 信号青(§1.3),不占金色。 */
|
||||
.pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -711,12 +711,12 @@ let {
|
||||
display: inline;
|
||||
padding: 1px 7px;
|
||||
margin: 0 1px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg-elevated));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
color: var(--accent);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-4);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -724,12 +724,12 @@ let {
|
||||
display: inline;
|
||||
padding: 1px 7px;
|
||||
margin: 0 1px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, #38bdf8 20%, var(--bg-elevated));
|
||||
border: 1px solid color-mix(in srgb, #38bdf8 45%, transparent);
|
||||
color: #7dd3fc;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-4);
|
||||
border: 1px dashed var(--line-2);
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
}
|
||||
.head:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
.head:hover {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.icon-wrap {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -135,9 +135,7 @@
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-float);
|
||||
padding: 4px;
|
||||
}
|
||||
.empty {
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
/**
|
||||
* Agent identity palette: a stable display name + accent color per agent.
|
||||
*
|
||||
* The main loop runs with `agent_name = null`; we label it "Calliope". Each
|
||||
* swarm sub-agent ("planner", "story-agent", …) gets its own fixed color so
|
||||
* differently-authored bubbles are visually distinct, plus a deterministic
|
||||
* fallback for any future agent name.
|
||||
* Identity colors answer "who is speaking" only (avatar / name / bubble
|
||||
* tint) — they never express task status (spec v2 §3.6). The curated set
|
||||
* reuses the spec §3.6 avatar five-color family plus one neutral slate, so
|
||||
* no tailwind rainbow hues leak into the fusion palette; the default
|
||||
* main-loop agent is neutral `--text-2` slate.
|
||||
*/
|
||||
|
||||
export const DEFAULT_AGENT_NAME = 'Calliope';
|
||||
|
||||
// Fixed identity map (keyed by the raw agent_name the backend emits).
|
||||
const IDENTITIES: Record<string, { label: string; color: string }> = {
|
||||
planner: { label: 'Planner', color: '#22d3ee' }, // cyan
|
||||
'story-agent': { label: 'Story Agent', color: '#22c55e' }, // green
|
||||
'script-agent': { label: 'Script Agent', color: '#f59e0b' }, // amber
|
||||
'assets-agent': { label: 'Assets Agent', color: '#ec4899' }, // pink
|
||||
'video-agent': { label: 'Video Agent', color: '#fb923c' }, // orange
|
||||
'research-agent': { label: 'Research Agent', color: '#a78bfa' }, // violet
|
||||
planner: { label: 'Planner', color: '#5b8def' }, // avatar-2 blue
|
||||
'story-agent': { label: 'Story Agent', color: '#3fb97f' }, // avatar-3 green
|
||||
'script-agent': { label: 'Script Agent', color: '#e8a33d' }, // avatar-1 amber
|
||||
'assets-agent': { label: 'Assets Agent', color: '#b98be0' }, // avatar-4 violet
|
||||
'video-agent': { label: 'Video Agent', color: '#d98e6a' }, // avatar-5 clay
|
||||
'research-agent': { label: 'Research Agent', color: '#8fa3c8' }, // neutral slate
|
||||
// The five named agents (HEL-613) — the runner stamps their zh names on
|
||||
// named-session replies; colors mirror the spec §2.1 avatar five-set.
|
||||
剧本创作: { label: '剧本创作', color: '#e8a33d' },
|
||||
@@ -26,19 +27,9 @@ const IDENTITIES: Record<string, { label: string; color: string }> = {
|
||||
镜头脚本: { label: '镜头脚本', color: '#d98e6a' },
|
||||
};
|
||||
|
||||
// Fallback palette for unknown names (stable hash → deterministic color).
|
||||
const FALLBACK_COLORS = [
|
||||
'#22d3ee',
|
||||
'#22c55e',
|
||||
'#f59e0b',
|
||||
'#ec4899',
|
||||
'#fb923c',
|
||||
'#a78bfa',
|
||||
'#38bdf8',
|
||||
'#4ade80',
|
||||
'#f472b6',
|
||||
'#facc15',
|
||||
];
|
||||
// Fallback palette for unknown names (stable hash → deterministic color),
|
||||
// narrowed to the avatar identity family + neutral slate (spec v2 §十.4).
|
||||
const FALLBACK_COLORS = ['#5b8def', '#3fb97f', '#e8a33d', '#b98be0', '#d98e6a', '#8fa3c8'];
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 0;
|
||||
@@ -48,8 +39,8 @@ function hashString(s: string): number {
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The default "Calliope" accent (matches --accent). */
|
||||
export const DEFAULT_AGENT_COLOR = '#8b5cf6';
|
||||
/** Neutral slate (--text-2) — the default agent borrows no signal color. */
|
||||
export const DEFAULT_AGENT_COLOR = '#aab2c5';
|
||||
|
||||
export function agentDisplayName(name: string | null | undefined): string {
|
||||
const key = (name || '').trim().toLowerCase();
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import ParamChip from './ParamChip.svelte';
|
||||
import SlotTray from './SlotTray.svelte';
|
||||
import WorkflowPicker from './WorkflowPicker.svelte';
|
||||
import { chipValue, type BlockReason, type DeskSchema, type FormValue, type FormValues } from './schema';
|
||||
|
||||
/**
|
||||
* 输入台 (spec-v2 §3.3): slots from the workflow JSON, the prompt, and one
|
||||
* row of chips — workflow, connection, common params, 参数 — ending in
|
||||
* 生成 with its reason line. The whole composer takes dropped files.
|
||||
* ≤760px it collapses to one line and rises as a bottom sheet on focus.
|
||||
*/
|
||||
interface Props {
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
workflows: Workflow[];
|
||||
current: Workflow | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
endpointId: string;
|
||||
conn: { state: 'ok' | 'fail' | 'untested' | 'dry'; ms?: number | null };
|
||||
errors: Record<string, string>;
|
||||
showErrors: boolean;
|
||||
block: BlockReason | null;
|
||||
changedCount: number;
|
||||
submitting: boolean;
|
||||
/** Shows 已提交 on the button for --d-flash. */
|
||||
sent: boolean;
|
||||
slotBusy?: boolean;
|
||||
promptEl?: HTMLTextAreaElement | null;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onPickWorkflow: (wf: Workflow) => void;
|
||||
onPickEndpoint: (id: string) => void;
|
||||
onSubmit: () => void;
|
||||
onReveal: () => void;
|
||||
onOpenParams: () => void;
|
||||
onFromResults: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
ds,
|
||||
values,
|
||||
workflows,
|
||||
current,
|
||||
endpoints,
|
||||
endpointId,
|
||||
conn,
|
||||
errors,
|
||||
showErrors,
|
||||
block,
|
||||
changedCount,
|
||||
submitting,
|
||||
sent,
|
||||
slotBusy = $bindable(false),
|
||||
promptEl = $bindable(null),
|
||||
onSet,
|
||||
onPickWorkflow,
|
||||
onPickEndpoint,
|
||||
onSubmit,
|
||||
onReveal,
|
||||
onOpenParams,
|
||||
onFromResults,
|
||||
}: Props = $props();
|
||||
|
||||
let tray = $state<ReturnType<typeof SlotTray> | null>(null);
|
||||
let root = $state<HTMLElement | null>(null);
|
||||
let dragDepth = $state(0);
|
||||
let expanded = $state(false);
|
||||
let connOpen = $state(false);
|
||||
|
||||
const hasSlots = $derived(ds.slots.length + ds.lists.length > 0);
|
||||
const promptVal = $derived(ds.prompt ? String(values[ds.prompt.nodeId] ?? '') : '');
|
||||
const promptErr = $derived(showErrors && ds.prompt ? errors[ds.prompt.nodeId] : undefined);
|
||||
const endpointName = $derived(endpoints.find((e) => e.id === endpointId)?.name ?? t('desk.noConn'));
|
||||
|
||||
/** Composer chips: ratio, the width × height pair, resolution, duration, seed. */
|
||||
const chips = $derived.by(() => {
|
||||
const out: Array<{ key: string; inputs: ComfyDynamicInput[]; text: string }> = [];
|
||||
if (ds.ratio) out.push({ key: ds.ratio.nodeId, inputs: [ds.ratio], text: chipValue(ds.ratio, values, ds) });
|
||||
if (ds.width && ds.height) {
|
||||
const w = values[ds.width.nodeId] ?? ds.width.defaultValue ?? '?';
|
||||
const h = values[ds.height.nodeId] ?? ds.height.defaultValue ?? '?';
|
||||
out.push({ key: ds.width.nodeId, inputs: [ds.width, ds.height], text: `${w}×${h}` });
|
||||
} else {
|
||||
for (const inp of [ds.width, ds.height]) {
|
||||
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: `${inp.label} ${chipValue(inp, values, ds)}` });
|
||||
}
|
||||
}
|
||||
for (const inp of [ds.resolution, ds.duration, ds.seed]) {
|
||||
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: chipValue(inp, values, ds) });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragDepth = 0;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) tray?.dropFiles(files);
|
||||
}
|
||||
|
||||
function hasFiles(e: DragEvent): boolean {
|
||||
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
||||
}
|
||||
|
||||
function onFocusOut(e: FocusEvent) {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!next || !root?.contains(next)) expanded = false;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && expanded && !e.defaultPrevented) {
|
||||
expanded = false;
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="composer-wrap">
|
||||
<div class="a-sprocket" aria-hidden="true"></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={root}
|
||||
class="composer"
|
||||
class:drag={dragDepth > 0}
|
||||
class:expanded
|
||||
onfocusin={() => (expanded = true)}
|
||||
onfocusout={onFocusOut}
|
||||
onkeydown={onKeydown}
|
||||
ondragenter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
}}
|
||||
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
|
||||
ondragover={(e) => hasFiles(e) && e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if hasSlots}
|
||||
<div class="slots">
|
||||
<SlotTray bind:this={tray} bind:busy={slotBusy} {ds} {values} {showErrors} {errors} {onSet} {onFromResults} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ds.prompt}
|
||||
<textarea
|
||||
bind:this={promptEl}
|
||||
id="desk-prompt"
|
||||
class="prompt"
|
||||
class:invalid={!!promptErr}
|
||||
rows="2"
|
||||
value={promptVal}
|
||||
placeholder={t('desk.promptPh')}
|
||||
aria-label={t('studio.promptLabel')}
|
||||
aria-invalid={!!promptErr || undefined}
|
||||
aria-keyshortcuts="Control+Enter Meta+Enter /"
|
||||
oninput={(e) => onSet(ds.prompt!.nodeId, e.currentTarget.value)}
|
||||
></textarea>
|
||||
{#if promptErr}<p class="a-slot-hint err prompt-err" role="alert">{promptErr}</p>{/if}
|
||||
{:else if current}
|
||||
<p class="no-prompt">{t('desk.noPromptInput')}</p>
|
||||
{/if}
|
||||
|
||||
<div class="foot">
|
||||
<WorkflowPicker {workflows} {current} {endpoints} onPick={onPickWorkflow} />
|
||||
<div class="conn opt">
|
||||
<button
|
||||
type="button"
|
||||
id="desk-conn"
|
||||
class="a-chip"
|
||||
class:err={conn.state === 'fail'}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={connOpen}
|
||||
title={t('studio.connLabel')}
|
||||
onclick={() => (connOpen = !connOpen)}
|
||||
>
|
||||
<span class="status-chip-dot {conn.state === 'ok' ? 'run' : conn.state === 'fail' ? 'fail' : 'idle'}"></span>
|
||||
<span class="lbl">{endpointName}</span>
|
||||
{#if conn.state === 'ok' && conn.ms != null}<span class="mono">· {Math.round(conn.ms)} ms</span>{/if}
|
||||
{#if conn.state === 'dry'}<span>· {t('desk.dryRun')}</span>{/if}
|
||||
</button>
|
||||
<ShellPop bind:open={connOpen} label={t('studio.connLabel')} placement="top" width="280px">
|
||||
<div class="a-menu-group">{t('studio.connLabel')}</div>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item"
|
||||
aria-current={ep.id === endpointId ? 'true' : undefined}
|
||||
onclick={() => {
|
||||
onPickEndpoint(ep.id);
|
||||
connOpen = false;
|
||||
}}
|
||||
>
|
||||
<span class="a-menu-label">{ep.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<a class="a-menu-item" href="/connections">
|
||||
<Icon name="link" size={15} />
|
||||
<span class="a-menu-label">{t('studio.connManage')}</span>
|
||||
</a>
|
||||
</ShellPop>
|
||||
</div>
|
||||
{#each chips as c (c.key)}
|
||||
<div class="opt">
|
||||
<ParamChip
|
||||
inputs={c.inputs}
|
||||
{values}
|
||||
text={c.text}
|
||||
invalid={showErrors && c.inputs.some((i) => !!errors[i.nodeId]?.trim())}
|
||||
{onSet}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if ds.common.length + ds.advanced.length > 0}
|
||||
<button type="button" class="a-chip opt" class:changed={changedCount > 0} onclick={onOpenParams}>
|
||||
<Icon name="settings" size={14} />
|
||||
<span class="lbl">
|
||||
{changedCount > 0 ? t('desk.paramsChanged', { n: changedCount }) : t('desk.paramsAll')}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="submit">
|
||||
{#if block}
|
||||
<button type="button" class="why" class:err={block.tone === 'err'} onclick={onReveal}>
|
||||
{block.text}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn primary gen"
|
||||
class:sent
|
||||
disabled={!!block || submitting || slotBusy}
|
||||
aria-keyshortcuts="Control+Enter Meta+Enter"
|
||||
title={t('desk.genHint')}
|
||||
onclick={onSubmit}
|
||||
>
|
||||
<span class="l-idle"><Icon name="sparkle" size={13} />{t('desk.generate')}</span>
|
||||
<span class="l-sent" aria-hidden={!sent}><Icon name="check" size={13} />{t('desk.sent')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.composer-wrap {
|
||||
padding: var(--sp-2) var(--sp-7) var(--sp-6);
|
||||
}
|
||||
.a-sprocket {
|
||||
margin: 0 var(--sp-2) var(--sp-3);
|
||||
}
|
||||
.composer {
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--composer-bg);
|
||||
transition:
|
||||
border-color var(--d-state) ease,
|
||||
box-shadow var(--d-state) ease;
|
||||
}
|
||||
.composer:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: var(--composer-focus);
|
||||
}
|
||||
.composer.drag {
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.slots {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.prompt {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
max-height: 180px;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-2);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
field-sizing: content;
|
||||
}
|
||||
.prompt:focus-visible {
|
||||
outline: 0;
|
||||
}
|
||||
.prompt::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.prompt-err,
|
||||
.no-prompt {
|
||||
margin: 0;
|
||||
padding: 0 var(--sp-6);
|
||||
}
|
||||
.no-prompt {
|
||||
padding-top: var(--sp-5);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3) var(--sp-3);
|
||||
}
|
||||
.conn {
|
||||
position: relative;
|
||||
}
|
||||
.submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
margin-left: auto;
|
||||
}
|
||||
.why {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--warn);
|
||||
white-space: nowrap;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.why.err {
|
||||
color: var(--err);
|
||||
}
|
||||
/* 生成:按下即缩,提交后文字换成「已提交」约 --d-flash,期间仍可再次提交 */
|
||||
.gen {
|
||||
display: inline-grid;
|
||||
min-width: 84px;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.gen > span {
|
||||
grid-area: 1 / 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-1);
|
||||
transition:
|
||||
opacity var(--d-state) ease,
|
||||
transform var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.gen .l-sent {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.gen.sent .l-idle {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
.gen.sent .l-sent {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
:global(:root[data-reduce='1']) .gen > span {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1179px) {
|
||||
.composer-wrap {
|
||||
padding: var(--sp-2) var(--sp-6) var(--sp-5);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.composer-wrap {
|
||||
padding: var(--sp-1) var(--sp-4) var(--sp-4);
|
||||
}
|
||||
.composer:not(.expanded) .slots,
|
||||
.composer:not(.expanded) .opt {
|
||||
display: none;
|
||||
}
|
||||
.composer:not(.expanded) .prompt {
|
||||
min-height: 40px;
|
||||
max-height: 40px;
|
||||
padding: var(--sp-4) var(--sp-5) var(--sp-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.composer.expanded {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
|
||||
z-index: 45;
|
||||
border-bottom: 0;
|
||||
border-radius: var(--r-lg) var(--r-lg) 0 0;
|
||||
box-shadow: var(--shadow-float);
|
||||
max-height: calc(100dvh - var(--topbar-h) - var(--tabbar-h));
|
||||
overflow-y: auto;
|
||||
}
|
||||
.composer.expanded .slots {
|
||||
max-height: none;
|
||||
}
|
||||
.submit {
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { backHref, type DeskContext } from './context';
|
||||
|
||||
/**
|
||||
* 上下文条 (spec-v2 §3.3): the desk was opened from a creation stage.
|
||||
* Says where from, and plainly that takes stay in the desk feed — the
|
||||
* desk's generate call has no shot/asset link to fill back.
|
||||
*/
|
||||
interface Props {
|
||||
ctx: DeskContext;
|
||||
projectTitle: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
let { ctx, projectTitle, onDismiss }: Props = $props();
|
||||
|
||||
const stageName = $derived(t(`workbench.stage.${ctx.stage}`));
|
||||
</script>
|
||||
|
||||
<div class="context-bar" role="status">
|
||||
<Icon name="link" size={14} />
|
||||
<span class="from">
|
||||
<b>{t('desk.ctxFrom', { project: projectTitle })}</b>{#if ctx.label} · {ctx.label}{/if}
|
||||
</span>
|
||||
<span class="note">{t('desk.ctxNoBackfill', { stage: stageName })}</span>
|
||||
<a class="a-btn ghost xs back" href={backHref(ctx)}>{t('desk.ctxBack', { stage: stageName })}</a>
|
||||
<button type="button" class="a-btn ghost xs" aria-label={t('desk.ctxDismiss')} onclick={onDismiss}>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.context-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2) var(--sp-4);
|
||||
margin: var(--sp-4) var(--sp-7) 0;
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--run-soft);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.context-bar > :global(svg) {
|
||||
color: var(--run);
|
||||
}
|
||||
.from b {
|
||||
color: var(--run);
|
||||
font-weight: 600;
|
||||
}
|
||||
.note {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.back {
|
||||
margin-left: auto;
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.context-bar {
|
||||
margin: var(--sp-4) var(--sp-6) 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.context-bar {
|
||||
margin: var(--sp-3) var(--sp-5) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,565 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
import { usageLabel } from './schema';
|
||||
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf, specOf } from './takes';
|
||||
|
||||
/**
|
||||
* 检视区 (spec-v2 §3.3): 结果 shows the selected take on the projection
|
||||
* stage with its spec, inputs and workflow; 参数 hosts the full form.
|
||||
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
|
||||
* fullscreen — `overlay` / `open` drive the last two.
|
||||
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
|
||||
* ↑↓/J/K 即时无动画), a finished/just-picked video auto-plays muted,
|
||||
* failed takes get 重试, and a done take whose files no longer load
|
||||
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
|
||||
*/
|
||||
interface Props {
|
||||
tab: 'result' | 'params';
|
||||
job: Job | null;
|
||||
wf: Workflow | undefined;
|
||||
nowMs: number;
|
||||
open: boolean;
|
||||
/** Collapsed or off-screen — nothing inside may take focus. */
|
||||
inactive: boolean;
|
||||
/** Current workflow can take an image output (送入当前图片槽). */
|
||||
canSendToSlot: boolean;
|
||||
/** Animate this result change (pointer pick) — 150ms cross-fade. */
|
||||
crossfade: boolean;
|
||||
/** Bump to auto-play the stage video (pointer pick / completion). */
|
||||
playSignal: number;
|
||||
/** The stage <video> element, for the desk's space-bar toggle. */
|
||||
videoEl?: HTMLVideoElement | null;
|
||||
params: Snippet;
|
||||
onClose: () => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onSendToSlot: (path: string) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tab = $bindable(),
|
||||
job,
|
||||
wf,
|
||||
nowMs,
|
||||
open,
|
||||
inactive,
|
||||
canSendToSlot,
|
||||
crossfade,
|
||||
playSignal,
|
||||
videoEl = $bindable(null),
|
||||
params,
|
||||
onClose,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onSendToSlot,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
let outIndex = $state(0);
|
||||
$effect.pre(() => {
|
||||
void job?.id;
|
||||
outIndex = 0;
|
||||
});
|
||||
|
||||
const st = $derived(job ? jobStatusInfo(job) : null);
|
||||
// §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files
|
||||
// cannot be fetched — the failed case stays red.
|
||||
const gonePaths = $derived(missingOutputsOf(job));
|
||||
const fileGone = $derived(gonePaths.length > 0);
|
||||
const outputs = $derived(job && st?.tone === 'ok' ? job.output_paths.filter((p) => !gonePaths.includes(p)) : []);
|
||||
const output = $derived(outputs[Math.min(outIndex, outputs.length - 1)] ?? null);
|
||||
const allOutputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
|
||||
const video = $derived(!!job && !!output && isVideoOutput(job, output));
|
||||
const wfDeleted = $derived(!!job && job.workflow_id == null);
|
||||
const prompt = $derived(job ? promptOf(job, wf) : '');
|
||||
const inputs = $derived(job ? inputsOf(job, wf) : []);
|
||||
const spec = $derived(job ? specOf(job, wf) : []);
|
||||
const endpoint = $derived(
|
||||
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ?? '',
|
||||
);
|
||||
const progress = $derived.by(() => {
|
||||
if (!job || job.status !== 'running') return 0;
|
||||
const live = progressFor(job.id);
|
||||
const phase = live?.phase ?? job.phase;
|
||||
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
|
||||
return live?.progress ?? 3;
|
||||
});
|
||||
let dims = $state('');
|
||||
$effect.pre(() => {
|
||||
void output;
|
||||
dims = '';
|
||||
});
|
||||
function noteImg(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
dims = `${img.naturalWidth}×${img.naturalHeight}`;
|
||||
}
|
||||
function noteVideo(e: Event) {
|
||||
const v = e.currentTarget as HTMLVideoElement;
|
||||
dims = `${v.videoWidth}×${v.videoHeight}`;
|
||||
}
|
||||
|
||||
// 完成或点选后自动播放(静音);键盘切换不触发。
|
||||
$effect(() => {
|
||||
void playSignal;
|
||||
if (inactive || tab !== 'result' || !videoEl) return;
|
||||
videoEl.muted = true;
|
||||
videoEl.play().catch(() => {});
|
||||
});
|
||||
|
||||
function reduced(): boolean {
|
||||
return (
|
||||
document.documentElement.dataset.reduce === '1' ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
}
|
||||
|
||||
/** 150ms 交叉淡变 on pointer picks; keyboard switches stay instant. */
|
||||
function xfade(_node: Element) {
|
||||
const ms = crossfade ? (reduced() ? 120 : 150) : 0;
|
||||
return { duration: ms, css: (t: number) => `opacity: ${t}` };
|
||||
}
|
||||
|
||||
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
|
||||
|
||||
let frameBusy = $state(false);
|
||||
async function exportTail() {
|
||||
const src = assetUrl(output);
|
||||
if (!src || frameBusy) return;
|
||||
frameBusy = true;
|
||||
try {
|
||||
await exportLastFrame(src);
|
||||
} finally {
|
||||
frameBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="inspector" class:open inert={inactive} aria-label={t('desk.inspector')}>
|
||||
<div class="tabs">
|
||||
<div class="a-seg" role="group" aria-label={t('desk.inspector')}>
|
||||
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}>{t('desk.tabResult')}</button>
|
||||
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}>{t('desk.tabParams')}</button>
|
||||
</div>
|
||||
<span class="keys"><span class="a-kbd">]</span>{t('desk.keyInspector')}</span>
|
||||
<button type="button" class="a-btn ghost sm close" aria-label={t('desk.closeInspector')} onclick={onClose}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
{#if tab === 'params'}
|
||||
{@render params()}
|
||||
{:else if !job || !st}
|
||||
<div class="a-stage stage"><div class="stage-empty">{t('desk.pickTake')}</div></div>
|
||||
{:else}
|
||||
<div class="a-stage stage">
|
||||
{#key mediaKey}
|
||||
<div class="stage-media" in:xfade out:xfade>
|
||||
{#if output && video}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
src={assetUrl(output)}
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
></video>
|
||||
{:else if output}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt={prompt || t('desk.tabResult')}
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImg(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
{:else if fileGone}
|
||||
<div class="stage-empty gone">
|
||||
<span>{t('desk.missingTitle')}</span>
|
||||
<span class="sub">{t('desk.missingSub')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stage-empty">
|
||||
<span>{st.label}</span>
|
||||
<span class="sub">{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span>
|
||||
{#if progress > 0}
|
||||
<div class="a-progress stage-bar" style:--p={progress / 100}><i></i></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if allOutputs.length > 1}
|
||||
<div class="strip" role="group" aria-label={t('desk.outputs')}>
|
||||
{#each allOutputs as p, i (p)}
|
||||
<button
|
||||
type="button"
|
||||
class="strip-item"
|
||||
class:gone={gonePaths.includes(p)}
|
||||
aria-pressed={p === output}
|
||||
aria-label={t('desk.outputN', { n: i + 1 })}
|
||||
onclick={() => (outIndex = i)}
|
||||
>
|
||||
{#if isVideoOutput(job, p)}
|
||||
<Icon name="film" size={14} />
|
||||
{:else}
|
||||
<img src={assetUrl(p)} alt="" loading="lazy" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="meta">
|
||||
{#if dims}<span class="mono"><b>{dims}</b></span>{/if}
|
||||
{#each spec as s (s.label)}
|
||||
{#if !(dims && s.label === t('desk.specSize'))}
|
||||
<span>{s.label} <b class="mono">{s.value}</b></span>
|
||||
{/if}
|
||||
{/each}
|
||||
<span>{t('desk.elapsed')} <b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span>
|
||||
{#if endpoint}<span>{endpoint}</span>{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#if fileGone && !outputs.length}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('desk.redo')}</button>
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('desk.removeRecord')}</button>
|
||||
{:else}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}>{t('desk.reuseToComposer')}</button>
|
||||
{#if output && !video && canSendToSlot}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onSendToSlot(output)}>
|
||||
{t('desk.sendToSlot')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if output}
|
||||
<a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a>
|
||||
{/if}
|
||||
{#if output && video}
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn secondary sm"
|
||||
title={t('lastFrame.hint')}
|
||||
disabled={frameBusy}
|
||||
aria-busy={frameBusy}
|
||||
onclick={exportTail}
|
||||
>
|
||||
{frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if st.tone === 'fail'}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button>
|
||||
{/if}
|
||||
{#if job.status !== 'pending' && job.status !== 'running'}
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if st.tone === 'fail'}
|
||||
<div class="note err" role="alert">{st.detail}</div>
|
||||
{:else if fileGone}
|
||||
<div class="note warn" role="alert">{t('jobst.missingDetail')}</div>
|
||||
{/if}
|
||||
|
||||
{#if inputs.length || prompt}
|
||||
<div class="section-title">{t('desk.inputs')}</div>
|
||||
{#if inputs.length}
|
||||
<div class="inputs">
|
||||
{#each inputs as inp (inp.label + inp.path)}
|
||||
{#if inp.kind === 'image'}
|
||||
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
|
||||
{:else}
|
||||
<span class="mini glyph" title={inp.label}><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<div class="section-title">{t('desk.workflow')}</div>
|
||||
<dl class="kv">
|
||||
<dt>{t('desk.kvName')}</dt>
|
||||
<dd>
|
||||
{wf?.name ?? t('studio.jobName', { id: job.id })}
|
||||
{#if wfDeleted}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
|
||||
</dd>
|
||||
{#if wf?.usage}
|
||||
<dt>{t('desk.kvUsage')}</dt>
|
||||
<dd>{usageLabel(wf.usage)}</dd>
|
||||
{/if}
|
||||
<dt>{t('desk.kvCreated')}</dt>
|
||||
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
|
||||
{#if job.comfy_prompt_id}
|
||||
<dt>{t('desk.kvPromptId')}</dt>
|
||||
<dd class="mono">{job.comfy_prompt_id}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.close {
|
||||
padding: 0 var(--sp-3);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
.stage {
|
||||
aspect-ratio: 16 / 10;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.stage-media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.stage-media > img,
|
||||
.stage-media > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.stage-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-6);
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.stage-empty.gone {
|
||||
color: var(--warn);
|
||||
}
|
||||
.stage-empty .sub {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.stage-bar {
|
||||
width: 160px;
|
||||
}
|
||||
.strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.strip-item {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.strip-item[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.strip-item.gone {
|
||||
border-style: dashed;
|
||||
border-color: var(--warn);
|
||||
color: var(--warn);
|
||||
}
|
||||
.strip-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2) var(--sp-5);
|
||||
margin: var(--sp-5) 0;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.meta b {
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.note {
|
||||
margin-top: var(--sp-5);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--r-md);
|
||||
font-size: var(--fs-aux);
|
||||
word-break: break-word;
|
||||
}
|
||||
.note.err {
|
||||
background: var(--err-soft);
|
||||
color: var(--err);
|
||||
}
|
||||
.note.warn {
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
}
|
||||
.section-title {
|
||||
margin: var(--sp-7) 0 var(--sp-3);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.inputs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
.mini {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
object-fit: cover;
|
||||
}
|
||||
.mini.glyph {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.prompt {
|
||||
margin: 0;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 92px 1fr;
|
||||
gap: var(--sp-2) var(--sp-4);
|
||||
margin: 0;
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.kv dt {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
word-break: break-all;
|
||||
}
|
||||
.kv .wf-gone {
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ≥1180: a column of the desk grid; the close button hides it (]) */
|
||||
@media (max-width: 1179px) {
|
||||
.inspector {
|
||||
position: fixed;
|
||||
top: var(--topbar-h);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
width: min(420px, 100vw);
|
||||
box-shadow: var(--shadow-float);
|
||||
transform: translateX(100%);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform var(--d-drawer) var(--ease-drawer),
|
||||
visibility 0s linear var(--d-drawer);
|
||||
}
|
||||
.inspector.open {
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
transition:
|
||||
transform var(--d-drawer) var(--ease-drawer),
|
||||
visibility 0s;
|
||||
}
|
||||
:global(:root[data-reduce='1']) .inspector {
|
||||
transform: none;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--d-drawer) ease,
|
||||
visibility 0s linear var(--d-drawer);
|
||||
}
|
||||
:global(:root[data-reduce='1']) .inspector.open {
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity var(--d-drawer) ease,
|
||||
visibility 0s;
|
||||
}
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
.close {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.inspector {
|
||||
top: 0;
|
||||
z-index: 70;
|
||||
width: 100vw;
|
||||
border-left: 0;
|
||||
}
|
||||
.body {
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import type { ComfyDynamicInput } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { inputRole, isChanged, type FormValue, type FormValues } from './schema';
|
||||
|
||||
/**
|
||||
* One common parameter as a composer chip (spec-v2 §3.3 参数芯片): the
|
||||
* pop edits it in place — options list for selects, a number field for
|
||||
* the rest (seed adds 随机) — and 恢复原值 returns to the workflow value.
|
||||
* `inputs` holds two entries for the width × height pair.
|
||||
*/
|
||||
interface Props {
|
||||
inputs: ComfyDynamicInput[];
|
||||
values: FormValues;
|
||||
text: string;
|
||||
title?: string;
|
||||
invalid?: boolean;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
}
|
||||
|
||||
let { inputs, values, text, title, invalid = false, onSet }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
const changed = $derived(inputs.some((i) => isChanged(i, values)));
|
||||
const single = $derived(inputs.length === 1 ? inputs[0] : null);
|
||||
const uid = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
function current(inp: ComfyDynamicInput): string {
|
||||
const v = values[inp.nodeId] ?? inp.defaultValue ?? '';
|
||||
return Array.isArray(v) ? v.join(',') : String(v);
|
||||
}
|
||||
|
||||
function setNumber(inp: ComfyDynamicInput, raw: string) {
|
||||
if (raw === '') return onSet(inp.nodeId, '');
|
||||
const n = Number(raw);
|
||||
onSet(inp.nodeId, Number.isNaN(n) ? raw : n);
|
||||
}
|
||||
|
||||
function restore() {
|
||||
for (const inp of inputs) onSet(inp.nodeId, inp.defaultValue ?? '');
|
||||
}
|
||||
|
||||
function randomSeed(inp: ComfyDynamicInput) {
|
||||
const max = Math.min(inp.max ?? 2 ** 32 - 1, Number.MAX_SAFE_INTEGER);
|
||||
const min = Math.max(inp.min ?? 0, 0);
|
||||
onSet(inp.nodeId, min + Math.floor(Math.random() * (max - min + 1)));
|
||||
}
|
||||
|
||||
function pickOption(inp: ComfyDynamicInput, opt: string) {
|
||||
onSet(inp.nodeId, opt);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="param-chip">
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip"
|
||||
class:changed={changed && !invalid}
|
||||
class:err={invalid}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={title ?? inputs.map((i) => i.label).join(' × ')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span class="lbl">{text}</span>
|
||||
</button>
|
||||
<ShellPop bind:open label={inputs.map((i) => i.label).join(' × ')} placement="top" width="260px">
|
||||
{#if single?.kind === 'select' && single.options?.length}
|
||||
<div class="a-menu-group">{single.label}</div>
|
||||
{#each single.options as opt (opt)}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item"
|
||||
aria-current={current(single) === opt ? 'true' : undefined}
|
||||
onclick={() => pickOption(single, opt)}
|
||||
>
|
||||
<span class="a-menu-label">{opt}</span>
|
||||
{#if String(single.defaultValue ?? '') === opt}
|
||||
<span class="a-menu-meta">{t('desk.origValue')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="fields">
|
||||
{#each inputs as inp (inp.nodeId)}
|
||||
{@const id = `pc-${uid}-${inp.nodeId.replace(/[^a-zA-Z0-9_-]/g, '')}`}
|
||||
<div class="field-row">
|
||||
<label class="field-label" for={id}>
|
||||
{inp.label}
|
||||
{#if inp.defaultValue !== undefined && inp.defaultValue !== ''}
|
||||
<span class="orig mono">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<div class="inline">
|
||||
{#if inp.kind === 'number'}
|
||||
<input
|
||||
{id}
|
||||
class="a-input mono"
|
||||
class:invalid
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
value={current(inp)}
|
||||
oninput={(e) => setNumber(inp, e.currentTarget.value)}
|
||||
onkeydown={(e) => e.key === 'Enter' && (open = false)}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
{id}
|
||||
class="a-input"
|
||||
class:invalid
|
||||
type="text"
|
||||
value={current(inp)}
|
||||
oninput={(e) => onSet(inp.nodeId, e.currentTarget.value)}
|
||||
onkeydown={(e) => e.key === 'Enter' && (open = false)}
|
||||
/>
|
||||
{/if}
|
||||
{#if inputRole(inp) === 'seed'}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => randomSeed(inp)}>
|
||||
{t('desk.seedRandom')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if inp.min != null || inp.max != null}
|
||||
<span class="field-hint mono">{inp.min ?? '−∞'} – {inp.max ?? '∞'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if changed}
|
||||
<div class="foot">
|
||||
<button type="button" class="a-btn ghost sm" onclick={restore}>{t('desk.restoreOrig')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.param-chip {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: var(--sp-5);
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
.field-row {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.field-row .field-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin: 0;
|
||||
}
|
||||
.orig {
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
.inline {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.field-row .field-hint {
|
||||
margin: 0;
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--sp-2) var(--sp-1) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import type { DeskSchema, FormValue, FormValues } from './schema';
|
||||
|
||||
/**
|
||||
* 参数 (spec-v2 §3.3 检视区 · 参数): every non-media input the workflow
|
||||
* declares, each with its 原值 and a gold edge once changed. Common ones
|
||||
* first; the rest fold under 高级参数 — untouched, they keep the
|
||||
* workflow's own values.
|
||||
*/
|
||||
interface Props {
|
||||
workflow: Workflow | null;
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
errors: Record<string, string>;
|
||||
showErrors: boolean;
|
||||
changedCount: number;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onRestoreAll: () => void;
|
||||
}
|
||||
|
||||
let { workflow, ds, values, errors, showErrors, changedCount, onSet, onRestoreAll }: Props = $props();
|
||||
|
||||
let advancedOpen = $state(false);
|
||||
const brokenReasons = $derived(
|
||||
(workflow?.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message),
|
||||
);
|
||||
const advancedErr = $derived(showErrors && ds.advanced.some((i) => !!errors[i.nodeId]?.trim()));
|
||||
|
||||
$effect(() => {
|
||||
if (advancedErr) advancedOpen = true;
|
||||
});
|
||||
|
||||
function patch(inputs: ComfyDynamicInput[], next: Record<string, string | number>) {
|
||||
for (const inp of inputs) {
|
||||
const v = next[inp.nodeId];
|
||||
if (v !== undefined && v !== values[inp.nodeId]) onSet(inp.nodeId, v);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="params">
|
||||
{#if !workflow}
|
||||
<p class="note">{t('desk.pickWorkflow')}</p>
|
||||
{:else}
|
||||
{#if brokenReasons.length}
|
||||
<div class="note err" role="alert">
|
||||
<strong>{t('studio.wfBrokenTitle')}</strong>
|
||||
<ul>
|
||||
{#each brokenReasons as r (r)}<li>{r}</li>{/each}
|
||||
</ul>
|
||||
<span>{t('studio.wfBrokenHint')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !(workflow.input_schema ?? []).length}
|
||||
<p class="note">{t('studio.wfNoParamsNote')}</p>
|
||||
{:else if (workflow.input_schema ?? []).some((i) => i.auto)}
|
||||
<p class="note">{t('studio.wfAutoParamsNote', { n: workflow.input_schema.length })}</p>
|
||||
{/if}
|
||||
|
||||
{#if ds.common.length}
|
||||
<div class="section-title">{t('desk.paramsCommon')}</div>
|
||||
<ComfyDynamicForm
|
||||
inputs={ds.common}
|
||||
values={values as Record<string, string | number>}
|
||||
quiet
|
||||
showOrig
|
||||
{errors}
|
||||
{showErrors}
|
||||
onChange={(v) => patch(ds.common, v)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if ds.advanced.length}
|
||||
<div class="fold">
|
||||
<button
|
||||
type="button"
|
||||
class="fold-head"
|
||||
class:err={advancedErr}
|
||||
aria-expanded={advancedOpen}
|
||||
aria-controls="desk-advanced"
|
||||
onclick={() => (advancedOpen = !advancedOpen)}
|
||||
>
|
||||
<span class="chev" aria-hidden="true"><Icon name="play" size={10} /></span>
|
||||
{t('desk.advanced', { n: ds.advanced.length })}
|
||||
</button>
|
||||
<div id="desk-advanced" class="collapse" class:open={advancedOpen} inert={!advancedOpen}>
|
||||
<div class="collapse-in">
|
||||
<ComfyDynamicForm
|
||||
inputs={ds.advanced}
|
||||
values={values as Record<string, string | number>}
|
||||
quiet
|
||||
showOrig
|
||||
{errors}
|
||||
{showErrors}
|
||||
onChange={(v) => patch(ds.advanced, v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if changedCount > 0}
|
||||
<div class="restore">
|
||||
<button type="button" class="a-btn ghost sm" onclick={onRestoreAll}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('desk.restoreAll')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.params {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.note {
|
||||
margin: 0;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.note.err {
|
||||
background: var(--err-soft);
|
||||
color: var(--err);
|
||||
}
|
||||
.note ul {
|
||||
margin: var(--sp-2) 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.fold {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.fold-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
padding: var(--sp-4) 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color var(--d-state) ease;
|
||||
}
|
||||
.fold-head.err {
|
||||
color: var(--err);
|
||||
}
|
||||
.chev {
|
||||
display: inline-grid;
|
||||
transition: transform var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.fold-head[aria-expanded='true'] .chev {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.fold-head:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
.collapse {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
transition:
|
||||
grid-template-rows var(--d-enter) var(--ease-out),
|
||||
opacity var(--d-state) ease;
|
||||
}
|
||||
.collapse.open {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
}
|
||||
.collapse-in {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.collapse.open .collapse-in {
|
||||
padding-bottom: var(--sp-2);
|
||||
}
|
||||
.restore {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import MediaSlot from '$lib/components/studio/MediaSlot.svelte';
|
||||
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
|
||||
import { mediaFileProblem } from '$lib/comfy/imageFile';
|
||||
import { createUploadManager } from '$lib/comfy/useUpload.svelte';
|
||||
import type { ComfyDynamicInput } from '$lib/comfy/types';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { h3Warnings, listKind, listPaths, type DeskSchema, type FormValue, type FormValues, type SlotDef } from './schema';
|
||||
|
||||
/**
|
||||
* 输入槽 (spec-v2 §3.3): exactly the media inputs the workflow JSON wires —
|
||||
* text-to-image shows none, image-to-image one input, image-to-video a
|
||||
* first frame, ref-to-video the 1–N list. Owns uploads and rejects;
|
||||
* values flow back through `onSet`.
|
||||
*/
|
||||
interface Props {
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
showErrors: boolean;
|
||||
errors: Record<string, string>;
|
||||
/** True while any upload runs (generate waits for it). */
|
||||
busy?: boolean;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onFromResults: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
let { ds, values, showErrors, errors, busy = $bindable(false), onSet, onFromResults }: Props = $props();
|
||||
|
||||
const uploads = createUploadManager();
|
||||
type Reject = { name: string; message: string };
|
||||
let rejects = $state<Record<string, Reject[]>>({});
|
||||
let fileSizes = $state<Record<string, number>>({});
|
||||
let replacingKey = $state('');
|
||||
let replacingIndex = $state(-1);
|
||||
|
||||
$effect(() => {
|
||||
busy = Object.keys(uploads.uploading).length > 0;
|
||||
});
|
||||
|
||||
// A different workflow starts with no stale rejects.
|
||||
$effect(() => {
|
||||
void ds;
|
||||
rejects = {};
|
||||
});
|
||||
|
||||
const warns = $derived(h3Warnings(ds, values));
|
||||
|
||||
function setRejects(nodeId: string, list: Reject[]) {
|
||||
rejects = { ...rejects, [nodeId]: list };
|
||||
}
|
||||
|
||||
async function upload(nodeId: string, file: File): Promise<string | null> {
|
||||
const path = await uploads.uploadSafe(nodeId, file);
|
||||
if (path) fileSizes = { ...fileSizes, [path]: file.size };
|
||||
return path;
|
||||
}
|
||||
|
||||
async function slotFile(s: SlotDef, file: File) {
|
||||
const problem = mediaFileProblem(s.mediaKind, file);
|
||||
if (problem) {
|
||||
setRejects(s.inp.nodeId, [{ name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
setRejects(s.inp.nodeId, []);
|
||||
const path = await upload(s.inp.nodeId, file);
|
||||
if (path) onSet(s.inp.nodeId, path);
|
||||
}
|
||||
|
||||
/** Add picked/dropped files in order; a batch that would pass `max` is refused whole. */
|
||||
async function groupAdd(entry: ComfyDynamicInput, files: File[]) {
|
||||
const key = entry.nodeId;
|
||||
const mk = listKind(entry);
|
||||
const max = entry.max ?? 9;
|
||||
const ok = files.filter((f) => !mediaFileProblem(mk, f));
|
||||
setRejects(
|
||||
key,
|
||||
files.filter((f) => mediaFileProblem(mk, f)).map((f) => ({ name: f.name, message: mediaFileProblem(mk, f)! })),
|
||||
);
|
||||
const start = listPaths(values, entry);
|
||||
if (start.length + ok.length > max) {
|
||||
toast.error(t('studio.errRefMax', { max, n: start.length, k: ok.length }));
|
||||
return;
|
||||
}
|
||||
for (const f of ok) {
|
||||
const path = await upload(key, f);
|
||||
if (!path) break;
|
||||
onSet(key, [...listPaths(values, entry), path]);
|
||||
}
|
||||
}
|
||||
|
||||
async function groupReplace(entry: ComfyDynamicInput, index: number, file: File) {
|
||||
const key = entry.nodeId;
|
||||
const problem = mediaFileProblem(listKind(entry), file);
|
||||
if (problem) {
|
||||
setRejects(key, [{ name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
setRejects(key, []);
|
||||
replacingKey = key;
|
||||
replacingIndex = index;
|
||||
try {
|
||||
const path = await upload(key, file);
|
||||
if (path) onSet(key, listPaths(values, entry).map((p, i) => (i === index ? path : p)));
|
||||
} finally {
|
||||
replacingKey = '';
|
||||
replacingIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function fileKind(f: File): 'image' | 'video' | 'audio' {
|
||||
if (f.type.startsWith('video/')) return 'video';
|
||||
if (f.type.startsWith('audio/')) return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
/** Composer-wide drop: each file goes to the first wired slot of its kind with room. */
|
||||
export function dropFiles(files: File[]) {
|
||||
const pending = new Map<string, File[]>();
|
||||
const taken = new Set<string>();
|
||||
let unplaced = 0;
|
||||
for (const f of files) {
|
||||
const mk = fileKind(f);
|
||||
const slot = ds.slots.find(
|
||||
(s) =>
|
||||
s.mediaKind === mk &&
|
||||
!s.mask &&
|
||||
!taken.has(s.inp.nodeId) &&
|
||||
!String(values[s.inp.nodeId] ?? ''),
|
||||
);
|
||||
if (slot) {
|
||||
taken.add(slot.inp.nodeId);
|
||||
void slotFile(slot, f);
|
||||
continue;
|
||||
}
|
||||
const list = ds.lists.find((g) => listKind(g) === mk);
|
||||
if (list) {
|
||||
pending.set(list.nodeId, [...(pending.get(list.nodeId) ?? []), f]);
|
||||
continue;
|
||||
}
|
||||
unplaced += 1;
|
||||
}
|
||||
for (const [nodeId, batch] of pending) {
|
||||
const entry = ds.lists.find((g) => g.nodeId === nodeId);
|
||||
if (entry) void groupAdd(entry, batch);
|
||||
}
|
||||
if (unplaced) toast.error(t('desk.dropNoSlot', { n: unplaced }));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if ds.slots.length || ds.lists.length}
|
||||
<div class="tray">
|
||||
{#each ds.slots as s (s.inp.nodeId)}
|
||||
{@const reject = rejects[s.inp.nodeId]?.[0]}
|
||||
<MediaSlot
|
||||
label={s.label}
|
||||
hint={s.lastFrame && !s.inp.required ? t('studio.slotOptional') : undefined}
|
||||
required={s.inp.required}
|
||||
value={String(values[s.inp.nodeId] ?? '')}
|
||||
kind={s.mediaKind}
|
||||
size={fileSizes[String(values[s.inp.nodeId] ?? '')]}
|
||||
uploading={uploads.isUploading(s.inp.nodeId)}
|
||||
uploadName={uploads.uploading[s.inp.nodeId]}
|
||||
need={showErrors && !!errors[s.inp.nodeId]}
|
||||
error={reject?.message ?? (showErrors ? errors[s.inp.nodeId] : undefined)}
|
||||
allowFromResults={s.fromResults}
|
||||
anchorId={`slot-${s.inp.nodeId}`}
|
||||
onFile={(f) => slotFile(s, f)}
|
||||
onFromResults={() => onFromResults(s.inp.nodeId)}
|
||||
onClear={() => {
|
||||
setRejects(s.inp.nodeId, []);
|
||||
onSet(s.inp.nodeId, '');
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{#each ds.lists as entry (entry.nodeId)}
|
||||
<RefImageGrid
|
||||
label={entry.label}
|
||||
mediaKind={listKind(entry)}
|
||||
paths={listPaths(values, entry)}
|
||||
max={entry.max ?? 9}
|
||||
sizes={fileSizes}
|
||||
uploadingName={uploads.uploading[entry.nodeId]}
|
||||
uploadingIndex={replacingKey === entry.nodeId ? replacingIndex : -1}
|
||||
rejects={rejects[entry.nodeId] ?? []}
|
||||
need={showErrors && !!errors[entry.nodeId]}
|
||||
error={showErrors ? errors[entry.nodeId] : undefined}
|
||||
allowFromResults={entry.kind === 'image_list'}
|
||||
anchorId={`slot-${entry.nodeId}`}
|
||||
onAdd={(files) => groupAdd(entry, files)}
|
||||
onReplace={(i, file) => groupReplace(entry, i, file)}
|
||||
onRemove={(i) => onSet(entry.nodeId, listPaths(values, entry).filter((_, j) => j !== i))}
|
||||
onDismissReject={(i) => setRejects(entry.nodeId, (rejects[entry.nodeId] ?? []).filter((_, j) => j !== i))}
|
||||
onFromResults={entry.kind === 'image_list' ? () => onFromResults(entry.nodeId) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if showErrors && errors.firstLast}
|
||||
<p class="a-slot-hint err note">{errors.firstLast}</p>
|
||||
{/if}
|
||||
{#if ds.hasH3Hint}
|
||||
<div class="note" role="note">
|
||||
<p class="a-slot-hint">{t('wfMedia.h3Limits')}</p>
|
||||
{#each warns as w (w)}
|
||||
<p class="a-slot-hint warn">{w}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tray {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3) var(--sp-6);
|
||||
padding: var(--sp-4) var(--sp-5) 0;
|
||||
}
|
||||
.note {
|
||||
margin: 0;
|
||||
padding: var(--sp-2) var(--sp-5) 0;
|
||||
}
|
||||
.note p {
|
||||
margin: 0;
|
||||
}
|
||||
.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,568 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel } from './schema';
|
||||
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf } from './takes';
|
||||
|
||||
/**
|
||||
* 一条记录 (spec-v2 §3.3 记录卡): workflow · usage · time, the prompt, the
|
||||
* inputs it used, its real state (phase / progress / time / error) and
|
||||
* the first output. The whole card selects; actions sit above it.
|
||||
* D 批 (HEL-654): the gold sel-mark frame slides between cards on
|
||||
* pointer selection (driven by the feed), new takes enter with a
|
||||
* 220ms rise, completion flashes once, failure shakes once, a done
|
||||
* take whose files no longer load turns amber (文件缺失) with
|
||||
* 用相同参数重做 / 移除记录, and takes on a deleted workflow are
|
||||
* marked as such instead of pretending nothing changed.
|
||||
*/
|
||||
interface Props {
|
||||
job: Job;
|
||||
wf: Workflow | undefined;
|
||||
selected: boolean;
|
||||
nowMs: number;
|
||||
busy?: boolean;
|
||||
/** Play the 220ms entrance rise (new take / undo restore). */
|
||||
enter?: boolean;
|
||||
/** One-shot 900ms gold flash on completion. */
|
||||
flash?: boolean;
|
||||
/** One-shot failure shake. */
|
||||
shake?: boolean;
|
||||
onSelect: () => void;
|
||||
onReuse: () => void;
|
||||
onRetry: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
job,
|
||||
wf,
|
||||
selected,
|
||||
nowMs,
|
||||
busy = false,
|
||||
enter = false,
|
||||
flash = false,
|
||||
shake = false,
|
||||
onSelect,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
const st = $derived(jobStatusInfo(job));
|
||||
// §3.4 红/琥珀界线: red follows the backend task status only. Amber is
|
||||
// reserved for a SUCCEEDED take whose product files no longer load.
|
||||
const gonePaths = $derived(missingOutputsOf(job));
|
||||
const fileGone = $derived(gonePaths.length > 0);
|
||||
const tone = $derived(fileGone ? 'warn' : st.tone === 'queue' ? '' : st.tone === 'fail' ? 'err' : st.tone);
|
||||
const label = $derived(fileGone ? t('jobst.missing') : st.label);
|
||||
const wfDeleted = $derived(job.workflow_id == null);
|
||||
const prompt = $derived(promptOf(job, wf));
|
||||
const inputs = $derived(inputsOf(job, wf).filter((i) => i.kind === 'image'));
|
||||
const output = $derived(
|
||||
fileGone
|
||||
? (job.output_paths.find((p) => !gonePaths.includes(p)) ?? null)
|
||||
: (job.output_paths[0] ?? null),
|
||||
);
|
||||
const progress = $derived.by(() => {
|
||||
if (job.status !== 'running') return 0;
|
||||
const live = progressFor(job.id);
|
||||
const phase = live?.phase ?? job.phase;
|
||||
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
|
||||
return live?.progress ?? 3;
|
||||
});
|
||||
const active = $derived(job.status === 'pending' || job.status === 'running');
|
||||
const name = $derived(wf?.name ?? t('studio.jobName', { id: job.id }));
|
||||
|
||||
let portrait = $state(false);
|
||||
let clipSec = $state<number | null>(null);
|
||||
let errOpen = $state(false);
|
||||
|
||||
function noteImage(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
portrait = img.naturalHeight > img.naturalWidth;
|
||||
}
|
||||
|
||||
function noteVideo(e: Event) {
|
||||
const v = e.currentTarget as HTMLVideoElement;
|
||||
portrait = v.videoHeight > v.videoWidth;
|
||||
if (Number.isFinite(v.duration)) clipSec = v.duration;
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="take"
|
||||
class:selected
|
||||
class:enter
|
||||
class:done-flash={flash}
|
||||
class:shake
|
||||
data-job={job.id}
|
||||
>
|
||||
<span class="sel-mark" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="hit"
|
||||
aria-pressed={selected}
|
||||
aria-label={`${name} · ${label}`}
|
||||
onclick={onSelect}
|
||||
></button>
|
||||
|
||||
<div class="head">
|
||||
<span class="name">{name}</span>
|
||||
{#if wfDeleted}<span class="a-tag">{t('studio.wfDeletedTag')}</span>{/if}
|
||||
{#if wf?.usage}<span class="a-tag">{usageLabel(wf.usage)}</span>{/if}
|
||||
<span class="meta mono">{fmtClock(parseDbTs(job.created_at))}</span>
|
||||
</div>
|
||||
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
|
||||
{#if inputs.length}
|
||||
<div class="inputs" aria-label={t('desk.inputs')}>
|
||||
{#each inputs.slice(0, 5) as inp (inp.path + inp.label)}
|
||||
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
|
||||
{/each}
|
||||
{#if inputs.length > 5}<span class="more">+{inputs.length - 5}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="state">
|
||||
<span class="a-tag {tone}">{label}</span>
|
||||
{#if st.tone === 'run'}
|
||||
{#if progress > 0}
|
||||
<div class="a-progress bar" style:--p={progress / 100}><i></i></div>
|
||||
{/if}
|
||||
<span class="txt">{fmtDur(elapsedSec(job, nowMs))}</span>
|
||||
{:else if st.tone === 'ok' && !fileGone}
|
||||
<span class="txt">{t('desk.took', { d: fmtDur(elapsedSec(job, nowMs)) })}</span>
|
||||
{:else if st.tone === 'fail'}
|
||||
<button
|
||||
type="button"
|
||||
class="err-txt"
|
||||
class:open={errOpen}
|
||||
title={st.detail}
|
||||
aria-expanded={errOpen}
|
||||
onclick={() => (errOpen = !errOpen)}
|
||||
>
|
||||
{st.detail}
|
||||
</button>
|
||||
{:else if fileGone}
|
||||
<span class="gone-txt">{t('jobst.missingDetail')}</span>
|
||||
{:else}
|
||||
<span class="txt">{st.detail}</span>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
{#if fileGone}
|
||||
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>
|
||||
{t('desk.redo')}
|
||||
</button>
|
||||
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('desk.removeRecord')}</button>
|
||||
{:else}
|
||||
<button type="button" class="a-btn ghost xs" onclick={onReuse}>{t('desk.reuse')}</button>
|
||||
{#if st.tone === 'fail' && job.error !== 'cancelled'}
|
||||
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>{t('common.retry')}</button>
|
||||
{/if}
|
||||
{#if active}
|
||||
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onCancel}>{t('common.cancel')}</button>
|
||||
{/if}
|
||||
{#if output}
|
||||
<a class="a-btn ghost xs" href={assetUrl(output)} download>{t('desk.download')}</a>
|
||||
{/if}
|
||||
{#if !active}
|
||||
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('studio.deleteBtn')}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="out">
|
||||
{#if output}
|
||||
<div class="thumb" class:portrait>
|
||||
{#if isVideoOutput(job, output)}
|
||||
<video
|
||||
src={assetUrl(output)}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
></video>
|
||||
<span class="play" aria-hidden="true"><Icon name="play" size={10} /></span>
|
||||
{#if clipSec != null}<span class="badge mono">{fmtDur(clipSec)}</span>{/if}
|
||||
{:else}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImage(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
{/if}
|
||||
{#if job.output_paths.length > 1}<span class="count mono">×{job.output_paths.length}</span>{/if}
|
||||
</div>
|
||||
{:else if active}
|
||||
<div class="thumb pending">{st.label}</div>
|
||||
{:else if fileGone}
|
||||
<div class="thumb gone">{t('jobst.missing')}</div>
|
||||
{:else}
|
||||
<div class="thumb missing">{t('desk.notGenerated')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.take {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--sp-4) var(--sp-6);
|
||||
margin-bottom: var(--sp-3);
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--bg-2);
|
||||
transition:
|
||||
border-color var(--d-state) ease,
|
||||
background-color var(--d-state) ease,
|
||||
box-shadow var(--d-state) ease;
|
||||
}
|
||||
/* 选中框:金色描边从上一条记录滑到新选中的记录(feed 驱动 WAAPI) */
|
||||
.sel-mark {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
z-index: 3;
|
||||
display: none;
|
||||
border: 1px solid var(--accent-line);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.take.selected {
|
||||
border-color: transparent;
|
||||
background: var(--accent-wash), var(--bg-3);
|
||||
box-shadow: var(--sel-shadow);
|
||||
}
|
||||
.take.selected > .sel-mark {
|
||||
display: block;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.take:hover:not(.selected) {
|
||||
border-color: var(--line-2);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
}
|
||||
/* 新记录 / 撤销恢复:220ms 上移 8px 入场(减动效只余淡变) */
|
||||
.take.enter {
|
||||
animation: take-in var(--d-enter) var(--ease-out);
|
||||
}
|
||||
@keyframes take-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
/* 完成一次性金色光晕 900ms;失败原地轻抖一次 */
|
||||
.take.done-flash {
|
||||
animation: done-flash var(--d-flash) var(--ease-out);
|
||||
}
|
||||
@keyframes done-flash {
|
||||
from {
|
||||
box-shadow: var(--accent-glow), 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
}
|
||||
.take.shake {
|
||||
animation: take-shake 320ms var(--ease-out);
|
||||
}
|
||||
@keyframes take-shake {
|
||||
20% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
45% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
70% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .take.enter {
|
||||
animation: take-fade var(--d-state) ease;
|
||||
}
|
||||
:global(:root[data-reduce='1']) .take.done-flash,
|
||||
:global(:root[data-reduce='1']) .take.shake {
|
||||
animation: none;
|
||||
}
|
||||
@keyframes take-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.hit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hit:focus-visible {
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
.head,
|
||||
.prompt,
|
||||
.inputs,
|
||||
.state {
|
||||
grid-column: 1;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.meta {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.prompt {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-body-sm);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.mini {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 5px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line-2);
|
||||
}
|
||||
.more {
|
||||
padding-left: 2px;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-height: 22px;
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.bar {
|
||||
flex: 0 0 140px;
|
||||
}
|
||||
.txt {
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gone-txt {
|
||||
min-width: 0;
|
||||
color: var(--warn);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* 失败:一行中文结论自动展开,点开看完整详情 */
|
||||
.err-txt {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--err);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.err-txt.open {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
.err-txt:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transition: opacity var(--d-state) ease;
|
||||
}
|
||||
.take:focus-within .actions,
|
||||
.take.selected .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.take:hover .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (hover: none) {
|
||||
.actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.out {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 4;
|
||||
align-self: start;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
.thumb {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-4);
|
||||
box-shadow: inset 0 0 0 1px var(--on-media-line);
|
||||
}
|
||||
.thumb.portrait {
|
||||
width: 96px;
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
.thumb img,
|
||||
.thumb video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.badge,
|
||||
.count {
|
||||
position: absolute;
|
||||
bottom: var(--sp-2);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.badge {
|
||||
right: var(--sp-2);
|
||||
}
|
||||
.count {
|
||||
right: var(--sp-2);
|
||||
top: var(--sp-2);
|
||||
bottom: auto;
|
||||
}
|
||||
.play {
|
||||
position: absolute;
|
||||
left: var(--sp-2);
|
||||
bottom: var(--sp-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
}
|
||||
.thumb.pending,
|
||||
.thumb.missing,
|
||||
.thumb.gone {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-2);
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
border: 1px dashed var(--line-2);
|
||||
background: var(--bg-2);
|
||||
box-shadow: none;
|
||||
}
|
||||
.thumb.pending {
|
||||
border-style: solid;
|
||||
color: var(--run);
|
||||
}
|
||||
.thumb.gone {
|
||||
color: var(--warn);
|
||||
border-color: var(--warn);
|
||||
}
|
||||
.thumb.pending::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(100deg, transparent 30%, var(--run-soft) 50%, transparent 70%);
|
||||
animation: take-sheen 1.4s linear infinite;
|
||||
}
|
||||
@keyframes take-sheen {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .thumb.pending::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.take {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
.head {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 2px;
|
||||
}
|
||||
.out {
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
.state {
|
||||
grid-column: 1 / -1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.thumb {
|
||||
width: 108px;
|
||||
}
|
||||
.thumb.portrait {
|
||||
width: 72px;
|
||||
}
|
||||
.bar {
|
||||
flex-basis: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,344 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import TakeCard from './TakeCard.svelte';
|
||||
import { dayLabel, matchesFilter, type FeedFilter } from './takes';
|
||||
|
||||
/**
|
||||
* 记录流 (spec-v2 §3.3): every desk take, newest last above the composer,
|
||||
* filtered 全部 / 图片 / 视频 / 失败 and grouped by day.
|
||||
* D 批 (HEL-654): rows pending their 5-second undo collapse out
|
||||
* (200ms) instead of vanishing, new/restored takes rise in (220ms),
|
||||
* the gold selection frame slides between cards on pointer picks
|
||||
* (键盘 ↑↓/J/K 即时切换无动画 — driven by the desk through the
|
||||
* exported helpers), and long histories page in from the top
|
||||
* (spec §五: 记录流超 200 条分页).
|
||||
*/
|
||||
interface Props {
|
||||
jobs: Job[];
|
||||
workflowOf: (job: Job) => Workflow | undefined;
|
||||
filter: FeedFilter;
|
||||
selectedId: number | null;
|
||||
nowMs: number;
|
||||
busyId: number | null;
|
||||
loading: boolean;
|
||||
/** Takes whose delete is inside the 5s undo window — hidden. */
|
||||
pendingIds: SvelteSet<number>;
|
||||
/** One-shot completion flashes / failure shakes (driven by SSE). */
|
||||
flashIds: SvelteSet<number>;
|
||||
shakeIds: SvelteSet<number>;
|
||||
/** Ordered visible ids (oldest first) for ↑↓/J/K. */
|
||||
orderedIds?: number[];
|
||||
/** Shown under the filters (e.g. the workbench context bar). */
|
||||
banner?: Snippet;
|
||||
/** Inspector toggle when it is hidden (desktop) or an overlay (mid). */
|
||||
inspectorToggle?: Snippet;
|
||||
onSelect: (job: Job) => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onCancel: (job: Job) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
jobs,
|
||||
workflowOf,
|
||||
filter = $bindable(),
|
||||
selectedId,
|
||||
nowMs,
|
||||
busyId,
|
||||
loading,
|
||||
pendingIds,
|
||||
flashIds,
|
||||
shakeIds,
|
||||
orderedIds = $bindable(),
|
||||
banner,
|
||||
inspectorToggle,
|
||||
onSelect,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
const FILTERS: Array<{ id: FeedFilter; key: string }> = [
|
||||
{ id: 'all', key: 'desk.filterAll' },
|
||||
{ id: 'image', key: 'desk.filterImage' },
|
||||
{ id: 'video', key: 'desk.filterVideo' },
|
||||
{ id: 'failed', key: 'desk.filterFailed' },
|
||||
];
|
||||
|
||||
/** §五 分页: render the newest PAGE_SIZE takes; older ones load on demand. */
|
||||
const PAGE_SIZE = 80;
|
||||
let limit = $state(PAGE_SIZE);
|
||||
|
||||
let scroller = $state<HTMLElement | null>(null);
|
||||
|
||||
/** Oldest first so the newest take sits right above the composer. */
|
||||
const shown = $derived(
|
||||
jobs
|
||||
.filter((j) => matchesFilter(j, filter) && !pendingIds.has(j.id))
|
||||
.sort((a, b) => parseDbTs(a.created_at) - parseDbTs(b.created_at)),
|
||||
);
|
||||
const hiddenEarlier = $derived(Math.max(0, shown.length - limit));
|
||||
const visible = $derived(hiddenEarlier > 0 ? shown.slice(-limit) : shown);
|
||||
$effect(() => {
|
||||
orderedIds = visible.map((j) => j.id);
|
||||
});
|
||||
const activeCount = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running').length);
|
||||
const days = $derived.by(() => {
|
||||
const out: Array<{ label: string; items: Job[] }> = [];
|
||||
for (const job of visible) {
|
||||
const label = dayLabel(parseDbTs(job.created_at), nowMs);
|
||||
const last = out[out.length - 1];
|
||||
if (last?.label === label) last.items.push(job);
|
||||
else out.push({ label, items: [job] });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Stick to the bottom as takes arrive, unless the user scrolled up to read.
|
||||
let pinned = true;
|
||||
function onScroll() {
|
||||
if (!scroller) return;
|
||||
pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80;
|
||||
}
|
||||
$effect(() => {
|
||||
void visible.length;
|
||||
void filter;
|
||||
if (!scroller || !pinned) return;
|
||||
const el = scroller;
|
||||
requestAnimationFrame(() => (el.scrollTop = el.scrollHeight));
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Entrance bookkeeping: only takes that appear AFTER the first fill
|
||||
// (or come back from an undo) play the 220ms rise.
|
||||
// ------------------------------------------------------------------
|
||||
const seenIds = new Set<number>();
|
||||
const animateIds = new SvelteSet<number>();
|
||||
let hydrated = false;
|
||||
$effect(() => {
|
||||
if (!hydrated) {
|
||||
if (jobs.length) {
|
||||
for (const j of jobs) seenIds.add(j.id);
|
||||
hydrated = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const j of jobs) {
|
||||
if (seenIds.has(j.id)) continue;
|
||||
seenIds.add(j.id);
|
||||
animateIds.add(j.id);
|
||||
}
|
||||
});
|
||||
|
||||
export function markEnter(jobId: number) {
|
||||
seenIds.add(jobId);
|
||||
animateIds.add(jobId);
|
||||
}
|
||||
|
||||
function reduced(): boolean {
|
||||
return (
|
||||
document.documentElement.dataset.reduce === '1' ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Gold selection frame (HEL-648 slideMark): pointer picks only.
|
||||
// ------------------------------------------------------------------
|
||||
export function markRect(): DOMRect | null {
|
||||
const el = scroller?.querySelector(`.take[data-job="${selectedId}"]`);
|
||||
return el instanceof HTMLElement ? el.getBoundingClientRect() : null;
|
||||
}
|
||||
|
||||
export function slideFrom(fromRect: DOMRect | null) {
|
||||
if (!fromRect || reduced()) return;
|
||||
const el = scroller?.querySelector(`.take[data-job="${selectedId}"]`);
|
||||
const mark = el?.querySelector('.sel-mark');
|
||||
if (!(el instanceof HTMLElement) || !(mark instanceof HTMLElement)) return;
|
||||
const to = el.getBoundingClientRect();
|
||||
if (Math.abs(fromRect.top - to.top) < 1 && Math.abs(fromRect.height - to.height) < 1) return;
|
||||
mark.animate(
|
||||
[
|
||||
{ top: `${fromRect.top - to.top - 1}px`, bottom: `${to.bottom - fromRect.bottom - 1}px` },
|
||||
{ top: '-1px', bottom: '-1px' },
|
||||
],
|
||||
{ duration: 220, easing: 'cubic-bezier(0.23, 1, 0.32, 1)' },
|
||||
);
|
||||
}
|
||||
|
||||
/** Bring a keyboard-selected take into view without animation. */
|
||||
export function scrollToJob(jobId: number) {
|
||||
const el = scroller?.querySelector(`.take[data-job="${jobId}"]`);
|
||||
el?.scrollIntoView({ block: 'nearest', behavior: 'auto' });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Row transitions: undo-pending rows collapse out (200ms 收起 + 补位),
|
||||
// new/restored takes rise in (220ms). Everything else is instant.
|
||||
// ------------------------------------------------------------------
|
||||
function takeEnter(_node: HTMLElement, should: () => boolean) {
|
||||
if (!should()) return { duration: 0 };
|
||||
if (reduced()) return { duration: 120, css: (t: number) => `opacity: ${t}` };
|
||||
return {
|
||||
duration: 220,
|
||||
easing: cubicOut,
|
||||
css: (t: number) => `opacity: ${t}; transform: translateY(${8 * (1 - t)}px)`,
|
||||
};
|
||||
}
|
||||
|
||||
function takeLeave(node: HTMLElement, isPending: () => boolean) {
|
||||
if (!isPending()) return { duration: 0 };
|
||||
if (reduced()) return { duration: 120, css: (t: number) => `opacity: ${t}` };
|
||||
const h = node.offsetHeight;
|
||||
const cs = getComputedStyle(node);
|
||||
const num = (v: string) => parseFloat(v) || 0;
|
||||
const padT = num(cs.paddingTop);
|
||||
const padB = num(cs.paddingBottom);
|
||||
const mb = num(cs.marginBottom);
|
||||
return {
|
||||
duration: 200,
|
||||
easing: cubicOut,
|
||||
css: (t: number, u: number) =>
|
||||
`height:${h * t}px;padding-top:${padT * t}px;padding-bottom:${padB * t}px;` +
|
||||
`margin-bottom:${mb * t}px;opacity:${t};transform:translateX(${-10 * u}px);overflow:hidden;`,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="filters">
|
||||
<div class="a-seg" role="group" aria-label={t('desk.filterAria')}>
|
||||
{#each FILTERS as f (f.id)}
|
||||
<button type="button" aria-pressed={filter === f.id} onclick={() => (filter = f.id)}>{t(f.key)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="count">
|
||||
{t('desk.countTakes', { n: shown.length })}{#if activeCount} · {t('desk.countActive', { n: activeCount })}{/if}
|
||||
</span>
|
||||
{@render inspectorToggle?.()}
|
||||
</div>
|
||||
{@render banner?.()}
|
||||
|
||||
<div class="feed" bind:this={scroller} onscroll={onScroll}>
|
||||
{#if loading && !jobs.length}
|
||||
<p class="empty">{t('common.loading')}</p>
|
||||
{:else if !shown.length}
|
||||
<div class="empty">
|
||||
<Icon name={filter === 'video' ? 'film' : filter === 'failed' ? 'check' : 'image'} size={20} />
|
||||
<p>{filter === 'failed' ? t('desk.emptyFailed') : t('desk.emptyFeed')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if hiddenEarlier > 0}
|
||||
<button type="button" class="a-btn ghost sm earlier" onclick={() => (limit += PAGE_SIZE)}>
|
||||
<Icon name="chevron-up" size={13} />
|
||||
{t('desk.loadEarlier', { n: hiddenEarlier })}
|
||||
</button>
|
||||
{/if}
|
||||
{#each days as day (day.label)}
|
||||
<div class="day">{day.label}</div>
|
||||
{#each day.items as job (job.id)}
|
||||
<div
|
||||
class="take-slot"
|
||||
in:takeEnter={() => animateIds.has(job.id)}
|
||||
out:takeLeave={() => pendingIds.has(job.id)}
|
||||
>
|
||||
<TakeCard
|
||||
{job}
|
||||
wf={workflowOf(job)}
|
||||
selected={job.id === selectedId}
|
||||
{nowMs}
|
||||
busy={busyId === job.id}
|
||||
enter={animateIds.has(job.id)}
|
||||
flash={flashIds.has(job.id)}
|
||||
shake={shakeIds.has(job.id)}
|
||||
onSelect={() => onSelect(job)}
|
||||
onReuse={() => onReuse(job)}
|
||||
onRetry={() => onRetry(job)}
|
||||
onCancel={() => onCancel(job)}
|
||||
onDelete={() => onDelete(job)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4) var(--sp-7);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.count {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.feed {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-6) var(--sp-7) var(--sp-3);
|
||||
}
|
||||
.earlier {
|
||||
display: flex;
|
||||
margin: 0 auto var(--sp-4);
|
||||
}
|
||||
.day {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin: var(--sp-4) 0 var(--sp-3);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.day::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
.empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin: 0;
|
||||
padding: var(--sp-8) var(--sp-6);
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.empty p {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.filters {
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
}
|
||||
.feed {
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-3);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.filters {
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.count {
|
||||
display: none;
|
||||
}
|
||||
.feed {
|
||||
padding: var(--sp-5) var(--sp-5) var(--sp-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import { WORKFLOW_USAGES, type Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { slotSummary, usageLabel } from './schema';
|
||||
|
||||
/**
|
||||
* 工作流选择 (spec-v2 §3.3 工作流面板): the composer's first chip opens a
|
||||
* pop grouped by usage with search; each row shows what the workflow
|
||||
* really takes (slot summary from its JSON), its connection and whether
|
||||
* it last ran. Picking keeps the prompt and carried-over images.
|
||||
* D 批 (HEL-654): the footer's「↑↓ 选择」is now literally true — an
|
||||
* active row follows ↑↓ (and the pointer), Enter uses it, and the
|
||||
* search's Enter picks the active row instead of always the first.
|
||||
*/
|
||||
interface Props {
|
||||
workflows: Workflow[];
|
||||
current: Workflow | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onPick: (wf: Workflow) => void;
|
||||
}
|
||||
|
||||
let { workflows, current, endpoints, onPick }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let activeId = $state<number | null>(null);
|
||||
|
||||
type Status = { tone: '' | 'ok' | 'warn' | 'err'; label: string };
|
||||
|
||||
function statusOf(wf: Workflow): Status {
|
||||
if ((wf.warnings ?? []).some((w) => w.level === 'error')) return { tone: 'err', label: t('desk.wfBroken') };
|
||||
const lt = wf.last_test;
|
||||
if (!lt) return { tone: '', label: t('desk.wfUntested') };
|
||||
if (lt.status === 'done' && lt.output_count > 0) return { tone: 'ok', label: t('desk.wfOk') };
|
||||
if (lt.status === 'failed' || lt.status === 'done') return { tone: 'warn', label: t('desk.wfLastFailed') };
|
||||
return { tone: '', label: t('desk.wfTesting') };
|
||||
}
|
||||
|
||||
function endpointLabel(wf: Workflow): string {
|
||||
const ep = endpoints.find((e) => e.id === wf.endpoint_id);
|
||||
return ep ? ep.name : t('desk.wfDefaultConn');
|
||||
}
|
||||
|
||||
const rows = $derived(
|
||||
workflows.map((wf) => ({
|
||||
wf,
|
||||
summary: slotSummary(wf),
|
||||
conn: endpointLabel(wf),
|
||||
status: statusOf(wf),
|
||||
usage: wf.usage ? usageLabel(wf.usage) : '',
|
||||
})),
|
||||
);
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const hit = (r: (typeof rows)[number]) =>
|
||||
!q || `${r.wf.name} ${r.usage} ${r.summary}`.toLowerCase().includes(q);
|
||||
return WORKFLOW_USAGES.map((u) => ({
|
||||
usage: u,
|
||||
items: rows.filter((r) => r.wf.usage === u && hit(r)),
|
||||
})).filter((g) => g.items.length);
|
||||
});
|
||||
|
||||
/** Flattened rows in display order — the roving selection walks these. */
|
||||
const flat = $derived(groups.flatMap((g) => g.items));
|
||||
|
||||
// Keep the active row real: default to the current workflow, clamp on
|
||||
// filter changes, never point at a row that is no longer listed.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
if (activeId != null && flat.some((r) => r.wf.id === activeId)) return;
|
||||
activeId = flat.find((r) => r.wf.id === current?.id)?.wf.id ?? flat[0]?.wf.id ?? null;
|
||||
});
|
||||
|
||||
function moveActive(delta: number) {
|
||||
if (!flat.length) return;
|
||||
let i = flat.findIndex((r) => r.wf.id === activeId);
|
||||
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
|
||||
activeId = flat[Math.max(0, Math.min(flat.length - 1, i))].wf.id;
|
||||
}
|
||||
|
||||
function pickActive() {
|
||||
const row = flat.find((r) => r.wf.id === activeId) ?? flat[0];
|
||||
if (row) pick(row.wf);
|
||||
}
|
||||
|
||||
function pick(wf: Workflow) {
|
||||
open = false;
|
||||
query = '';
|
||||
if (wf.id !== current?.id) onPick(wf);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
pickActive();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="picker">
|
||||
<button
|
||||
type="button"
|
||||
id="desk-wf"
|
||||
class="a-chip wf"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={current?.name ?? t('desk.pickWorkflow')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<Icon name="playground" size={14} />
|
||||
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
|
||||
</button>
|
||||
<ShellPop
|
||||
bind:open
|
||||
label={t('desk.pickWorkflow')}
|
||||
placement="top"
|
||||
width="520px"
|
||||
arrowRoving={false}
|
||||
onclose={() => (query = '')}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="pop-body" onkeydown={onKeydown}>
|
||||
<div class="search">
|
||||
<Icon name="search" size={14} />
|
||||
<input
|
||||
class="search-input"
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder={t('desk.wfSearchPh')}
|
||||
aria-label={t('desk.wfSearchPh')}
|
||||
/>
|
||||
</div>
|
||||
<div class="list" role="listbox" aria-label={t('desk.pickWorkflow')}>
|
||||
{#each groups as g (g.usage)}
|
||||
<div class="a-menu-group">{usageLabel(g.usage)} · {g.items.length}</div>
|
||||
{#each g.items as r (r.wf.id)}
|
||||
{@const cur = r.wf.id === current?.id}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item row"
|
||||
class:off={r.status.tone === 'err'}
|
||||
class:active={r.wf.id === activeId}
|
||||
role="option"
|
||||
aria-current={cur ? 'true' : undefined}
|
||||
aria-selected={r.wf.id === activeId ? 'true' : undefined}
|
||||
onclick={() => pick(r.wf)}
|
||||
onpointermove={() => (activeId = r.wf.id)}
|
||||
>
|
||||
<span class="main">
|
||||
<span class="a-menu-label">{r.wf.name}{cur ? ` · ${t('desk.wfCurrent')}` : ''}</span>
|
||||
<span class="sub">
|
||||
<span class="a-tag">{r.summary}</span>
|
||||
<span class="a-tag">{r.conn}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="a-tag {r.status.tone}">{r.status.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="empty">{t('desk.wfNoMatch')}</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="foot">
|
||||
<a class="a-btn ghost sm" href="/connections">
|
||||
<Icon name="upload" size={13} />
|
||||
{t('desk.wfImport')}
|
||||
</a>
|
||||
<span class="keys">
|
||||
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
|
||||
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
|
||||
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.pop-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
/* Search and foot stay put; only the list scrolls inside the pop's max-height. */
|
||||
.list {
|
||||
max-height: min(380px, 50vh);
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
.row {
|
||||
align-items: center;
|
||||
}
|
||||
/* 键盘游走选中态(↑↓ 选择 · Enter 使用) */
|
||||
.row.active {
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.main {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.main .a-menu-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.row.off .a-menu-label {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3) var(--sp-1) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keys .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { WorkbenchStageKey } from '$lib/api';
|
||||
import type { DeskKind } from './schema';
|
||||
|
||||
/**
|
||||
* 五段创作 → 生成台 handoff, carried in the URL so a reload keeps it.
|
||||
* The desk only prefills from it; the playground generate API has no
|
||||
* project/shot link, so results stay in the desk feed (no backfill).
|
||||
*/
|
||||
export interface DeskContext {
|
||||
kind: DeskKind;
|
||||
projectId: number;
|
||||
stage: WorkbenchStageKey;
|
||||
/** Human label of the source row, e.g. 镜头 S1 / 角色「林夏」. */
|
||||
label: string;
|
||||
prompt?: string;
|
||||
negative?: string;
|
||||
durationSec?: number;
|
||||
}
|
||||
|
||||
const STAGES: WorkbenchStageKey[] = ['script', 'style', 'storyboard', 'assets', 'shot'];
|
||||
|
||||
export function deskHref(ctx: DeskContext): string {
|
||||
const q = new URLSearchParams({
|
||||
from: 'workbench',
|
||||
project: String(ctx.projectId),
|
||||
stage: ctx.stage,
|
||||
label: ctx.label,
|
||||
});
|
||||
if (ctx.prompt) q.set('prompt', ctx.prompt);
|
||||
if (ctx.negative) q.set('negative', ctx.negative);
|
||||
if (ctx.durationSec != null) q.set('duration', String(ctx.durationSec));
|
||||
return `/${ctx.kind}?${q.toString()}`;
|
||||
}
|
||||
|
||||
export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
|
||||
const q = url.searchParams;
|
||||
if (q.get('from') !== 'workbench') return null;
|
||||
const projectId = Number(q.get('project'));
|
||||
const stage = q.get('stage') as WorkbenchStageKey | null;
|
||||
if (!Number.isInteger(projectId) || projectId <= 0 || !stage || !STAGES.includes(stage)) return null;
|
||||
const duration = Number(q.get('duration'));
|
||||
return {
|
||||
kind,
|
||||
projectId,
|
||||
stage,
|
||||
label: q.get('label') ?? '',
|
||||
prompt: q.get('prompt') ?? undefined,
|
||||
negative: q.get('negative') ?? undefined,
|
||||
durationSec: Number.isFinite(duration) && duration > 0 ? duration : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function backHref(ctx: DeskContext): string {
|
||||
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import type { ComfyDynamicInput, Workflow, WorkflowUsage } from '$lib/comfy/types';
|
||||
import { normalizeInputRole } from '$lib/comfy/parser';
|
||||
import { ratioShort, resolutionSelectorSize } from '$lib/comfy/resolution';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 生成台表单识别 (HEL-653, moved verbatim from StudioGenerate): slots and
|
||||
* structured params come from what the workflow JSON actually declares —
|
||||
* never from model names. No wiring → no slot.
|
||||
*/
|
||||
|
||||
export type DeskKind = 'image' | 'video';
|
||||
export type FormValue = string | number | string[];
|
||||
export type FormValues = Record<string, FormValue>;
|
||||
|
||||
export interface SlotDef {
|
||||
inp: ComfyDynamicInput;
|
||||
label: string;
|
||||
mediaKind: 'image' | 'video' | 'audio';
|
||||
fromResults: boolean;
|
||||
/** Required-slot message when left empty (ref-upload-spec §3). */
|
||||
missing: string;
|
||||
firstFrame: boolean;
|
||||
lastFrame: boolean;
|
||||
mask: boolean;
|
||||
}
|
||||
|
||||
/** Why 生成 is disabled — the line next to the button (spec-v2 §1.2). */
|
||||
export interface BlockReason {
|
||||
text: string;
|
||||
tone: 'warn' | 'err';
|
||||
/** Element id to focus when the reason is clicked. */
|
||||
target?: string;
|
||||
/** Open the params tab instead of focusing. */
|
||||
params?: boolean;
|
||||
}
|
||||
|
||||
export interface DeskSchema {
|
||||
prompt: ComfyDynamicInput | null;
|
||||
negative: ComfyDynamicInput | null;
|
||||
seed: ComfyDynamicInput | null;
|
||||
width: ComfyDynamicInput | null;
|
||||
height: ComfyDynamicInput | null;
|
||||
resolution: ComfyDynamicInput | null;
|
||||
duration: ComfyDynamicInput | null;
|
||||
batch: ComfyDynamicInput | null;
|
||||
/** ComfyUI ResolutionSelector: the ratio + megapixels pair decides width/height. */
|
||||
ratio: ComfyDynamicInput | null;
|
||||
megapixels: ComfyDynamicInput | null;
|
||||
/** Single media inputs (input image, first/last frame, mask, ref video/audio). */
|
||||
slots: SlotDef[];
|
||||
/** Autogrow media lists actually wired (image / video / audio). */
|
||||
lists: ComfyDynamicInput[];
|
||||
/** Parameters shown as composer chips (比例 / 尺寸 / 分辨率 / 时长 / 种子). */
|
||||
common: ComfyDynamicInput[];
|
||||
/** Everything else that is not media and not the prompt — folded. */
|
||||
advanced: ComfyDynamicInput[];
|
||||
hasH3Hint: boolean;
|
||||
}
|
||||
|
||||
export function inputRole(inp: ComfyDynamicInput): string | null {
|
||||
return normalizeInputRole(inp.role ?? null);
|
||||
}
|
||||
|
||||
function hay(inp: ComfyDynamicInput): string {
|
||||
return `${inp.role ?? ''} ${inp.label}`.toLowerCase();
|
||||
}
|
||||
|
||||
export function isMedia(inp: ComfyDynamicInput): boolean {
|
||||
return (
|
||||
inp.kind === 'image' ||
|
||||
inp.kind === 'image_url' ||
|
||||
inp.kind === 'video' ||
|
||||
inp.kind === 'audio' ||
|
||||
inp.kind === 'image_list' ||
|
||||
inp.kind === 'video_list' ||
|
||||
inp.kind === 'audio_list'
|
||||
);
|
||||
}
|
||||
|
||||
function isList(inp: ComfyDynamicInput): boolean {
|
||||
return inp.kind === 'image_list' || inp.kind === 'video_list' || inp.kind === 'audio_list';
|
||||
}
|
||||
|
||||
function isMask(inp: ComfyDynamicInput): boolean {
|
||||
return inputRole(inp) === 'mask' || /mask|遮罩/.test(hay(inp));
|
||||
}
|
||||
|
||||
// Auto-detected slots carry their role from the wiring; tagged (Input:…)
|
||||
// workflows still name frames only in the title.
|
||||
export function isFirstFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
|
||||
return inputRole(inp) === 'first_frame' || (!inp.auto && kind === 'video' && /first|start|首帧/.test(hay(inp)));
|
||||
}
|
||||
export function isLastFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
|
||||
return inputRole(inp) === 'last_frame' || (!inp.auto && kind === 'video' && /last|end|尾帧/.test(hay(inp)));
|
||||
}
|
||||
|
||||
export function listKind(entry: ComfyDynamicInput): 'image' | 'video' | 'audio' {
|
||||
if (entry.kind === 'video_list') return 'video';
|
||||
if (entry.kind === 'audio_list') return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
export function listPaths(values: FormValues, entry: ComfyDynamicInput): string[] {
|
||||
const v = values[entry.nodeId];
|
||||
if (Array.isArray(v)) return v;
|
||||
return typeof v === 'string' && v ? [v] : [];
|
||||
}
|
||||
|
||||
export function classifySchema(schema: ComfyDynamicInput[], kind: DeskKind): DeskSchema {
|
||||
const plain = (pred: (i: ComfyDynamicInput) => boolean) =>
|
||||
schema.find((i) => !isMedia(i) && pred(i)) ?? null;
|
||||
const byRole = (r: string) => plain((i) => inputRole(i) === r);
|
||||
|
||||
const prompt = byRole('prompt');
|
||||
const negative = byRole('negative');
|
||||
const seed = byRole('seed');
|
||||
const width = byRole('width');
|
||||
const height = byRole('height');
|
||||
const resolution = byRole('resolution');
|
||||
const duration = byRole('duration');
|
||||
const batch = plain((i) => /批次|batch/.test(hay(i)));
|
||||
const ratio =
|
||||
schema.find((i) => i.kind === 'select' && inputRole(i) === 'aspect_ratio' && i.resolution) ?? null;
|
||||
const megapixels = ratio ? (schema.find((i) => i.nodeId === ratio.resolution!.megapixels) ?? null) : null;
|
||||
|
||||
const media = schema.filter((i) => isMedia(i) && !isList(i));
|
||||
const plainImages = media.filter(
|
||||
(i) => i.kind === 'image' && !isMask(i) && !isFirstFrame(i, kind) && !isLastFrame(i, kind),
|
||||
);
|
||||
const editInput = kind === 'image' && plainImages.length === 1 ? plainImages[0] : null;
|
||||
const slots: SlotDef[] = [];
|
||||
let refIdx = 0;
|
||||
for (const inp of media) {
|
||||
const base = { inp, missing: '', firstFrame: false, lastFrame: false, mask: false };
|
||||
if (inp.kind === 'video') {
|
||||
slots.push({ ...base, label: t('studio.slotRefVideo'), mediaKind: 'video', fromResults: false });
|
||||
} else if (inp.kind === 'audio') {
|
||||
slots.push({ ...base, label: t('studio.slotAudio'), mediaKind: 'audio', fromResults: false });
|
||||
} else if (isMask(inp)) {
|
||||
slots.push({ ...base, mask: true, label: t('studio.slotMask'), mediaKind: 'image', fromResults: false });
|
||||
} else if (isFirstFrame(inp, kind)) {
|
||||
slots.push({
|
||||
...base,
|
||||
firstFrame: true,
|
||||
label: t('studio.slotFirst'),
|
||||
mediaKind: 'image',
|
||||
fromResults: true,
|
||||
missing: t('studio.errNeedFirstFrame'),
|
||||
});
|
||||
} else if (isLastFrame(inp, kind)) {
|
||||
slots.push({ ...base, lastFrame: true, label: t('studio.slotLast'), mediaKind: 'image', fromResults: true });
|
||||
} else if (inp === editInput) {
|
||||
slots.push({
|
||||
...base,
|
||||
label: t('studio.slotInput'),
|
||||
mediaKind: 'image',
|
||||
fromResults: true,
|
||||
missing: t('studio.errNeedInputImage'),
|
||||
});
|
||||
} else {
|
||||
refIdx += 1;
|
||||
const label = inp.auto
|
||||
? inp.label
|
||||
: refIdx === 1 && media.length === 1
|
||||
? t('studio.slotRef')
|
||||
: t('studio.slotRefN', { n: refIdx });
|
||||
slots.push({
|
||||
...base,
|
||||
label,
|
||||
mediaKind: 'image',
|
||||
fromResults: true,
|
||||
missing: t('studio.errUpload', { label }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const lists = schema.filter(isList);
|
||||
const common = [ratio, width, height, resolution, duration, seed].filter(
|
||||
(i): i is ComfyDynamicInput => !!i,
|
||||
);
|
||||
const commonIds = new Set(common.map((i) => i.nodeId));
|
||||
const advanced = schema.filter((i) => !isMedia(i) && i !== prompt && !commonIds.has(i.nodeId));
|
||||
|
||||
return {
|
||||
prompt,
|
||||
negative,
|
||||
seed,
|
||||
width,
|
||||
height,
|
||||
resolution,
|
||||
duration,
|
||||
batch,
|
||||
ratio,
|
||||
megapixels,
|
||||
slots,
|
||||
lists,
|
||||
common,
|
||||
advanced,
|
||||
hasH3Hint: lists.some((e) => e.media_hint === 'minimax_h3_ref2va'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Workflow JSON defaults — the 原值 every parameter falls back to. */
|
||||
export function schemaDefaults(schema: ComfyDynamicInput[]): FormValues {
|
||||
const out: FormValues = {};
|
||||
for (const inp of schema) {
|
||||
if (inp.defaultValue !== undefined && inp.defaultValue !== '') out[inp.nodeId] = inp.defaultValue;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isChanged(inp: ComfyDynamicInput, values: FormValues): boolean {
|
||||
const v = values[inp.nodeId];
|
||||
const orig = inp.defaultValue;
|
||||
if (v === undefined || v === '') return orig !== undefined && orig !== '';
|
||||
if (orig === undefined || orig === '') return true;
|
||||
return String(v) !== String(orig);
|
||||
}
|
||||
|
||||
export function changedParams(ds: DeskSchema, values: FormValues): ComfyDynamicInput[] {
|
||||
return [...ds.common, ...ds.advanced].filter((i) => isChanged(i, values));
|
||||
}
|
||||
|
||||
export function outputSize(ds: DeskSchema, values: FormValues): { width: number; height: number } | null {
|
||||
if (!ds.ratio?.resolution) return null;
|
||||
const mp = Number(values[ds.megapixels?.nodeId ?? ''] ?? ds.megapixels?.defaultValue);
|
||||
return resolutionSelectorSize(
|
||||
String(values[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
|
||||
mp,
|
||||
ds.ratio.resolution.multiple,
|
||||
);
|
||||
}
|
||||
|
||||
export function batchCount(ds: DeskSchema, values: FormValues): number {
|
||||
if (!ds.batch) return 1;
|
||||
const n = Number(values[ds.batch.nodeId]);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
|
||||
}
|
||||
|
||||
/** H3 Ref2VA official limits (soft hint, keyed off the node class only). */
|
||||
export function h3Warnings(ds: DeskSchema, values: FormValues): string[] {
|
||||
if (!ds.hasH3Hint) return [];
|
||||
const warns: string[] = [];
|
||||
const counts = { image: 0, video: 0, audio: 0 };
|
||||
for (const g of ds.lists) counts[listKind(g)] += listPaths(values, g).length;
|
||||
const total = counts.image + counts.video + counts.audio;
|
||||
if (total > 12) warns.push(t('wfMedia.h3TotalOver', { n: total }));
|
||||
if (counts.audio > 0 && counts.image === 0 && counts.video === 0) warns.push(t('wfMedia.h3AudioAlone'));
|
||||
return warns;
|
||||
}
|
||||
|
||||
/** Same rules the backend enforces before queueing (comfyui/validate.py). */
|
||||
export function valueError(inp: ComfyDynamicInput, raw: string | number): string | null {
|
||||
const label = inp.label;
|
||||
if (inp.kind === 'select') {
|
||||
return inp.options?.length && !inp.options.includes(String(raw)) ? t('studio.errOption', { label }) : null;
|
||||
}
|
||||
if (inp.kind !== 'number') return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return t('studio.errNumber', { label });
|
||||
if (inp.integer && !Number.isInteger(n)) return t('studio.errInteger', { label });
|
||||
if (inp.min != null && n < inp.min) return t('studio.errMin', { label, v: inp.min });
|
||||
if (inp.max != null && n > inp.max) return t('studio.errMax', { label, v: inp.max });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Field errors keyed by nodeId (spec §6.2 wording); '' keys never block. */
|
||||
export function validateForm(
|
||||
schema: ComfyDynamicInput[],
|
||||
ds: DeskSchema,
|
||||
values: FormValues,
|
||||
): Record<string, string> {
|
||||
const errs: Record<string, string> = {};
|
||||
if (ds.prompt?.required) {
|
||||
const v = String(values[ds.prompt.nodeId] ?? '');
|
||||
if (!v.trim()) errs[ds.prompt.nodeId] = t('studio.errPrompt');
|
||||
else if (v.length > 2000) errs[ds.prompt.nodeId] = t('studio.errPromptLong');
|
||||
}
|
||||
const first = ds.slots.find((s) => s.firstFrame);
|
||||
const last = ds.slots.find((s) => s.lastFrame);
|
||||
if (first && last) {
|
||||
const a = String(values[first.inp.nodeId] ?? '');
|
||||
const b = String(values[last.inp.nodeId] ?? '');
|
||||
// An optional last frame (H3 FL2VA) may stay empty; it never goes without a first frame.
|
||||
const unpaired = last.inp.required ? (!a || !b) && (a || b) : !a && !!b;
|
||||
if (unpaired) {
|
||||
errs.firstLast = t('studio.errFirstLast');
|
||||
if (!a) errs[first.inp.nodeId] = ' ';
|
||||
if (!b) errs[last.inp.nodeId] = ' ';
|
||||
}
|
||||
}
|
||||
for (const s of ds.slots) {
|
||||
if (!s.inp.required) continue;
|
||||
if (!String(values[s.inp.nodeId] ?? '').trim()) {
|
||||
errs[s.inp.nodeId] = s.missing || t('studio.errUpload', { label: s.label });
|
||||
}
|
||||
}
|
||||
for (const g of ds.lists) {
|
||||
const n = listPaths(values, g).length;
|
||||
const max = g.max ?? 9;
|
||||
if (n < (g.min ?? 1)) {
|
||||
errs[g.nodeId] =
|
||||
g.kind === 'image_list'
|
||||
? t('studio.errNeedRef')
|
||||
: t('studio.errNeedRefMedia', { n: g.min ?? 1, label: g.label });
|
||||
} else if (n > max) {
|
||||
errs[g.nodeId] = t('studio.errRefMax', { max, n, k: n - max });
|
||||
}
|
||||
}
|
||||
for (const inp of [...ds.common, ...ds.advanced]) {
|
||||
if (inp.required && !String(values[inp.nodeId] ?? '').trim()) {
|
||||
errs[inp.nodeId] = t('studio.errFill', { label: inp.label });
|
||||
}
|
||||
}
|
||||
for (const inp of schema) {
|
||||
const raw = values[inp.nodeId];
|
||||
if (raw === undefined || Array.isArray(raw) || String(raw).trim() === '' || errs[inp.nodeId]) continue;
|
||||
const msg = valueError(inp, raw);
|
||||
if (msg) errs[inp.nodeId] = msg;
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
/** Media a slot still waits for — the 「还缺:…」 hint next to 生成. */
|
||||
export function missingMedia(ds: DeskSchema, values: FormValues): Array<{ nodeId: string; label: string }> {
|
||||
const out: Array<{ nodeId: string; label: string }> = [];
|
||||
for (const s of ds.slots) {
|
||||
if (s.inp.required && !String(values[s.inp.nodeId] ?? '').trim()) out.push({ nodeId: s.inp.nodeId, label: s.label });
|
||||
}
|
||||
for (const g of ds.lists) {
|
||||
if (listPaths(values, g).length < (g.min ?? 1)) out.push({ nodeId: g.nodeId, label: g.label });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function usageLabel(u: WorkflowUsage): string {
|
||||
return t(`usage.${u}`);
|
||||
}
|
||||
|
||||
/** Mode badge: the stored usage when we have one, else the wiring shape. */
|
||||
export function modeLabel(wf: Workflow | null, ds: DeskSchema, kind: DeskKind): string {
|
||||
if (wf?.usage) return usageLabel(wf.usage);
|
||||
if (ds.lists.some((g) => g.kind === 'image_list')) return t('studio.modeRefToVideo');
|
||||
if (ds.slots.some((s) => s.firstFrame)) return t('studio.modeImageToVideo');
|
||||
if (kind === 'image' && ds.slots.some((s) => s.mediaKind === 'image' && !s.mask))
|
||||
return t('studio.modeImageEdit');
|
||||
if (ds.slots.length) return ds.slots.map((s) => s.label).join(' · ');
|
||||
return kind === 'image' ? t('studio.modeTextToImage') : t('studio.modeTextToVideo');
|
||||
}
|
||||
|
||||
/** Picker row summary of a workflow's real media inputs, e.g. 参考图 1–9 张. */
|
||||
export function slotSummary(wf: Workflow): string {
|
||||
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
|
||||
const parts = [
|
||||
...ds.slots.map((s) => t('desk.slotOne', { label: s.label })),
|
||||
...ds.lists.map((g) => t('desk.slotRange', { label: g.label, min: g.min ?? 1, max: g.max ?? 9 })),
|
||||
];
|
||||
return parts.length ? parts.join(' · ') : t('desk.promptOnly');
|
||||
}
|
||||
|
||||
/** Short chip text for one common parameter value. */
|
||||
export function chipValue(inp: ComfyDynamicInput, values: FormValues, ds: DeskSchema): string {
|
||||
const raw = values[inp.nodeId] ?? inp.defaultValue ?? '';
|
||||
const v = Array.isArray(raw) ? raw.join(',') : String(raw);
|
||||
if (inp === ds.ratio) {
|
||||
const size = outputSize(ds, values);
|
||||
return size ? `${ratioShort(v)} · ${size.width}×${size.height}` : ratioShort(v);
|
||||
}
|
||||
if (inp === ds.duration) return v ? t('desk.chipSeconds', { v }) : t('studio.durationLabel');
|
||||
if (inp === ds.seed) return t('desk.chipSeed', { v: v.length > 8 ? `${v.slice(0, 8)}…` : v || '—' });
|
||||
if (inp === ds.resolution) return t('desk.chipResolution', { v: v || '—' });
|
||||
return v || inp.label;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { QueryClient } from '@tanstack/svelte-query';
|
||||
import { apiErrorMessage, playgroundApi } from '$lib/api';
|
||||
import type { Job } from '$lib/comfy/types';
|
||||
import { showToast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 撤销删除 (spec-v2 §四.4 删除两级制 · HEL-654 D 批): deleting a single
|
||||
* take/task never opens a dialog. The row leaves the view right away, a
|
||||
* 5-second Toast offers 撤销, and the backend DELETE is only sent when
|
||||
* that window closes without an undo — the backend removes result files,
|
||||
* so an undo after the call would be impossible. Leaving the page (SPA
|
||||
* route change or hard unload) flushes pending deletes instead of
|
||||
* dropping them.
|
||||
*/
|
||||
|
||||
export interface TakeDeleteHandlers {
|
||||
/** Localized toast message for this take. */
|
||||
message: (job: Job) => string;
|
||||
/** Undo: the row comes back — reselect / replay its entrance here. */
|
||||
onUndo?: (job: Job) => void;
|
||||
/** Called after the delete really hit the backend. */
|
||||
onCommitted?: (job: Job) => void;
|
||||
}
|
||||
|
||||
export interface TakeDeleteQueue {
|
||||
/** Ids whose rows should be hidden (commit pending). */
|
||||
readonly pending: SvelteSet<number>;
|
||||
request: (job: Job, handlers: TakeDeleteHandlers) => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
const UNDO_MS = 5000;
|
||||
|
||||
export function createTakeDeleteQueue(client: QueryClient): TakeDeleteQueue {
|
||||
const pending = new SvelteSet<number>();
|
||||
const entries = new Map<
|
||||
number,
|
||||
{
|
||||
job: Job;
|
||||
handlers: TakeDeleteHandlers;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
>();
|
||||
let destroyed = false;
|
||||
|
||||
async function invalidate() {
|
||||
await Promise.allSettled([
|
||||
client.invalidateQueries({ queryKey: ['playground-jobs'] }),
|
||||
client.invalidateQueries({ queryKey: ['jobs'] }),
|
||||
]);
|
||||
}
|
||||
|
||||
async function commit(id: number) {
|
||||
const entry = entries.get(id);
|
||||
if (!entry) return;
|
||||
entries.delete(id);
|
||||
try {
|
||||
await playgroundApi.deleteJob(id);
|
||||
pending.delete(id);
|
||||
entry.handlers.onCommitted?.(entry.job);
|
||||
} catch (err) {
|
||||
// Delete failed: the take stays — bring the row back honestly.
|
||||
pending.delete(id);
|
||||
showToast(
|
||||
t('studio.deleteFailedToast', { detail: apiErrorMessage(err) }),
|
||||
'error',
|
||||
);
|
||||
}
|
||||
await invalidate();
|
||||
}
|
||||
|
||||
/** Hard unload (tab close / external nav): fire-and-forget with keepalive. */
|
||||
function onPageHide() {
|
||||
for (const [id] of entries) {
|
||||
void fetch(`/api/playground/jobs/${id}`, {
|
||||
method: 'DELETE',
|
||||
keepalive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
if (inBrowser) window.removeEventListener('pagehide', onPageHide);
|
||||
// SPA route change: the JS context survives, normal commits are fine.
|
||||
for (const [id] of entries) {
|
||||
const entry = entries.get(id)!;
|
||||
clearTimeout(entry.timer);
|
||||
void commit(id);
|
||||
}
|
||||
}
|
||||
|
||||
const inBrowser = typeof window !== 'undefined';
|
||||
if (inBrowser) window.addEventListener('pagehide', onPageHide);
|
||||
|
||||
return {
|
||||
pending,
|
||||
request(job, handlers) {
|
||||
if (destroyed || pending.has(job.id)) return;
|
||||
pending.add(job.id);
|
||||
const timer = setTimeout(() => void commit(job.id), UNDO_MS);
|
||||
entries.set(job.id, { job, handlers, timer });
|
||||
showToast(handlers.message(job), 'info', UNDO_MS, {
|
||||
label: t('toast.undo'),
|
||||
run: () => {
|
||||
const e = entries.get(job.id);
|
||||
if (!e) return;
|
||||
clearTimeout(e.timer);
|
||||
entries.delete(job.id);
|
||||
pending.delete(job.id);
|
||||
handlers.onUndo?.(job);
|
||||
},
|
||||
});
|
||||
},
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { resolutionSelectorSize } from '$lib/comfy/resolution';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { classifySchema, inputRole, isFirstFrame, isLastFrame, isMedia } from './schema';
|
||||
|
||||
/** Read-only views of a finished / running take (one playground job). */
|
||||
|
||||
export type FeedFilter = 'all' | 'image' | 'video' | 'failed';
|
||||
|
||||
export function jobValues(job: Job): Record<string, unknown> {
|
||||
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function jobValueByRole(job: Job, wf: Workflow | undefined, ...roles: string[]): string | null {
|
||||
if (!wf) return null;
|
||||
const vals = jobValues(job);
|
||||
for (const inp of wf.input_schema ?? []) {
|
||||
const r = inputRole(inp);
|
||||
if (r && roles.includes(r)) {
|
||||
const v = vals[inp.nodeId];
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function promptOf(job: Job, wf: Workflow | undefined): string {
|
||||
return jobValueByRole(job, wf, 'prompt') ?? '';
|
||||
}
|
||||
|
||||
export interface TakeInput {
|
||||
label: string;
|
||||
path: string;
|
||||
kind: 'image' | 'video' | 'audio';
|
||||
}
|
||||
|
||||
/** Media the take was generated from, in workflow order. */
|
||||
export function inputsOf(job: Job, wf: Workflow | undefined): TakeInput[] {
|
||||
if (!wf) return [];
|
||||
const kind = wf.kind;
|
||||
const vals = jobValues(job);
|
||||
const out: TakeInput[] = [];
|
||||
let refIdx = 0;
|
||||
for (const inp of wf.input_schema ?? []) {
|
||||
if (!isMedia(inp)) continue;
|
||||
const v = vals[inp.nodeId];
|
||||
const list = Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : [];
|
||||
const mk = inp.kind.startsWith('video') ? 'video' : inp.kind.startsWith('audio') ? 'audio' : 'image';
|
||||
list.forEach((p, i) => {
|
||||
if (typeof p !== 'string' || !p) return;
|
||||
let label = inp.label;
|
||||
if (inp.kind === 'image_list') label = t('studio.slotRefN', { n: i + 1 });
|
||||
else if (isFirstFrame(inp, kind)) label = t('studio.slotFirst');
|
||||
else if (isLastFrame(inp, kind)) label = t('studio.slotLast');
|
||||
else if (inp.kind === 'image' && inputRole(inp) !== 'mask') label = t('studio.slotRefN', { n: ++refIdx });
|
||||
out.push({ label, path: p, kind: mk });
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 888×1184 · 种子 42 · 5 秒 — values that differ per take. */
|
||||
export function specOf(job: Job, wf: Workflow | undefined): Array<{ label: string; value: string }> {
|
||||
const out: Array<{ label: string; value: string }> = [];
|
||||
if (!wf) return out;
|
||||
const w = jobValueByRole(job, wf, 'width');
|
||||
const h = jobValueByRole(job, wf, 'height');
|
||||
if (w && h) out.push({ label: t('desk.specSize'), value: `${w}×${h}` });
|
||||
else {
|
||||
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
|
||||
if (ds.ratio?.resolution) {
|
||||
const vals = jobValues(job);
|
||||
const size = resolutionSelectorSize(
|
||||
String(vals[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
|
||||
Number(vals[ds.ratio.resolution.megapixels] ?? ds.megapixels?.defaultValue),
|
||||
ds.ratio.resolution.multiple,
|
||||
);
|
||||
if (size) out.push({ label: t('desk.specSize'), value: `${size.width}×${size.height}` });
|
||||
}
|
||||
}
|
||||
const seed = jobValueByRole(job, wf, 'seed');
|
||||
if (seed) out.push({ label: t('studio.seedLabel'), value: seed });
|
||||
const dur = jobValueByRole(job, wf, 'duration');
|
||||
if (dur) out.push({ label: t('studio.durationLabel'), value: t('desk.chipSeconds', { v: dur }) });
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isVideoOutput(job: Job, path: string): boolean {
|
||||
return job.kind === 'video' && !/\.(png|jpe?g|webp)$/i.test(path);
|
||||
}
|
||||
|
||||
export function elapsedSec(job: Job, nowMs: number): number | null {
|
||||
const from = job.started_at ? parseDbTs(job.started_at) : parseDbTs(job.created_at);
|
||||
if (!Number.isFinite(from)) return null;
|
||||
const to = job.completed_at ? parseDbTs(job.completed_at) : nowMs;
|
||||
return Math.max(0, (to - from) / 1000);
|
||||
}
|
||||
|
||||
export function fmtDur(sec: number | null): string {
|
||||
if (sec == null) return '—';
|
||||
if (sec < 60) return t('desk.durSec', { s: Math.floor(sec) });
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return t('desk.durMin', { m, s: String(s).padStart(2, '0') });
|
||||
}
|
||||
|
||||
export function fmtClock(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 今天 / 昨天 / 9月28日 heading for the feed. */
|
||||
export function dayLabel(ms: number, nowMs: number): string {
|
||||
const d = new Date(ms);
|
||||
const today = new Date(nowMs);
|
||||
const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
||||
const diff = Math.round((startOf(today) - startOf(d)) / 86_400_000);
|
||||
if (diff === 0) return t('desk.today');
|
||||
if (diff === 1) return t('desk.yesterday');
|
||||
return t('desk.dayDate', { m: d.getMonth() + 1, d: d.getDate() });
|
||||
}
|
||||
|
||||
export function matchesFilter(job: Job, filter: FeedFilter): boolean {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'failed') return jobStatusInfo(job).tone === 'fail';
|
||||
return job.kind === filter;
|
||||
}
|
||||
@@ -39,7 +39,8 @@
|
||||
function segClass(key: string): string {
|
||||
const state = stats.stage_status?.[key];
|
||||
if (state === 'confirmed') return 'seg-confirmed';
|
||||
if (state === 'running' || state === 'awaiting_confirm') return 'seg-draft';
|
||||
if (state === 'running') return 'seg-running';
|
||||
if (state === 'awaiting_confirm') return 'seg-draft';
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -70,7 +71,7 @@
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.running')}` };
|
||||
}
|
||||
if (state === 'awaiting_confirm') {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.draft')}` };
|
||||
return { cls: 'st-pending', label: `${stageName} · ${t('workbench.state.draft')}` };
|
||||
}
|
||||
if (stats.stage_confirmed > 0) {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('home.statusProgress')}` };
|
||||
@@ -329,7 +330,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(22, 19, 10, 0.82);
|
||||
color: color-mix(in srgb, var(--accent-ink) 82%, transparent);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.status-tag {
|
||||
@@ -340,7 +341,7 @@
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
background: rgba(13, 14, 17, 0.78);
|
||||
background: var(--media-scrim);
|
||||
border: 1px solid var(--border-strong);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
@@ -350,7 +351,10 @@
|
||||
color: var(--text-2);
|
||||
}
|
||||
.st-progress {
|
||||
color: var(--accent);
|
||||
color: var(--run);
|
||||
}
|
||||
.st-pending {
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-done {
|
||||
color: var(--success);
|
||||
@@ -377,7 +381,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.arrow {
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
@@ -409,8 +413,11 @@
|
||||
border-radius: 2px;
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.seg-running {
|
||||
background: var(--run);
|
||||
}
|
||||
.seg-draft {
|
||||
background: var(--accent);
|
||||
background: var(--warn);
|
||||
}
|
||||
.seg-confirmed {
|
||||
background: var(--success);
|
||||
@@ -425,8 +432,8 @@
|
||||
margin-left: auto;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -462,7 +469,7 @@
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: rgba(13, 14, 17, 0.6);
|
||||
background: var(--media-scrim);
|
||||
color: var(--text-2);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { projects } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
import { NAV } from './nav';
|
||||
|
||||
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
interface Entry {
|
||||
key: string;
|
||||
group: 'pages' | 'projects';
|
||||
label: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
|
||||
let query = $state('');
|
||||
let activeIndex = $state(0);
|
||||
let list = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const projectsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['projects'],
|
||||
queryFn: projects.list,
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
);
|
||||
|
||||
const entries = $derived.by<Entry[]>(() => {
|
||||
const pages: Entry[] = NAV.map((item) => ({
|
||||
key: `page-${item.id}`,
|
||||
group: 'pages',
|
||||
label: t(item.labelKey),
|
||||
icon: item.icon,
|
||||
href: item.href,
|
||||
}));
|
||||
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
|
||||
key: `project-${p.id}`,
|
||||
group: 'projects',
|
||||
label: p.title,
|
||||
icon: 'story',
|
||||
href: `/workbench/${p.id}`,
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = [...pages, ...projectEntries];
|
||||
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void query;
|
||||
activeIndex = 0;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) query = '';
|
||||
});
|
||||
|
||||
function optionId(index: number) {
|
||||
return `${listId}-${index}`;
|
||||
}
|
||||
|
||||
function go(entry: Entry) {
|
||||
open = false;
|
||||
void goto(entry.href);
|
||||
}
|
||||
|
||||
function move(step: number) {
|
||||
if (entries.length === 0) return;
|
||||
activeIndex = (activeIndex + step + entries.length) % entries.length;
|
||||
list?.querySelector(`#${optionId(activeIndex)}`)?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
} else if (event.key === 'Enter') {
|
||||
const entry = entries[activeIndex];
|
||||
if (entry) {
|
||||
event.preventDefault();
|
||||
go(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function focusOnMount(node: HTMLInputElement) {
|
||||
requestAnimationFrame(() => node.focus());
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title={t('shell.searchTitle')}>
|
||||
<div class="palette">
|
||||
<div class="palette-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input
|
||||
use:focusOnMount
|
||||
class="palette-input"
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder={t('shell.searchPlaceholder')}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={entries.length ? optionId(activeIndex) : undefined}
|
||||
aria-label={t('shell.searchTitle')}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
<div class="palette-list" id={listId} role="listbox" bind:this={list} aria-label={t('shell.searchTitle')}>
|
||||
{#if entries.length === 0}
|
||||
<p class="palette-empty">{t('shell.searchEmpty', { q: query.trim() })}</p>
|
||||
{/if}
|
||||
{#each entries as entry, i (entry.key)}
|
||||
{#if i === 0 || entries[i - 1].group !== entry.group}
|
||||
<div class="a-menu-group" role="presentation">
|
||||
{entry.group === 'pages' ? t('shell.searchGroupPages') : t('shell.searchGroupProjects')}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
id={optionId(i)}
|
||||
class="a-menu-item"
|
||||
class:active={i === activeIndex}
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
aria-selected={i === activeIndex}
|
||||
onpointermove={() => (activeIndex = i)}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => go(entry)}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<Icon name={entry.icon} size={15} />
|
||||
<span class="a-menu-label">{entry.label}</span>
|
||||
<span class="a-menu-meta mono">{entry.href}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="palette-hint">
|
||||
<span class="a-kbd">↑↓</span>{t('shell.searchHintMove')}
|
||||
<span class="a-kbd">Enter</span>{t('shell.searchHintOpen')}
|
||||
<span class="a-kbd">Esc</span>{t('shell.searchHintClose')}
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.palette {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.palette-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-5);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.palette-search:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.palette-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.palette-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-list {
|
||||
max-height: min(360px, 50vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.palette-empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.palette-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-hint .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-3);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.palette-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { projects } from '$lib/api';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from './ShellPop.svelte';
|
||||
|
||||
/**
|
||||
* 顶栏范围胶囊 — the project picker that replaced the old sidebar project
|
||||
* entry: shows the project the page belongs to (or 全部项目) and lists
|
||||
* recent projects, each opening its five-stage workbench.
|
||||
*/
|
||||
interface Props {
|
||||
projectId: number | null;
|
||||
onProjectsList: boolean;
|
||||
}
|
||||
|
||||
let { projectId, onProjectsList }: Props = $props();
|
||||
|
||||
const RECENT_LIMIT = 8;
|
||||
let open = $state(false);
|
||||
|
||||
const projectsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['projects'],
|
||||
queryFn: projects.list,
|
||||
enabled: open || projectId != null,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
);
|
||||
|
||||
const all = $derived($projectsQuery.data ?? []);
|
||||
const current = $derived(projectId == null ? null : (all.find((p) => p.id === projectId) ?? null));
|
||||
const recent = $derived(
|
||||
[...all]
|
||||
.sort((a, b) => (parseDbTs(b.updated_at) || 0) - (parseDbTs(a.updated_at) || 0))
|
||||
.slice(0, RECENT_LIMIT),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="scope">
|
||||
<button
|
||||
type="button"
|
||||
class="status-chip scope-btn"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={t('shell.scopeHint')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span class="scope-label">{current?.title ?? t('shell.scopeAll')}</span>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
|
||||
<ShellPop bind:open label={t('shell.scopeHint')} width="300px">
|
||||
<a
|
||||
class="a-menu-item"
|
||||
href="/"
|
||||
aria-current={onProjectsList ? 'page' : undefined}
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
<Icon name="folder" size={15} />
|
||||
<span class="a-menu-label">{t('shell.scopeAllList')}</span>
|
||||
{#if all.length > 0}<span class="a-menu-meta mono">{all.length}</span>{/if}
|
||||
</a>
|
||||
<div class="a-menu-group">{t('shell.scopeRecent')}</div>
|
||||
{#if $projectsQuery.isPending}
|
||||
<p class="scope-note">{t('common.loading')}…</p>
|
||||
{:else if $projectsQuery.isError}
|
||||
<p class="scope-note err">{t('shell.scopeLoadFailed')}</p>
|
||||
{:else if recent.length === 0}
|
||||
<a class="a-menu-item" href="/" onclick={() => (open = false)}>
|
||||
<Icon name="plus" size={15} />
|
||||
<span class="a-menu-label">{t('shell.scopeEmpty')}</span>
|
||||
</a>
|
||||
{:else}
|
||||
{#each recent as project (project.id)}
|
||||
<a
|
||||
class="a-menu-item"
|
||||
href="/workbench/{project.id}"
|
||||
aria-current={project.id === projectId ? 'true' : undefined}
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
<Icon name="story" size={15} />
|
||||
<span class="a-menu-label">{project.title}</span>
|
||||
{#if project.stats}
|
||||
<span class="a-menu-meta">
|
||||
{t('shell.scopeStage', { n: project.stats.stage_confirmed })}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scope {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scope-btn {
|
||||
max-width: 220px;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.scope-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.scope-note {
|
||||
margin: 0;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.scope-note.err {
|
||||
color: var(--err);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.scope-btn {
|
||||
max-width: 132px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user