Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6f761f8c8 |
@@ -78,10 +78,41 @@ def workflow_warnings(workflow: dict[str, Any]) -> list[dict[str, str]]:
|
||||
"level": "warning",
|
||||
"code": "orphan_latent",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, node)}没有接到任何节点,"
|
||||
"message": f"{_title(nid, nodes[nid])}没有接到任何节点,"
|
||||
"它的宽高不会生效,已不在表单中显示",
|
||||
}
|
||||
)
|
||||
|
||||
# HEL-636: 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 *possible* savers (needs_mapping) don't
|
||||
# trigger this: the user confirms those on the import page.
|
||||
from calliope.comfyui.parser import detect_output_nodes
|
||||
|
||||
outputs = detect_output_nodes(workflow)
|
||||
if not any(o.get("kind") in ("image", "video") for o in outputs):
|
||||
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}),"
|
||||
"导入后需人工确认用途;结果按 ComfyUI 历史的图片/视频输出提取",
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
|
||||
@@ -3,18 +3,23 @@
|
||||
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_OUTPUT_CLASSES,
|
||||
IMAGE_URL_CLASSES,
|
||||
VIDEO_CLASSES,
|
||||
VIDEO_OUTPUT_CLASSES,
|
||||
ComfyOutputKind,
|
||||
class_to_input_kind,
|
||||
class_to_output_kind,
|
||||
)
|
||||
from calliope.comfyui.roles import (
|
||||
USAGE_VALUES,
|
||||
normalize_input_role,
|
||||
normalize_output_role,
|
||||
parse_title_tag,
|
||||
@@ -76,9 +81,17 @@ def effective_input_schema(
|
||||
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 or any(isinstance(e, dict) and e.get("auto") for e in stored):
|
||||
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
|
||||
|
||||
|
||||
@@ -266,15 +279,22 @@ def resolution_selector_size(aspect_ratio: str, megapixels: float, multiple: int
|
||||
)
|
||||
|
||||
|
||||
# ComfyUI Autogrow image inputs a studio form fills as one 1…max list
|
||||
# (values read from /object_info). The workflow wires ``<group>.<prefix>N``;
|
||||
# extra images are cloned LoadImage nodes added at patch time (patcher.py).
|
||||
# ComfyUI Autogrow image inputs a studio form fills as one 1…max list.
|
||||
# The workflow wires ``<group>.<prefix>N``; extra images are cloned LoadImage
|
||||
# nodes added at patch time (patcher.py). This registry only beautifies known
|
||||
# classes (label + real max from /object_info); detection itself is generic —
|
||||
# any ``<group>.<prefix>N`` run of image-loader links folds into a group.
|
||||
IMAGE_GROUPS: dict[str, dict[str, dict[str, Any]]] = {
|
||||
"MiniMaxH3ReferenceToVideo": {
|
||||
"ref_images": {"prefix": "ref_image_", "max": 9, "label": "参考图"},
|
||||
},
|
||||
}
|
||||
|
||||
# ``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", "首帧"),
|
||||
@@ -288,6 +308,28 @@ def image_group_spec(class_type: str, group: str) -> dict[str, Any] | None:
|
||||
return IMAGE_GROUPS.get(class_type, {}).get(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]]:
|
||||
@@ -300,6 +342,43 @@ def image_group_slots(
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def _detected_group_runs(
|
||||
workflow: dict[str, Any], node: dict[str, Any]
|
||||
) -> list[tuple[str, str, list[str]]]:
|
||||
"""``[(group, prefix, source LoadImage ids)]`` for generic Autogrow inputs.
|
||||
|
||||
A run qualifies when the numbers are consecutive, every slot links to an
|
||||
image-loader class, 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]]] = []
|
||||
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] = []
|
||||
for n in ns:
|
||||
link = slots[n]
|
||||
src = workflow.get(str(link[0]))
|
||||
if not (isinstance(src, dict) and src.get("class_type") in IMAGE_CLASSES):
|
||||
sources = []
|
||||
break
|
||||
sources.append(str(link[0]))
|
||||
if sources:
|
||||
out.append((group, prefix, sources))
|
||||
return out
|
||||
|
||||
|
||||
def _image_groups(
|
||||
workflow: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], set[str]]:
|
||||
@@ -310,27 +389,19 @@ def _image_groups(
|
||||
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
|
||||
key=lambda kv: _node_sort_key(kv[0]),
|
||||
):
|
||||
for group, spec in IMAGE_GROUPS.get(str(node.get("class_type") or ""), {}).items():
|
||||
sources: list[str] = []
|
||||
for _n, _name, link in image_group_slots(node, group, spec["prefix"]):
|
||||
if not (isinstance(link, list) and len(link) == 2):
|
||||
continue
|
||||
src = workflow.get(str(link[0]))
|
||||
if isinstance(src, dict) and src.get("class_type") in IMAGE_CLASSES:
|
||||
sources.append(str(link[0]))
|
||||
if not sources:
|
||||
continue
|
||||
for group, _prefix, sources in _detected_group_runs(workflow, node):
|
||||
spec = image_group_spec(str(node.get("class_type") or ""), group) or {}
|
||||
absorbed.update(sources)
|
||||
entries.append(
|
||||
_entry(
|
||||
f"{nid}{FIELD_KEY_SEP}{group}",
|
||||
spec["label"],
|
||||
str(spec.get("label") or "参考图"),
|
||||
"reference_image",
|
||||
"image_list",
|
||||
None,
|
||||
True,
|
||||
min=1,
|
||||
max=spec["max"],
|
||||
max=spec.get("max", 9),
|
||||
)
|
||||
)
|
||||
return entries, absorbed
|
||||
@@ -624,4 +695,255 @@ 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
|
||||
|
||||
# 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 _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 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). Fuzzy class-name matches only
|
||||
count at a sink. Unknown sink classes that look like savers become
|
||||
``other`` + ``unknown_node`` evidence — the import page then asks the
|
||||
user to map them.
|
||||
"""
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
consumers = _consumers(workflow)
|
||||
videoish = _video_semantics(workflow)
|
||||
results: list[dict[str, Any]] = []
|
||||
for nid, node in _sinks(workflow, consumers):
|
||||
ct = str(node.get("class_type") or "")
|
||||
if not ct or ct in _INPUT_ONLY_CLASSES:
|
||||
continue
|
||||
title = _title_of(node)
|
||||
if ct in IMAGE_OUTPUT_CLASSES or ct in VIDEO_OUTPUT_CLASSES:
|
||||
kind: ComfyOutputKind = "video" if ct in VIDEO_OUTPUT_CLASSES else "image"
|
||||
evidence = "registry"
|
||||
else:
|
||||
kind = class_to_output_kind(ct)
|
||||
if kind in ("image", "video"):
|
||||
evidence = "class_name_match"
|
||||
elif _unknown_sink_looks_like_save(node, workflow):
|
||||
# A custom saver we cannot type: with video evidence in the
|
||||
# graph it most likely saves video, but the user decides.
|
||||
kind = "video" if videoish else "other"
|
||||
evidence = "unknown_node"
|
||||
else:
|
||||
continue # utility/primitive sink — not an output
|
||||
results.append(
|
||||
{
|
||||
"nodeId": nid,
|
||||
"label": title or ct,
|
||||
"role": None,
|
||||
"kind": kind,
|
||||
"class_type": ct,
|
||||
"evidence": evidence,
|
||||
}
|
||||
)
|
||||
# Registry output classes consumed by other nodes are outputs too.
|
||||
seen = {r["nodeId"] for r in results}
|
||||
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 (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"nodeId": nid,
|
||||
"label": _title_of(node) or ct,
|
||||
"role": None,
|
||||
"kind": "video" if ct in VIDEO_OUTPUT_CLASSES else "image",
|
||||
"class_type": ct,
|
||||
"evidence": "registry",
|
||||
}
|
||||
)
|
||||
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")]
|
||||
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 = "图片" if o["kind"] == "image" else "视频"
|
||||
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 len(out_kinds) > 1:
|
||||
ambiguous = True
|
||||
problem = "同一工作流同时输出图片和视频,需人工选择主用途"
|
||||
elif len(out_kinds) == 1:
|
||||
if out_kinds[0] == "image":
|
||||
usage = "image2image" if bulk else "text2image"
|
||||
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 = "视频工作流中的单张图片输入无法判定是首帧还是参考图,需人工确认"
|
||||
else:
|
||||
# only unknown outputs — the user must map them before it is usable
|
||||
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),
|
||||
}
|
||||
|
||||
@@ -53,15 +53,15 @@ def apply_image_group(
|
||||
first, and slots/loaders left over from the export are removed so no
|
||||
stale image reaches ComfyUI.
|
||||
"""
|
||||
from calliope.comfyui.parser import image_group_slots, image_group_spec
|
||||
from calliope.comfyui.parser import image_group_prefix, image_group_slots
|
||||
|
||||
node = workflow.get(node_id)
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
spec = image_group_spec(str(node.get("class_type") or ""), group)
|
||||
if spec is None:
|
||||
prefix = image_group_prefix(node, group)
|
||||
if prefix is None:
|
||||
return
|
||||
prefix = spec["prefix"]
|
||||
label = "参考图"
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
slots = image_group_slots(node, group, prefix)
|
||||
loaders: list[str] = []
|
||||
@@ -96,7 +96,7 @@ def apply_image_group(
|
||||
loader_inputs = dict(loader.get("inputs") or {})
|
||||
loader_inputs["image"] = path
|
||||
loader["inputs"] = loader_inputs
|
||||
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{spec['label']} {i + 1}"}
|
||||
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{label} {i + 1}"}
|
||||
workflow[nid] = loader
|
||||
inputs[f"{group}.{prefix}{i}"] = [nid, 0]
|
||||
node["inputs"] = inputs
|
||||
|
||||
@@ -30,6 +30,27 @@ OUTPUT_ROLE_ALIASES: dict[str, frozenset[str]] = {
|
||||
"video": frozenset({"video", "vid"}),
|
||||
}
|
||||
|
||||
# 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",
|
||||
)
|
||||
|
||||
|
||||
def usage_to_kind(usage: str | None) -> str | None:
|
||||
"""Output media kind of a usage (image / video); None when unknown."""
|
||||
if usage in ("text2image", "image2image"):
|
||||
return "image"
|
||||
if usage in ("text2video", "image2video", "ref2video"):
|
||||
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.
|
||||
|
||||
@@ -338,6 +338,38 @@ 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 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],),
|
||||
)
|
||||
|
||||
|
||||
async def migrate_db(db_path: Path) -> None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
@@ -383,6 +415,15 @@ 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")
|
||||
# Idempotent backfill: pending rows are re-judged on every boot (parser
|
||||
# upgrades can settle them); confirmed rows are never touched.
|
||||
_backfill_workflow_usage(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")
|
||||
|
||||
@@ -198,14 +198,30 @@ class ExpandClipsRequest(BaseModel):
|
||||
clip_cap: int | None = None
|
||||
|
||||
|
||||
WorkflowUsageKind = Literal[
|
||||
"text2image",
|
||||
"image2image",
|
||||
"text2video",
|
||||
"image2video",
|
||||
"ref2video",
|
||||
]
|
||||
|
||||
|
||||
class WorkflowCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1)
|
||||
kind: Literal["image", "video"] = "image"
|
||||
kind: Literal["image", "video"] | 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
|
||||
|
||||
|
||||
class WorkflowUpdate(BaseModel):
|
||||
@@ -215,6 +231,9 @@ class WorkflowUpdate(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
class WorkflowAnalyze(BaseModel):
|
||||
|
||||
@@ -7,11 +7,13 @@ from fastapi import APIRouter, HTTPException
|
||||
|
||||
from calliope.comfyui.lint import workflow_warnings
|
||||
from calliope.comfyui.parser import (
|
||||
classify_usage,
|
||||
effective_input_schema,
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
from calliope.comfyui.profiles import detect_prompt_profile
|
||||
from calliope.comfyui.roles import usage_to_kind
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, row_to_dict
|
||||
from calliope.models.schemas import WorkflowAnalyze, WorkflowCreate, WorkflowUpdate
|
||||
@@ -34,20 +36,36 @@ def _serialize_workflow(row: Any) -> dict[str, Any]:
|
||||
async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
|
||||
inputs = parse_dynamic_inputs(payload.workflow_json)
|
||||
outputs = parse_dynamic_outputs(payload.workflow_json)
|
||||
judgment = classify_usage(payload.workflow_json)
|
||||
return {
|
||||
"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"],
|
||||
}
|
||||
|
||||
|
||||
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 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:
|
||||
@@ -56,11 +74,19 @@ 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)
|
||||
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 = ? WHERE id = ?",
|
||||
(json.dumps(schema), json.dumps(outputs), usage, usage_source, workflow_id),
|
||||
)
|
||||
conn.commit()
|
||||
fresh = conn.execute(
|
||||
@@ -118,8 +144,19 @@ 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")
|
||||
}
|
||||
kind = "video" if out_kinds == {"video"} else "image"
|
||||
profile = payload.prompt_profile or detect_prompt_profile(payload.workflow_json)
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
@@ -127,29 +164,39 @@ async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
|
||||
# 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 / unknown outputs) are stored but
|
||||
# disabled: they must not reach the generate pages until the user
|
||||
# confirms a usage on the connections page (PATCH sets it back on).
|
||||
is_enabled = 1 if usage 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)
|
||||
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,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -181,6 +228,17 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "is_enabled" in data and data["is_enabled"] is not None:
|
||||
data["is_enabled"] = 1 if data["is_enabled"] else 0
|
||||
if data.get("usage"):
|
||||
# Confirming / correcting the usage is an explicit user decision:
|
||||
# it re-enables the workflow and keeps kind (output media) in sync.
|
||||
data.setdefault("usage_source", "user")
|
||||
data["kind"] = usage_to_kind(data["usage"]) or existing["kind"]
|
||||
if "is_enabled" not in data:
|
||||
data["is_enabled"] = 1
|
||||
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.
|
||||
explicit_null = {"endpoint_id"} & {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}
|
||||
|
||||
@@ -389,6 +389,13 @@ def _import_workflow(client, name="H3 视频", kind="video", endpoint_id=None):
|
||||
"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:
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""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_first_frame_only():
|
||||
wf = _rename_everything(_load(FIXTURES / "h3_i2v_04_API.json"))
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "image2video"
|
||||
media = _media(parse_dynamic_inputs(wf))
|
||||
assert [i.get("role") for i in media] == ["first_frame"]
|
||||
# A3: no first/last pair → the pair validation must not fire
|
||||
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]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""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",
|
||||
]
|
||||
|
||||
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"]
|
||||
@@ -133,6 +133,8 @@ import type {
|
||||
SceneVideoSettings,
|
||||
Clip,
|
||||
Workflow,
|
||||
WorkflowUsage,
|
||||
WorkflowAnalyzeResult,
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
} from "./comfy/types";
|
||||
@@ -142,6 +144,8 @@ export type {
|
||||
SceneVideoSettings,
|
||||
Clip,
|
||||
Workflow,
|
||||
WorkflowUsage,
|
||||
WorkflowAnalyzeResult,
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
};
|
||||
@@ -453,11 +457,7 @@ 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", {
|
||||
api<WorkflowAnalyzeResult>("/api/workflows/analyze", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workflow_json }),
|
||||
}),
|
||||
@@ -465,8 +465,11 @@ export const workflows = {
|
||||
api<Workflow>(`/api/workflows/${id}/reanalyze`, { 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[];
|
||||
description?: string;
|
||||
prompt_profile?: string;
|
||||
endpoint_id?: string | null;
|
||||
@@ -484,6 +487,9 @@ export const workflows = {
|
||||
description: string;
|
||||
prompt_profile: string;
|
||||
endpoint_id: string | null;
|
||||
usage: WorkflowUsage;
|
||||
usage_source: "auto" | "user";
|
||||
input_schema: ComfyDynamicInput[];
|
||||
}>,
|
||||
) =>
|
||||
api<Workflow>(`/api/workflows/${id}`, {
|
||||
|
||||
@@ -43,9 +43,53 @@ export interface ComfyDynamicInput {
|
||||
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;
|
||||
/** Untagged detection: node class + which rule typed it. */
|
||||
class_type?: string;
|
||||
evidence?: 'registry' | 'class_name_match' | 'unknown_node';
|
||||
}
|
||||
|
||||
/** 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'],
|
||||
};
|
||||
|
||||
/** 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[];
|
||||
}
|
||||
|
||||
export interface WorkflowNode {
|
||||
@@ -58,6 +102,9 @@ export interface Workflow {
|
||||
id: number;
|
||||
name: string;
|
||||
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[];
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
type Job,
|
||||
} from '$lib/api';
|
||||
import type { ComfyDynamicInput } from '$lib/comfy/types';
|
||||
import { USAGE_BY_KIND, type WorkflowUsage } from '$lib/comfy/types';
|
||||
import { compactInputValues, isPromptLikeInput } from '$lib/comfy/promptInput';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { normalizeInputRole } from '$lib/comfy/parser';
|
||||
@@ -62,8 +63,12 @@
|
||||
|
||||
const endpoints = $derived($settingsQuery.data?.comfyui_endpoints ?? []);
|
||||
const activeId = $derived($settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? '');
|
||||
// HEL-636: the page serves its usages (image → t2i/i2i, video →
|
||||
// t2v/i2v/ref2v); pending workflows (usage = null) never appear.
|
||||
const kindWorkflows = $derived(
|
||||
($workflowsQuery.data ?? []).filter((w) => w.kind === kind && w.is_enabled),
|
||||
($workflowsQuery.data ?? []).filter(
|
||||
(w) => w.is_enabled && (w.usage ?? null) !== null && USAGE_BY_KIND[kind].includes(w.usage!),
|
||||
),
|
||||
);
|
||||
|
||||
const allJobs = $derived.by(() => {
|
||||
@@ -317,8 +322,12 @@
|
||||
return out;
|
||||
});
|
||||
|
||||
/** Mode badge from what the workflow really wires (spec §1 table). */
|
||||
/** Mode badge: the stored usage when we have one, else the wiring shape. */
|
||||
function usageLabel(u: WorkflowUsage): string {
|
||||
return t(`usage.${u}`);
|
||||
}
|
||||
const modeLabel = $derived.by(() => {
|
||||
if (currentWorkflow?.usage) return usageLabel(currentWorkflow.usage);
|
||||
if (refGroup) return t('studio.modeRefToVideo');
|
||||
if (mediaSlots.some((s) => isFirstFrame(s.inp))) return t('studio.modeImageToVideo');
|
||||
if (kind === 'image' && mediaSlots.some((s) => s.mediaKind === 'image' && role(s.inp) !== 'mask'))
|
||||
@@ -550,7 +559,10 @@
|
||||
let target = mediaSlots.find((s) => s.mediaKind === 'image' && role(s.inp) !== 'mask')?.inp ?? null;
|
||||
if (!target) {
|
||||
const candidate = ($workflowsQuery.data ?? []).find(
|
||||
(w) => w.kind === 'image' && w.is_enabled && (w.input_schema ?? []).some(isMedia),
|
||||
(w) =>
|
||||
w.is_enabled &&
|
||||
w.usage === 'image2image' &&
|
||||
(w.input_schema ?? []).some(isMedia),
|
||||
);
|
||||
if (!candidate) {
|
||||
toast.error(t('studio.noImg2ImgWf'));
|
||||
|
||||
@@ -1400,6 +1400,41 @@ export const de: Dict = {
|
||||
'conn.wfDeleted': 'Workflow gelöscht',
|
||||
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
|
||||
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
|
||||
// --- Five usages & import confirmation (HEL-636) ---
|
||||
'usage.text2image': 'Text → Image',
|
||||
'usage.image2image': 'Image → Image',
|
||||
'usage.text2video': 'Text → Video',
|
||||
'usage.image2video': 'Image → Video',
|
||||
'usage.ref2video': 'Reference → Video',
|
||||
'usage.pending': 'Usage pending',
|
||||
'wfRole.image': 'Input image',
|
||||
'wfRole.first_frame': 'First frame',
|
||||
'wfRole.last_frame': 'Last frame',
|
||||
'wfRole.reference_image': 'Reference',
|
||||
'wfRole.mask': 'Mask',
|
||||
'conn.wfImportAction': 'Import workflow',
|
||||
'conn.wfConfirmTitle': 'Confirm detection',
|
||||
'conn.wfStepAnalyze': 'Paste & analyze',
|
||||
'conn.wfStepConfirm': 'Confirm & save',
|
||||
'conn.wfChooseFile': 'Choose file',
|
||||
'conn.wfAnalyze': 'Analyze',
|
||||
'conn.wfAnalyzing': 'Analyzing…',
|
||||
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
|
||||
'conn.wfUsageLabel': 'Usage',
|
||||
'conn.wfUsageAuto': 'Auto-detected: {usage}',
|
||||
'conn.wfUsageNeedPick': 'Pick manually',
|
||||
'conn.wfPickUsage': 'Choose a usage first',
|
||||
'conn.wfSlotsLabel': 'Image slots',
|
||||
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
|
||||
'conn.wfBack': 'Back',
|
||||
'conn.wfConfirmSave': 'Confirm & save',
|
||||
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
|
||||
'conn.wfPendingCount': '{n} pending',
|
||||
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
|
||||
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.missingMaterial': 'Ein dediziertes Qwen Image 2.1-Workflow-JSON fehlt noch im Repo: aus ComfyUI exportieren und hier importieren — der generische Importweg steht bereit.',
|
||||
|
||||
// --- Eigenständige Bild-/Videogenerierung (a3/a4, HEL-612) ---
|
||||
|
||||
@@ -1400,6 +1400,41 @@ export const en = {
|
||||
'conn.wfDeleted': 'Workflow deleted',
|
||||
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
|
||||
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
|
||||
// --- Five usages & import confirmation (HEL-636) ---
|
||||
'usage.text2image': 'Text → Image',
|
||||
'usage.image2image': 'Image → Image',
|
||||
'usage.text2video': 'Text → Video',
|
||||
'usage.image2video': 'Image → Video',
|
||||
'usage.ref2video': 'Reference → Video',
|
||||
'usage.pending': 'Usage pending',
|
||||
'wfRole.image': 'Input image',
|
||||
'wfRole.first_frame': 'First frame',
|
||||
'wfRole.last_frame': 'Last frame',
|
||||
'wfRole.reference_image': 'Reference',
|
||||
'wfRole.mask': 'Mask',
|
||||
'conn.wfImportAction': 'Import workflow',
|
||||
'conn.wfConfirmTitle': 'Confirm detection',
|
||||
'conn.wfStepAnalyze': 'Paste & analyze',
|
||||
'conn.wfStepConfirm': 'Confirm & save',
|
||||
'conn.wfChooseFile': 'Choose file',
|
||||
'conn.wfAnalyze': 'Analyze',
|
||||
'conn.wfAnalyzing': 'Analyzing…',
|
||||
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
|
||||
'conn.wfUsageLabel': 'Usage',
|
||||
'conn.wfUsageAuto': 'Auto-detected: {usage}',
|
||||
'conn.wfUsageNeedPick': 'Pick manually',
|
||||
'conn.wfPickUsage': 'Choose a usage first',
|
||||
'conn.wfSlotsLabel': 'Image slots',
|
||||
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
|
||||
'conn.wfBack': 'Back',
|
||||
'conn.wfConfirmSave': 'Confirm & save',
|
||||
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
|
||||
'conn.wfPendingCount': '{n} pending',
|
||||
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
|
||||
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.missingMaterial': 'A dedicated Qwen Image 2.1 workflow JSON is not in the repo yet: export it from ComfyUI and import it here — the generic import path is ready.',
|
||||
// --- Standalone image / video generation (a3/a4, HEL-612) ---
|
||||
'studio.image.title': 'Image',
|
||||
|
||||
@@ -1400,6 +1400,41 @@ export const es: Dict = {
|
||||
'conn.wfDeleted': 'Flujo eliminado',
|
||||
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
|
||||
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
|
||||
// --- Five usages & import confirmation (HEL-636) ---
|
||||
'usage.text2image': 'Text → Image',
|
||||
'usage.image2image': 'Image → Image',
|
||||
'usage.text2video': 'Text → Video',
|
||||
'usage.image2video': 'Image → Video',
|
||||
'usage.ref2video': 'Reference → Video',
|
||||
'usage.pending': 'Usage pending',
|
||||
'wfRole.image': 'Input image',
|
||||
'wfRole.first_frame': 'First frame',
|
||||
'wfRole.last_frame': 'Last frame',
|
||||
'wfRole.reference_image': 'Reference',
|
||||
'wfRole.mask': 'Mask',
|
||||
'conn.wfImportAction': 'Import workflow',
|
||||
'conn.wfConfirmTitle': 'Confirm detection',
|
||||
'conn.wfStepAnalyze': 'Paste & analyze',
|
||||
'conn.wfStepConfirm': 'Confirm & save',
|
||||
'conn.wfChooseFile': 'Choose file',
|
||||
'conn.wfAnalyze': 'Analyze',
|
||||
'conn.wfAnalyzing': 'Analyzing…',
|
||||
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
|
||||
'conn.wfUsageLabel': 'Usage',
|
||||
'conn.wfUsageAuto': 'Auto-detected: {usage}',
|
||||
'conn.wfUsageNeedPick': 'Pick manually',
|
||||
'conn.wfPickUsage': 'Choose a usage first',
|
||||
'conn.wfSlotsLabel': 'Image slots',
|
||||
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
|
||||
'conn.wfBack': 'Back',
|
||||
'conn.wfConfirmSave': 'Confirm & save',
|
||||
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
|
||||
'conn.wfPendingCount': '{n} pending',
|
||||
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
|
||||
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.missingMaterial': 'Aún falta en el repo un JSON dedicado de Qwen Image 2.1: expórtalo desde ComfyUI e impórtalo aquí; la vía genérica ya está lista.',
|
||||
|
||||
// --- Generación independiente de imagen / vídeo (a3/a4, HEL-612) ---
|
||||
|
||||
@@ -1400,6 +1400,41 @@ export const fr: Dict = {
|
||||
'conn.wfDeleted': 'Workflow supprimé',
|
||||
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
|
||||
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
|
||||
// --- Five usages & import confirmation (HEL-636) ---
|
||||
'usage.text2image': 'Text → Image',
|
||||
'usage.image2image': 'Image → Image',
|
||||
'usage.text2video': 'Text → Video',
|
||||
'usage.image2video': 'Image → Video',
|
||||
'usage.ref2video': 'Reference → Video',
|
||||
'usage.pending': 'Usage pending',
|
||||
'wfRole.image': 'Input image',
|
||||
'wfRole.first_frame': 'First frame',
|
||||
'wfRole.last_frame': 'Last frame',
|
||||
'wfRole.reference_image': 'Reference',
|
||||
'wfRole.mask': 'Mask',
|
||||
'conn.wfImportAction': 'Import workflow',
|
||||
'conn.wfConfirmTitle': 'Confirm detection',
|
||||
'conn.wfStepAnalyze': 'Paste & analyze',
|
||||
'conn.wfStepConfirm': 'Confirm & save',
|
||||
'conn.wfChooseFile': 'Choose file',
|
||||
'conn.wfAnalyze': 'Analyze',
|
||||
'conn.wfAnalyzing': 'Analyzing…',
|
||||
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
|
||||
'conn.wfUsageLabel': 'Usage',
|
||||
'conn.wfUsageAuto': 'Auto-detected: {usage}',
|
||||
'conn.wfUsageNeedPick': 'Pick manually',
|
||||
'conn.wfPickUsage': 'Choose a usage first',
|
||||
'conn.wfSlotsLabel': 'Image slots',
|
||||
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
|
||||
'conn.wfBack': 'Back',
|
||||
'conn.wfConfirmSave': 'Confirm & save',
|
||||
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
|
||||
'conn.wfPendingCount': '{n} pending',
|
||||
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
|
||||
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.missingMaterial': 'Un JSON de workflow Qwen Image 2.1 dédié manque encore au dépôt : exportez-le depuis ComfyUI et importez-le ici — le chemin d\'import générique est prêt.',
|
||||
|
||||
// --- Génération indépendante image / vidéo (a3/a4, HEL-612) ---
|
||||
|
||||
@@ -1400,6 +1400,41 @@ export const ja: Dict = {
|
||||
'conn.wfDeleted': 'ワークフローを削除しました',
|
||||
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
|
||||
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
|
||||
// --- Five usages & import confirmation (HEL-636) ---
|
||||
'usage.text2image': 'Text → Image',
|
||||
'usage.image2image': 'Image → Image',
|
||||
'usage.text2video': 'Text → Video',
|
||||
'usage.image2video': 'Image → Video',
|
||||
'usage.ref2video': 'Reference → Video',
|
||||
'usage.pending': 'Usage pending',
|
||||
'wfRole.image': 'Input image',
|
||||
'wfRole.first_frame': 'First frame',
|
||||
'wfRole.last_frame': 'Last frame',
|
||||
'wfRole.reference_image': 'Reference',
|
||||
'wfRole.mask': 'Mask',
|
||||
'conn.wfImportAction': 'Import workflow',
|
||||
'conn.wfConfirmTitle': 'Confirm detection',
|
||||
'conn.wfStepAnalyze': 'Paste & analyze',
|
||||
'conn.wfStepConfirm': 'Confirm & save',
|
||||
'conn.wfChooseFile': 'Choose file',
|
||||
'conn.wfAnalyze': 'Analyze',
|
||||
'conn.wfAnalyzing': 'Analyzing…',
|
||||
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
|
||||
'conn.wfUsageLabel': 'Usage',
|
||||
'conn.wfUsageAuto': 'Auto-detected: {usage}',
|
||||
'conn.wfUsageNeedPick': 'Pick manually',
|
||||
'conn.wfPickUsage': 'Choose a usage first',
|
||||
'conn.wfSlotsLabel': 'Image slots',
|
||||
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
|
||||
'conn.wfBack': 'Back',
|
||||
'conn.wfConfirmSave': 'Confirm & save',
|
||||
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
|
||||
'conn.wfPendingCount': '{n} pending',
|
||||
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
|
||||
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.missingMaterial': 'Qwen Image 2.1 専用のワークフロー JSON はまだリポジトリにありません。ComfyUI から書き出してここで取り込んでください。汎用の取り込み経路は用意済みです。',
|
||||
|
||||
// --- 独立画像/動画生成(a3/a4、HEL-612)---
|
||||
|
||||
@@ -1400,6 +1400,41 @@ export const ko: Dict = {
|
||||
'conn.wfDeleted': '워크플로가 삭제되었습니다',
|
||||
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
|
||||
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
|
||||
// --- Five usages & import confirmation (HEL-636) ---
|
||||
'usage.text2image': 'Text → Image',
|
||||
'usage.image2image': 'Image → Image',
|
||||
'usage.text2video': 'Text → Video',
|
||||
'usage.image2video': 'Image → Video',
|
||||
'usage.ref2video': 'Reference → Video',
|
||||
'usage.pending': 'Usage pending',
|
||||
'wfRole.image': 'Input image',
|
||||
'wfRole.first_frame': 'First frame',
|
||||
'wfRole.last_frame': 'Last frame',
|
||||
'wfRole.reference_image': 'Reference',
|
||||
'wfRole.mask': 'Mask',
|
||||
'conn.wfImportAction': 'Import workflow',
|
||||
'conn.wfConfirmTitle': 'Confirm detection',
|
||||
'conn.wfStepAnalyze': 'Paste & analyze',
|
||||
'conn.wfStepConfirm': 'Confirm & save',
|
||||
'conn.wfChooseFile': 'Choose file',
|
||||
'conn.wfAnalyze': 'Analyze',
|
||||
'conn.wfAnalyzing': 'Analyzing…',
|
||||
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
|
||||
'conn.wfUsageLabel': 'Usage',
|
||||
'conn.wfUsageAuto': 'Auto-detected: {usage}',
|
||||
'conn.wfUsageNeedPick': 'Pick manually',
|
||||
'conn.wfPickUsage': 'Choose a usage first',
|
||||
'conn.wfSlotsLabel': 'Image slots',
|
||||
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
|
||||
'conn.wfBack': 'Back',
|
||||
'conn.wfConfirmSave': 'Confirm & save',
|
||||
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
|
||||
'conn.wfPendingCount': '{n} pending',
|
||||
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
|
||||
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.missingMaterial': 'Qwen Image 2.1 전용 워크플로 JSON이 아직 저장소에 없습니다. ComfyUI에서 내보내 여기서 가져오세요. 범용 가져오기 경로는 준비되어 있습니다.',
|
||||
|
||||
// --- 독립 이미지/비디오 생성 (a3/a4, HEL-612) ---
|
||||
|
||||
@@ -1379,7 +1379,7 @@ export const zh: Dict = {
|
||||
'conn.noticeStrong': '连通性以服务器为准:',
|
||||
'conn.noticeBody': '地址在浏览器能打开不代表生成可用;测试与生成请求都由运行影工坊的服务器发起。令牌仅存服务端。',
|
||||
'conn.wfTitle': '工作流库',
|
||||
'conn.wfHint': '导入 ComfyUI 的 API 格式 JSON,分类为图片 / 视频,并绑定到某个连接',
|
||||
'conn.wfHint': '导入 ComfyUI 的 API 格式 JSON,自动判定五类用途,并绑定到某个连接',
|
||||
'conn.wfEmpty': '还没有导入工作流。在 ComfyUI 里启用开发者模式,用「导出(API 格式)」得到 JSON 后粘贴导入。',
|
||||
'conn.wfCount': '{n} 个工作流 · {m} 图片 · {k} 视频',
|
||||
'conn.wfKindImage': '图片',
|
||||
@@ -1391,16 +1391,51 @@ export const zh: Dict = {
|
||||
'conn.wfImporting': '导入中…',
|
||||
'conn.wfNodes': '{n} 个节点',
|
||||
'conn.wfImportedN': '工作流已导入,识别到 {n} 个可填参数',
|
||||
'conn.wfImportedNoParams': '工作流已导入,但没有识别到可填参数:生成时将按工作流内置设置运行',
|
||||
'conn.wfImportFailed': '导入失败:{detail}',
|
||||
'conn.wfImportTitle': '导入 ComfyUI 工作流(API 格式 JSON)',
|
||||
'conn.wfImportHint': '粘贴或选择文件;名称必填,分类决定它出现在生图还是生视频窗口。',
|
||||
'conn.wfJsonInvalid': '不是有效的 API 格式 JSON(应为节点 id 到 class_type 的对象)',
|
||||
'conn.wfJsonPh': '在此粘贴 ComfyUI 导出的 API 格式 JSON,或点右侧按钮选择文件',
|
||||
'conn.wfDeleted': '工作流已删除',
|
||||
'conn.wfImportedBroken': '已导入,但工作流文件不完整,生成前需重新导出:{detail}',
|
||||
'conn.wfBrokenBadge': '文件不完整,无法生成',
|
||||
'conn.missingMaterial': 'Qwen Image 2.1 的专用工作流 JSON 仓库中暂缺:请从 ComfyUI 导出后在此导入,通用导入通道已就绪。',
|
||||
'conn.wfImportedNoParams': '工作流已导入,但没有识别到可填参数:生成时将按工作流内置设置运行',
|
||||
'conn.wfImportFailed': '导入失败:{detail}',
|
||||
'conn.wfImportTitle': '导入 ComfyUI 工作流(API 格式 JSON)',
|
||||
'conn.wfImportHint': '粘贴或选择文件并点「分析」;系统按输出节点和图片槽位判定五类用途,确认后保存。',
|
||||
'conn.wfJsonInvalid': '不是有效的 API 格式 JSON(应为节点 id 到 class_type 的对象)',
|
||||
'conn.wfJsonPh': '在此粘贴 ComfyUI 导出的 API 格式 JSON,或点右侧按钮选择文件',
|
||||
'conn.wfDeleted': '工作流已删除',
|
||||
'conn.wfImportedBroken': '已导入,但工作流文件不完整,生成前需重新导出:{detail}',
|
||||
'conn.wfBrokenBadge': '文件不完整,无法生成',
|
||||
// --- 五类用途与导入确认(HEL-636)---
|
||||
'usage.text2image': '文生图',
|
||||
'usage.image2image': '图生图',
|
||||
'usage.text2video': '文生视频',
|
||||
'usage.image2video': '图生视频',
|
||||
'usage.ref2video': '参考图生视频',
|
||||
'usage.pending': '用途待确认',
|
||||
'wfRole.image': '输入图',
|
||||
'wfRole.first_frame': '首帧',
|
||||
'wfRole.last_frame': '尾帧',
|
||||
'wfRole.reference_image': '参考图',
|
||||
'wfRole.mask': '遮罩',
|
||||
'conn.wfImportAction': '导入工作流',
|
||||
'conn.wfConfirmTitle': '确认识别结果',
|
||||
'conn.wfStepAnalyze': '粘贴分析',
|
||||
'conn.wfStepConfirm': '确认保存',
|
||||
'conn.wfChooseFile': '选择文件',
|
||||
'conn.wfAnalyze': '分析',
|
||||
'conn.wfAnalyzing': '分析中…',
|
||||
'conn.wfConfirmHint': '核对判定依据、输出节点与图片槽位;用途或槽位不对可在此纠正后再保存。',
|
||||
'conn.wfUsageLabel': '用途',
|
||||
'conn.wfUsageAuto': '自动判定:{usage}',
|
||||
'conn.wfUsageNeedPick': '需人工选择',
|
||||
'conn.wfPickUsage': '请先选择工作流用途',
|
||||
'conn.wfSlotsLabel': '图片槽位',
|
||||
'conn.wfSlotsHint': '槽位角色按连线推断;不符时可改为输入图 / 首帧 / 尾帧 / 参考图 / 遮罩,保存后以人工选择为准。',
|
||||
'conn.wfBack': '上一步',
|
||||
'conn.wfConfirmSave': '确认保存',
|
||||
'conn.wfImportedWithUsage': '已保存为「{usage}」,识别到 {n} 个可填参数',
|
||||
'conn.wfPendingCount': '{n} 个待确认',
|
||||
'conn.wfConfirmed': '已确认用途:{usage},工作流已可生成',
|
||||
'conn.wfSourceUser': '用途为人工确认,重新分析不会覆盖',
|
||||
'conn.wfSourceAuto': '用途为自动判定',
|
||||
'conn.wfDisabledBadge': '已停用',
|
||||
'conn.wfConfirmAria': '确认工作流用途',
|
||||
'conn.missingMaterial': 'Qwen Image 2.1 的专用工作流 JSON 仓库中暂缺:请从 ComfyUI 导出后在此导入,通用导入通道已就绪。',
|
||||
// --- 独立生图 / 生视频(a3/a4,HEL-612)---
|
||||
'studio.image.title': '生图',
|
||||
'studio.image.subtitle': '用已导入的图片工作流生成图片 · 连接与队列和「ComfyUI 连接」共用',
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import { comfy, settings, workflows, type ComfyEndpoint, type Workflow } from '$lib/api';
|
||||
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowUsage, type WorkflowAnalyzeResult } from '$lib/api';
|
||||
import { WORKFLOW_USAGES } from '$lib/comfy/types';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { classifyEndpoint, endpointTypeLabel, type EndpointKind } from '$lib/endpointKind';
|
||||
@@ -325,13 +326,19 @@
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Workflow library (import + binding)
|
||||
// Workflow library (import + binding) — two-step wizard: paste/analyze,
|
||||
// then confirm the judged usage and slot roles before saving (HEL-636).
|
||||
// ------------------------------------------------------------------
|
||||
let wfJsonText = $state('');
|
||||
let wfImportOpen = $state(false);
|
||||
let wfImportName = $state('');
|
||||
let wfImportKind = $state<'image' | 'video'>('image');
|
||||
let wfImportEndpoint = $state('');
|
||||
let wfStep = $state<'input' | 'confirm'>('input');
|
||||
let wfAnalysis = $state<WorkflowAnalyzeResult | null>(null);
|
||||
let wfUsage = $state<WorkflowUsage | null>(null);
|
||||
let wfRoleOverrides = $state<Record<string, string>>({});
|
||||
let wfAnalyzing = $state(false);
|
||||
let wfAnalyzeError = $state('');
|
||||
|
||||
const wfParsed = $derived.by(() => {
|
||||
const text = wfJsonText.trim();
|
||||
@@ -354,25 +361,87 @@
|
||||
return { valid: false as const, json: null };
|
||||
});
|
||||
|
||||
function startWfImport(kind: 'image' | 'video') {
|
||||
function usageLabel(u: WorkflowUsage | null | undefined): string {
|
||||
return u ? t(`usage.${u}`) : t('usage.pending');
|
||||
}
|
||||
|
||||
function startWfImport() {
|
||||
wfImportOpen = true;
|
||||
wfImportKind = kind;
|
||||
wfImportName = '';
|
||||
wfJsonText = '';
|
||||
wfImportEndpoint = '';
|
||||
wfStep = 'input';
|
||||
wfAnalysis = null;
|
||||
wfUsage = null;
|
||||
wfRoleOverrides = {};
|
||||
wfAnalyzeError = '';
|
||||
}
|
||||
|
||||
function wfProblems(wf: Workflow): string[] {
|
||||
return (wf.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message);
|
||||
}
|
||||
|
||||
async function runWfAnalyze() {
|
||||
if (!wfParsed.valid || !wfParsed.json) return;
|
||||
wfAnalyzing = true;
|
||||
wfAnalyzeError = '';
|
||||
try {
|
||||
const res = await workflows.analyze(wfParsed.json);
|
||||
wfAnalysis = res;
|
||||
wfUsage = res.usage ?? null;
|
||||
wfRoleOverrides = {};
|
||||
wfStep = 'confirm';
|
||||
} catch (err) {
|
||||
wfAnalyzeError = apiErrorMessage(err);
|
||||
} finally {
|
||||
wfAnalyzing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the graph has no output node at all — nothing to confirm. */
|
||||
const wfFatal = $derived(
|
||||
wfStep === 'confirm' && wfAnalysis !== null && (wfAnalysis.outputs?.length ?? 0) === 0,
|
||||
);
|
||||
const wfMustPick = $derived(
|
||||
wfStep === 'confirm' &&
|
||||
wfAnalysis !== null &&
|
||||
!wfFatal &&
|
||||
(wfAnalysis.usage_ambiguous || wfAnalysis.needs_mapping) &&
|
||||
wfUsage === null,
|
||||
);
|
||||
const wfSaveable = $derived(wfStep === 'confirm' && wfUsage !== null && !wfFatal);
|
||||
/** Media slots the confirm page lets the user re-role. */
|
||||
const wfSlotRows = $derived.by(() => {
|
||||
if (wfStep !== 'confirm' || !wfAnalysis) return [];
|
||||
return wfAnalysis.usage_evidence.image_slots.map((s) => ({
|
||||
...s,
|
||||
role: wfRoleOverrides[s.nodeId] ?? s.role ?? 'image',
|
||||
fixed: s.kind === 'image_list',
|
||||
}));
|
||||
});
|
||||
const wfParamPreview = $derived.by(() => {
|
||||
if (!wfAnalysis) return [];
|
||||
return wfAnalysis.inputs
|
||||
.filter((i) => !['image', 'image_list', 'image_url', 'video', 'audio'].includes(i.kind))
|
||||
.map((i) => i.label);
|
||||
});
|
||||
|
||||
const wfImportMutation = createMutation({
|
||||
mutationFn: async () => {
|
||||
if (!wfParsed.valid || !wfParsed.json) throw new Error(t('conn.wfJsonInvalid'));
|
||||
if (!wfParsed.valid || !wfParsed.json || !wfAnalysis || !wfUsage)
|
||||
throw new Error(t('conn.wfPickUsage'));
|
||||
const corrected = Object.keys(wfRoleOverrides).length > 0;
|
||||
const schema = wfAnalysis.inputs.map((e) =>
|
||||
wfRoleOverrides[e.nodeId]
|
||||
? { ...e, role: wfRoleOverrides[e.nodeId], manual: true }
|
||||
: e,
|
||||
);
|
||||
return workflows.create({
|
||||
name: wfImportName.trim(),
|
||||
kind: wfImportKind,
|
||||
workflow_json: wfParsed.json,
|
||||
usage: wfUsage,
|
||||
usage_source: wfUsage !== wfAnalysis.usage || corrected ? 'user' : 'auto',
|
||||
input_schema: schema,
|
||||
...(wfImportEndpoint ? { endpoint_id: wfImportEndpoint } : {}),
|
||||
});
|
||||
},
|
||||
@@ -381,11 +450,13 @@
|
||||
const n = wf.input_schema?.length ?? 0;
|
||||
const problems = wfProblems(wf);
|
||||
if (problems.length) toast.error(t('conn.wfImportedBroken', { detail: problems.join(';') }));
|
||||
else if (n) toast.success(t('conn.wfImportedN', { n }));
|
||||
else if (n) toast.success(t('conn.wfImportedWithUsage', { usage: usageLabel(wf.usage), n }));
|
||||
else toast.info(t('conn.wfImportedNoParams'));
|
||||
wfImportOpen = false;
|
||||
wfJsonText = '';
|
||||
wfImportName = '';
|
||||
wfStep = 'input';
|
||||
wfAnalysis = null;
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
@@ -397,10 +468,26 @@
|
||||
});
|
||||
|
||||
function submitWfImport() {
|
||||
if (!wfImportName.trim() || !wfParsed.valid || $wfImportMutation.isPending) return;
|
||||
if (!wfSaveable || !wfImportName.trim() || $wfImportMutation.isPending) return;
|
||||
$wfImportMutation.mutate();
|
||||
}
|
||||
|
||||
const wfConfirmMutation = createMutation({
|
||||
mutationFn: ({ id, usage }: { id: number; usage: WorkflowUsage }) =>
|
||||
workflows.update(id, { usage, usage_source: 'user' }),
|
||||
onSuccess: (wf) => {
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(t('conn.wfConfirmed', { usage: usageLabel(wf.usage) }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
t('conn.wfImportFailed', {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const wfBindMutation = createMutation({
|
||||
mutationFn: ({ id, endpointId }: { id: number; endpointId: string | null }) =>
|
||||
workflows.update(id, { endpoint_id: endpointId }),
|
||||
@@ -627,36 +714,39 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ---------------- workflow library ---------------- -->
|
||||
<section class="wf-panel" id="wf-library">
|
||||
<div class="panel-head">
|
||||
<h2>{t('conn.wfTitle')}</h2>
|
||||
<span class="panel-hint">{t('conn.wfHint')}</span>
|
||||
<span class="panel-actions">
|
||||
<Button variant="secondary" size="sm" onclick={() => startWfImport('image')}>
|
||||
<Icon name="image" size={13} />
|
||||
{t('conn.wfKindImage')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onclick={() => startWfImport('video')}>
|
||||
<Icon name="film" size={13} />
|
||||
{t('conn.wfKindVideo')}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- ---------------- workflow library ---------------- -->
|
||||
<section class="wf-panel" id="wf-library">
|
||||
<div class="panel-head">
|
||||
<h2>{t('conn.wfTitle')}</h2>
|
||||
<span class="panel-hint">{t('conn.wfHint')}</span>
|
||||
<span class="panel-actions">
|
||||
<Button variant="secondary" size="sm" onclick={startWfImport}>
|
||||
<Icon name="upload" size={13} />
|
||||
{t('conn.wfImportAction')}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if wfImportOpen}
|
||||
<div class="wf-import" transition:fade={{ duration: 180 }}>
|
||||
<div class="wf-import-head">
|
||||
<h3>{t('conn.wfImportTitle')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('conn.formClose')}
|
||||
onclick={() => (wfImportOpen = false)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{#if wfImportOpen}
|
||||
<div class="wf-import" transition:fade={{ duration: 180 }}>
|
||||
<div class="wf-import-head">
|
||||
<h3>
|
||||
{wfStep === 'input' ? t('conn.wfImportTitle') : t('conn.wfConfirmTitle')}
|
||||
<span class="wf-step-tag">
|
||||
{wfStep === 'input' ? `1 / 2 · ${t('conn.wfStepAnalyze')}` : `2 / 2 · ${t('conn.wfStepConfirm')}`}
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('conn.formClose')}
|
||||
onclick={() => (wfImportOpen = false)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if wfStep === 'input'}
|
||||
<p class="wf-import-hint">{t('conn.wfImportHint')}</p>
|
||||
<div class="wf-import-grid">
|
||||
<label class="f-field">
|
||||
@@ -681,31 +771,6 @@
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="f-field">
|
||||
<span class="f-label">
|
||||
{t('conn.wfKindImage')} / {t('conn.wfKindVideo')}
|
||||
</span>
|
||||
<div class="seg" role="radiogroup">
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:selected={wfImportKind === 'image'}
|
||||
aria-pressed={wfImportKind === 'image'}
|
||||
onclick={() => (wfImportKind = 'image')}
|
||||
>
|
||||
{t('conn.wfKindImage')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:selected={wfImportKind === 'video'}
|
||||
aria-pressed={wfImportKind === 'video'}
|
||||
onclick={() => (wfImportKind = 'video')}
|
||||
>
|
||||
{t('conn.wfKindVideo')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="f-field">
|
||||
<span class="f-label">API JSON<em>*</em></span>
|
||||
<div class="wf-json-row">
|
||||
@@ -720,7 +785,7 @@
|
||||
></textarea>
|
||||
<label class="file-btn">
|
||||
<Icon name="upload" size={14} />
|
||||
{t('conn.wfImport')}
|
||||
{t('conn.wfChooseFile')}
|
||||
<input type="file" accept=".json,application/json" onchange={onWfFile} />
|
||||
</label>
|
||||
</div>
|
||||
@@ -739,66 +804,216 @@
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!wfParsed.valid || !wfImportName.trim()}
|
||||
loading={wfAnalyzing}
|
||||
onclick={runWfAnalyze}
|
||||
>
|
||||
{wfAnalyzing ? t('conn.wfAnalyzing') : t('conn.wfAnalyze')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else if wfAnalysis}
|
||||
<p class="wf-import-hint">{t('conn.wfConfirmHint')}</p>
|
||||
|
||||
{#if wfAnalysis.usage_problem}
|
||||
<div class="wf-note" class:fatal={wfFatal} role={wfFatal ? 'alert' : 'note'}>
|
||||
<Icon name={wfFatal ? 'alert' : 'info'} size={14} />
|
||||
<p>{wfAnalysis.usage_problem}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if wfAnalyzeError}
|
||||
<div class="wf-note fatal" role="alert">
|
||||
<Icon name="alert" size={14} />
|
||||
<p>{wfAnalyzeError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="wf-evidence">
|
||||
{#each wfAnalysis.usage_evidence.summary as line (line)}
|
||||
<li>{line}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="f-field">
|
||||
<span class="f-label">
|
||||
{t('conn.wfUsageLabel')}<em>*</em>
|
||||
<span class="f-hint-inline">
|
||||
{#if wfAnalysis.usage}
|
||||
{t('conn.wfUsageAuto', { usage: usageLabel(wfAnalysis.usage) })}
|
||||
{:else}
|
||||
{t('conn.wfUsageNeedPick')}
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
<div class="seg wrap" role="radiogroup">
|
||||
{#each WORKFLOW_USAGES as u (u)}
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:selected={wfUsage === u}
|
||||
aria-pressed={wfUsage === u}
|
||||
onclick={() => (wfUsage = u)}
|
||||
>
|
||||
{usageLabel(u)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if wfMustPick}<span class="f-error">{t('conn.wfPickUsage')}</span>{/if}
|
||||
</div>
|
||||
|
||||
{#if wfSlotRows.length}
|
||||
<div class="f-field">
|
||||
<span class="f-label">{t('conn.wfSlotsLabel')}</span>
|
||||
<div class="wf-slot-rows">
|
||||
{#each wfSlotRows as row (row.nodeId)}
|
||||
<div class="wf-slot-row">
|
||||
<span class="wf-slot-id mono">{row.nodeId}</span>
|
||||
<span class="wf-slot-name">{row.label}</span>
|
||||
{#if row.fixed}
|
||||
<span class="tag tag-usage">{t('wfRole.reference_image')}</span>
|
||||
{:else}
|
||||
<select
|
||||
class="a-select"
|
||||
value={row.role}
|
||||
onchange={(e) =>
|
||||
(wfRoleOverrides = {
|
||||
...wfRoleOverrides,
|
||||
[row.nodeId]: e.currentTarget.value,
|
||||
})}
|
||||
>
|
||||
{#each ['image', 'first_frame', 'last_frame', 'reference_image', 'mask'] as r (r)}
|
||||
<option value={r}>{t(`wfRole.${r}`)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="f-hint">{t('conn.wfSlotsHint')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="wf-params">
|
||||
{t('conn.wfParams', { n: wfParamPreview.length })}
|
||||
{#if wfParamPreview.length}
|
||||
<span class="wf-params-list">{wfParamPreview.slice(0, 8).join(' · ')}{wfParamPreview.length > 8 ? ' …' : ''}</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if (wfAnalysis.warnings ?? []).length}
|
||||
<ul class="wf-warnings">
|
||||
{#each wfAnalysis.warnings as w (w.node_id + w.code)}
|
||||
<li class:err={w.level === 'error'}>{w.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={$wfImportMutation.isPending}
|
||||
onclick={() => (wfStep = 'input')}
|
||||
>
|
||||
{t('conn.wfBack')}
|
||||
</Button>
|
||||
<span class="need-test" class:passed={wfSaveable && wfImportName.trim() !== ''}>
|
||||
{wfSaveable ? usageLabel(wfUsage) : t('conn.wfUsageNeedPick')}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!wfSaveable || !wfImportName.trim()}
|
||||
loading={$wfImportMutation.isPending}
|
||||
onclick={submitWfImport}
|
||||
>
|
||||
{$wfImportMutation.isPending ? t('conn.wfImporting') : t('conn.wfImport')}
|
||||
{$wfImportMutation.isPending ? t('conn.wfImporting') : t('conn.wfConfirmSave')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ($workflowsQuery.data ?? []).length === 0}
|
||||
<EmptyState title={t('conn.wfEmpty')} />
|
||||
<p class="missing-note">{t('conn.missingMaterial')}</p>
|
||||
{:else}
|
||||
{@const wfs = $workflowsQuery.data ?? []}
|
||||
<p class="wf-count">
|
||||
{t('conn.wfCount', {
|
||||
n: wfs.length,
|
||||
m: wfs.filter((w) => w.kind === 'image').length,
|
||||
k: wfs.filter((w) => w.kind === 'video').length,
|
||||
})}
|
||||
</p>
|
||||
<div class="wf-table">
|
||||
{#each wfs as wf (wf.id)}
|
||||
<div class="wf-row">
|
||||
<span class="wf-kind {wf.kind}">
|
||||
<Icon name={wf.kind === 'image' ? 'image' : 'film'} size={14} />
|
||||
</span>
|
||||
<span class="wf-name" title={wf.description ?? wf.name}>{wf.name}</span>
|
||||
{#if wfProblems(wf).length}
|
||||
<span class="tag tag-error" title={wfProblems(wf).join('\n')}>
|
||||
{t('conn.wfBrokenBadge')}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="wf-params">{t('conn.wfParams', { n: wf.input_schema?.length ?? 0 })}</span>
|
||||
<select
|
||||
class="a-select"
|
||||
value={wf.endpoint_id ?? ''}
|
||||
onchange={(e) =>
|
||||
$wfBindMutation.mutate({
|
||||
id: wf.id,
|
||||
endpointId: e.currentTarget.value || null,
|
||||
})}
|
||||
{#if ($workflowsQuery.data ?? []).length === 0}
|
||||
<EmptyState title={t('conn.wfEmpty')} />
|
||||
<p class="missing-note">{t('conn.missingMaterial')}</p>
|
||||
{:else}
|
||||
{@const wfs = $workflowsQuery.data ?? []}
|
||||
{@const pending = wfs.filter((w) => !w.usage).length}
|
||||
<p class="wf-count">
|
||||
{t('conn.wfCount', {
|
||||
n: wfs.length,
|
||||
m: wfs.filter((w) => w.kind === 'image').length,
|
||||
k: wfs.filter((w) => w.kind === 'video').length,
|
||||
})}
|
||||
{#if pending}
|
||||
<span class="wf-pending-note">· {t('conn.wfPendingCount', { n: pending })}</span>
|
||||
{/if}
|
||||
</p>
|
||||
<div class="wf-table">
|
||||
{#each wfs as wf (wf.id)}
|
||||
<div class="wf-row" class:pending={!wf.usage}>
|
||||
<span class="wf-kind {wf.kind}">
|
||||
<Icon name={wf.kind === 'image' ? 'image' : 'film'} size={14} />
|
||||
</span>
|
||||
<span class="wf-name" title={wf.description ?? wf.name}>{wf.name}</span>
|
||||
{#if wf.usage}
|
||||
<span
|
||||
class="tag tag-usage"
|
||||
title={wf.usage_source === 'user' ? t('conn.wfSourceUser') : t('conn.wfSourceAuto')}
|
||||
>
|
||||
<option value="">{t('conn.wfConnectionActive')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{usageLabel(wf.usage)}
|
||||
</span>
|
||||
{:else}
|
||||
<select
|
||||
class="a-select wf-confirm-select"
|
||||
aria-label={t('conn.wfConfirmAria')}
|
||||
disabled={$wfConfirmMutation.isPending}
|
||||
onchange={(e) => {
|
||||
const usage = e.currentTarget.value as WorkflowUsage;
|
||||
if (usage) $wfConfirmMutation.mutate({ id: wf.id, usage });
|
||||
e.currentTarget.value = '';
|
||||
}}
|
||||
>
|
||||
<option value="">{t('usage.pending')}…</option>
|
||||
{#each WORKFLOW_USAGES as u (u)}
|
||||
<option value={u}>{usageLabel(u)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="link-danger"
|
||||
title={t('conn.delete')}
|
||||
onclick={() => $wfDeleteMutation.mutate(wf.id)}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !wf.is_enabled && wf.usage}
|
||||
<span class="tag tag-disabled">{t('conn.wfDisabledBadge')}</span>
|
||||
{/if}
|
||||
{#if wfProblems(wf).length}
|
||||
<span class="tag tag-error" title={wfProblems(wf).join('\n')}>
|
||||
{t('conn.wfBrokenBadge')}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="wf-params">{t('conn.wfParams', { n: wf.input_schema?.length ?? 0 })}</span>
|
||||
<select
|
||||
class="a-select"
|
||||
value={wf.endpoint_id ?? ''}
|
||||
onchange={(e) =>
|
||||
$wfBindMutation.mutate({
|
||||
id: wf.id,
|
||||
endpointId: e.currentTarget.value || null,
|
||||
})}
|
||||
>
|
||||
<option value="">{t('conn.wfConnectionActive')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="link-danger"
|
||||
title={t('conn.delete')}
|
||||
onclick={() => $wfDeleteMutation.mutate(wf.id)}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -1304,6 +1519,138 @@
|
||||
margin-bottom: 14px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.wf-step-tag {
|
||||
margin-left: 8px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 7px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wf-note :global(.icon) {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.wf-note.fatal {
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.wf-note.fatal :global(.icon) {
|
||||
color: var(--error);
|
||||
}
|
||||
.wf-note p {
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wf-evidence {
|
||||
margin: 0 0 12px;
|
||||
padding: 0 0 0 4px;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.wf-evidence li {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wf-evidence li::before {
|
||||
content: '·';
|
||||
margin-right: 6px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.seg.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.seg.wrap .seg-item {
|
||||
flex: 1 0 30%;
|
||||
}
|
||||
.wf-slot-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.wf-slot-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 7px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.wf-slot-id {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
min-width: 64px;
|
||||
}
|
||||
.wf-slot-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wf-slot-row .a-select {
|
||||
width: 130px;
|
||||
}
|
||||
.wf-params {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-params-list {
|
||||
margin-left: 6px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wf-warnings {
|
||||
margin: 0 0 10px;
|
||||
padding: 8px 12px;
|
||||
list-style: none;
|
||||
border-radius: 7px;
|
||||
background: var(--error-dim);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.wf-warnings li {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wf-warnings li.err {
|
||||
color: var(--error);
|
||||
}
|
||||
.tag-usage {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
.tag-disabled {
|
||||
color: var(--text-3);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.wf-pending-note {
|
||||
color: var(--accent);
|
||||
}
|
||||
.wf-row.pending .wf-name {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-confirm-select {
|
||||
max-width: 150px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.wf-import-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1326,7 +1673,7 @@
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 14px;
|
||||
}
|
||||
.wf-import-grid .f-field:nth-child(4) {
|
||||
.wf-import-grid .f-field:nth-child(3) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.wf-json-row {
|
||||
@@ -1562,6 +1909,12 @@
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.f-hint-inline {
|
||||
margin-left: auto;
|
||||
font-size: 10.5px;
|
||||
font-weight: 400;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.f-error {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
|
||||
Reference in New Issue
Block a user