Compare commits

...
Author SHA1 Message Date
总工andmultica-agent e6f761f8c8 feat(workflows): 五类用途识别与导入确认闭环 (HEL-636)
- 判型器 classify_usage:按输出节点(sink 检测)与被消费图片槽判定
  文生图/图生图/文生视频/图生视频/参考图生视频,不依赖文件名/模型名/
  固定节点 ID;歧义(单散装图+视频、同图双输出)与无输出给中文说明
- 参考图组泛化:任意 <group>.<prefix>N 连续编号且均接图源的输入成组
  (MiniMaxH3 特判仅剩标签美化);patcher 支持通用组回填
- workflows 表新增 usage/usage_source,幂等迁移回填;判不出的存量行
  停用待确认;kind 语义收窄为输出媒体,历史无损
- 导入改两步向导:分析→预览(判定依据/输出节点/媒体槽角色可改/
  参数预览/警告)→确认保存;usage_source=user 的纠正不被重分析覆盖
- 生成页按 usage 过滤;待确认工作流不进生成页且提交被拒
- 新增 test_usage_classifier / test_workflow_import_confirm;
  两个旧测试夹具补输出节点以匹配新守卫

Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 12:13:33 +08:00
30e8e85766 feat(studio): 三种图片输入工作流与最多九张参考图 (HEL-633)
- 解析:H3 参考生视频 ref_images 自增输入收拢为 1–9 张有序参考图组;
  图生视频首/尾帧、Qwen 编辑输入图按真实连线识别,未连线不出槽位;
  Qwen 编辑模式暴露 resolution
- 打补丁:按张数克隆/复用/删除 LoadImage 并按序接线 ref_image_0..N-1
- 校验:参考图上限 9、至少 1 张、格式/存在性;必填图片槽位
- 工作流体检:缺 class_type / 断链 / 采样器无模型 → 导入时中文告警,
  生成接口与 worker 拒绝执行并提示重新导出
- 前端:RefImageGrid(逐张添加/预览/替换/删除/序号/超限整批拒绝)、
  MediaSlot 横排已选态、分辨率字段、损坏工作流提示、连接页告警标签
- 测试:三份真实 JSON + 1/4/9 张参考图全链路

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-29 09:16:17 +08:00
35 changed files with 4571 additions and 263 deletions
@@ -0,0 +1,125 @@
"""Structural checks on an imported ComfyUI API workflow.
``error`` findings mean ComfyUI will reject the prompt as exported, so the
studio blocks generation; ``warning`` findings only explain odd parameters.
Nothing here repairs a workflow — a broken export has to be re-exported from
a ComfyUI that has the missing custom nodes installed.
"""
from __future__ import annotations
from typing import Any
REEXPORT_HINT = "请在已安装对应自定义节点的 ComfyUI 中打开该工作流,重新导出 API 格式 JSON 后再导入"
# Nodes that cannot run without a model link.
_NEEDS_MODEL = frozenset(
{"KSampler", "KSamplerAdvanced", "BasicGuider", "CFGGuider", "BasicScheduler"}
)
def _title(node_id: str, node: dict[str, Any]) -> str:
title = str((node.get("_meta") or {}).get("title") or "").strip()
return f"节点 {node_id}「{title}」" if title else f"节点 {node_id}"
def workflow_warnings(workflow: dict[str, Any]) -> list[dict[str, str]]:
if not isinstance(workflow, dict):
return []
found: list[dict[str, str]] = []
nodes = {str(k): v for k, v in workflow.items() if isinstance(v, dict)}
untyped = [nid for nid, node in nodes.items() if not str(node.get("class_type") or "").strip()]
for nid in untyped:
found.append(
{
"level": "error",
"code": "missing_class_type",
"node_id": nid,
"message": f"{_title(nid, nodes[nid])}缺少节点类型(class_type),"
"导出时 ComfyUI 未加载该自定义节点",
}
)
consumed: set[str] = set()
for nid, node in nodes.items():
inputs = node.get("inputs") or {}
for name, val in inputs.items():
if isinstance(val, list) and len(val) == 2 and isinstance(val[0], (str, int)):
src = str(val[0])
consumed.add(src)
if src not in nodes:
found.append(
{
"level": "error",
"code": "dangling_link",
"node_id": nid,
"message": f"{_title(nid, node)}的输入「{name}」"
f"连到了不存在的节点 {src}",
}
)
ct = str(node.get("class_type") or "")
if ct in _NEEDS_MODEL and not isinstance(inputs.get("model"), list):
found.append(
{
"level": "error",
"code": "missing_model_link",
"node_id": nid,
"message": f"{_title(nid, node)}({ct})没有连接模型(model),"
"模型加载链路在导出时丢失",
}
)
for nid, node in nodes.items():
ct = str(node.get("class_type") or "")
if ct.startswith("Empty") and "Latent" in ct and nid not in consumed:
found.append(
{
"level": "warning",
"code": "orphan_latent",
"node_id": nid,
"message": f"{_title(nid, nodes[nid])}没有接到任何节点,"
"它的宽高不会生效,已不在表单中显示",
}
)
# HEL-636: 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
def blocking_problem(workflow: dict[str, Any]) -> str | None:
"""One Chinese sentence explaining why this workflow cannot be submitted."""
errors = [w["message"] for w in workflow_warnings(workflow) if w["level"] == "error"]
if not errors:
return None
shown = ";".join(errors[:4]) + (f";等 {len(errors)} 处" if len(errors) > 4 else "")
return f"该工作流文件不完整,ComfyUI 无法执行:{shown}。{REEXPORT_HINT}。"
+458 -8
View File
@@ -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
@@ -236,6 +249,12 @@ _NUMBER_LIMITS: dict[str, dict[str, Any]] = {
"megapixels": {"min": 0.1, "max": 16.0, "step": 0.1},
}
# Text encoders whose ``resolution`` widget sizes the generated image when an
# input image is wired (edit mode); in text-to-image it only sizes references.
_RESOLUTION_ENCODERS: dict[str, dict[str, Any]] = {
"TextEncodeQwenImage21": {"min": 0, "max": 4096, "step": 32, "integer": True},
}
# comfy_extras/nodes_resolution.py — option strings must match exactly.
RESOLUTION_ASPECT_RATIOS: dict[str, tuple[int, int]] = {
"1:1 (Square)": (1, 1),
@@ -260,6 +279,151 @@ def resolution_selector_size(aspect_ratio: str, megapixels: float, multiple: int
)
# 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", "首帧"),
"start_image": ("first_frame", "首帧"),
"last_frame": ("last_frame", "尾帧"),
"end_image": ("last_frame", "尾帧"),
}
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]]:
"""``[(N, input name, link), …]`` wired into ``<group>.<prefix>N``, by N."""
head = f"{group}.{prefix}"
out: list[tuple[int, str, Any]] = []
for name, val in (node.get("inputs") or {}).items():
if name.startswith(head) and name[len(head):].isdigit():
out.append((int(name[len(head):]), name, val))
return sorted(out)
def _detected_group_runs(
workflow: dict[str, Any], node: dict[str, Any]
) -> list[tuple[str, str, list[str]]]:
"""``[(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]]:
"""Group entries for Autogrow reference inputs + the LoadImage ids they absorb."""
entries: list[dict[str, Any]] = []
absorbed: set[str] = set()
for nid, node in sorted(
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
key=lambda kv: _node_sort_key(kv[0]),
):
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}",
str(spec.get("label") or "参考图"),
"reference_image",
"image_list",
None,
True,
min=1,
max=spec.get("max", 9),
)
)
return entries, absorbed
def _image_slot(
nid: str, workflow: dict[str, Any], consumers: dict[str, list[tuple[str, str]]]
) -> tuple[str, str] | None:
"""(role, label) of a LoadImage from what consumes it; None when unwired."""
uses = consumers.get(nid, [])
if not uses:
return None
for _cid, name in uses:
if name in _FRAME_SLOT_ROLES:
return _FRAME_SLOT_ROLES[name]
for cid, name in uses:
ct = str((workflow.get(cid) or {}).get("class_type") or "")
if "TextEncode" in ct and name.startswith("image"):
return "image", "输入图"
return "image", ""
def _key(node_id: str, node: dict[str, Any], field: str) -> str:
"""Plain nodeId when that already patches ``field``; else ``nodeId@field``."""
return (
@@ -283,6 +447,7 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
)
texts: list[tuple[str, dict[str, Any], str, bool]] = [] # (id, node, field, negative)
groups, grouped_images = _image_groups(workflow)
media: list[dict[str, Any]] = []
numbers: list[dict[str, Any]] = []
taken_roles: set[str] = set()
@@ -308,19 +473,31 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
lowered = title.lower()
if ct in IMAGE_CLASSES or ct in _MASK_IMAGE_CLASSES:
if nid in grouped_images:
continue
is_mask = ct in _MASK_IMAGE_CLASSES or "mask" in lowered or "遮罩" in lowered
label = title if title and title != ct else ("遮罩" if is_mask else "参考图")
if is_mask and "mask" not in label.lower() and "遮罩" not in label:
label = f"遮罩 · {label}"
has_default = isinstance(inputs.get("image"), str) and bool(inputs.get("image"))
slot = _image_slot(nid, workflow, consumers)
if is_mask:
label = title if title and title != ct else "遮罩"
if "mask" not in label.lower() and "遮罩" not in label:
label = f"遮罩 · {label}"
has_default = isinstance(inputs.get("image"), str) and bool(inputs.get("image"))
media.append(
_entry(_key(nid, node, "image"), label, "mask", "image", None, not has_default)
)
continue
if slot is None:
# Nothing consumes it — ComfyUI never runs it, so no upload slot.
continue
role, label = slot
media.append(
_entry(
_key(nid, node, "image"),
label,
"mask" if is_mask else "image",
label or (title if title and title != ct else "参考图"),
role,
"image",
None,
not has_default,
True,
)
)
continue
@@ -346,6 +523,24 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
texts.append((nid, node, neg_field, True))
elif field:
texts.append((nid, node, field, _is_negative(nid, lowered, consumers)))
edits_image = any(
k.startswith("images.") and isinstance(v, list) for k, v in inputs.items()
)
if ct in _RESOLUTION_ENCODERS and edits_image and "resolution" not in taken_roles:
value = inputs.get("resolution")
if _is_scalar(value) and not isinstance(value, str):
taken_roles.add("resolution")
numbers.append(
_entry(
_key(nid, node, "resolution"),
"输出分辨率",
"resolution",
"number",
value,
False,
**_RESOLUTION_ENCODERS[ct],
)
)
continue
if ct in _SAMPLER_CLASSES:
@@ -391,6 +586,9 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
add_number(nid, node, "cfg", "cfg", "CFG")
continue
if ct.startswith("Empty") and "Latent" in ct:
if nid not in consumers:
# Orphan latent (Qwen edit sizes from its encoder): editing it changes nothing.
continue
add_number(nid, node, "width", "width", "宽度")
add_number(nid, node, "height", "height", "高度")
add_number(nid, node, "batch_size", "batch", "批次数量")
@@ -462,6 +660,7 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
)
results.extend(numbers)
results.extend(media)
results.extend(groups)
return results
@@ -496,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),
}
@@ -5,7 +5,7 @@ from __future__ import annotations
import copy
from typing import Any
from calliope.comfyui.registry import class_to_patch_field
from calliope.comfyui.registry import IMAGE_CLASSES, class_to_patch_field
# Input keys of the form "<nodeId>@<widget>" target one named widget on a
# node (auto-detected inputs: KSampler seed/steps/cfg, latent width/height…).
@@ -38,6 +38,70 @@ def resolve_patch_field(node: dict[str, Any]) -> str:
)
def _next_node_id(workflow: dict[str, Any]) -> str:
numeric = [int(k) for k in workflow if str(k).isdigit()]
return str(max(numeric, default=0) + 1)
def apply_image_group(
workflow: dict[str, Any], node_id: str, group: str, paths: list[str]
) -> None:
"""Rewire an Autogrow image group to exactly ``paths``, in order.
``<group>.<prefix>0 … N-1`` each get their own LoadImage: the workflow's
existing ones are reused in slot order, extra ones are clones of the
first, and slots/loaders left over from the export are removed so no
stale image reaches ComfyUI.
"""
from calliope.comfyui.parser import image_group_prefix, image_group_slots
node = workflow.get(node_id)
if not isinstance(node, dict):
return
prefix = image_group_prefix(node, group)
if prefix is None:
return
label = "参考图"
inputs = dict(node.get("inputs") or {})
slots = image_group_slots(node, group, prefix)
loaders: list[str] = []
for _n, name, link in slots:
inputs.pop(name, None)
if isinstance(link, list) and len(link) == 2:
src = workflow.get(str(link[0]))
if isinstance(src, dict) and src.get("class_type") in IMAGE_CLASSES:
loaders.append(str(link[0]))
if not loaders:
return
template = copy.deepcopy(workflow[loaders[0]])
still_used: set[str] = set()
for nid, other in workflow.items():
if nid == node_id or not isinstance(other, dict):
continue
for val in (other.get("inputs") or {}).values():
if isinstance(val, list) and len(val) == 2:
still_used.add(str(val[0]))
for nid in loaders[len(paths):]:
if nid not in still_used and nid not in loaders[: len(paths)]:
workflow.pop(nid, None)
for i, path in enumerate(paths):
if i < len(loaders):
nid = loaders[i]
loader = workflow[nid]
else:
nid = _next_node_id(workflow)
loader = copy.deepcopy(template)
loader_inputs = dict(loader.get("inputs") or {})
loader_inputs["image"] = path
loader["inputs"] = loader_inputs
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{label} {i + 1}"}
workflow[nid] = loader
inputs[f"{group}.{prefix}{i}"] = [nid, 0]
node["inputs"] = inputs
def patch_workflow(
base: dict[str, Any],
values_by_node_id: dict[str, Any],
@@ -54,6 +118,15 @@ def patch_workflow(
if not isinstance(node, dict):
continue
inputs = dict(node.get("inputs") or {})
if field is not None and field not in inputs and any(
k.startswith(f"{field}.") for k in inputs
):
paths = [value] if isinstance(value, str) else value
if isinstance(paths, list):
clean = [p for p in paths if isinstance(p, str) and p.strip()]
if clean:
apply_image_group(patched, key, field, clean)
continue
if field is None:
field = resolve_patch_field(node)
elif isinstance(inputs.get(field), list):
@@ -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.
@@ -158,8 +158,12 @@ def smart_fill_inputs(
break
if ref_images:
for inp, path in zip(ref_image_slots(inputs), ref_images):
values[str(inp["nodeId"])] = path
group = next((inp for inp in inputs if inp.get("kind") == "image_list"), None)
if group is not None:
values[str(group["nodeId"])] = list(ref_images[: group.get("max") or len(ref_images)])
else:
for inp, path in zip(ref_image_slots(inputs), ref_images):
values[str(inp["nodeId"])] = path
if ref_videos:
for inp, path in zip(ref_video_slots(inputs), ref_videos):
@@ -3,6 +3,7 @@
from __future__ import annotations
import math
from pathlib import Path
from typing import Any
@@ -19,8 +20,9 @@ def validate_input_values(
``min``/``max``); select values must be one of ``options``. Blank values
are dropped so the workflow keeps its own value. Keys not in the schema
pass through untouched (other callers patch extra keys, e.g. uploads).
Only an auto-detected positive prompt must be present — tagged workflows
mark every input required, and blank ones fall back to the workflow's value.
Auto-detected required entries (positive prompt, wired image slots,
reference-image lists) must be present — tagged workflows mark every
input required, and blank ones fall back to the workflow's value.
"""
by_key = {str(e.get("nodeId")): e for e in schema if isinstance(e, dict)}
clean: dict[str, Any] = {}
@@ -61,6 +63,26 @@ def validate_input_values(
errors.append(f"「{label}」不能大于 {_fmt(hi)}")
continue
clean[key] = value
elif kind == "image_list":
paths = [raw] if isinstance(raw, str) else raw
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
errors.append(f"「{label}」的取值格式不对,请重新上传")
continue
paths = [p for p in paths if p.strip()]
hi = entry.get("max")
if hi is not None and len(paths) > hi:
errors.append(f"{label}最多 {hi} 张(当前 {len(paths)} 张),请删除多余的图片")
continue
bad = [
f"{label} {i}:{msg}"
for i, p in enumerate(paths, 1)
if (msg := _image_file_problem(p))
]
if bad:
errors.extend(bad)
continue
if paths:
clean[key] = paths
elif kind == "select":
options = entry.get("options") or []
if options and raw not in options:
@@ -70,8 +92,36 @@ def validate_input_values(
else:
clean[key] = raw
for key, entry in by_key.items():
required_prompt = entry.get("auto") and entry.get("required") and entry.get("role") == "prompt"
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
if required_prompt and key not in clean and not has_fallback:
errors.append(f"请填写「{entry.get('label') or key}」")
required = entry.get("auto") and entry.get("required")
label = entry.get("label") or key
if required and entry.get("role") == "prompt":
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
if key not in clean and not has_fallback:
errors.append(f"请填写「{label}」")
elif required and entry.get("kind") == "image_list":
lo = entry.get("min") or 1
if len(clean.get(key) or []) < lo and not any(label in e for e in errors):
errors.append(f"需要至少 {lo} 张{label}")
elif required and entry.get("kind") == "image" and entry.get("role") != "mask":
if key not in clean:
errors.append(f"请上传{label}")
return clean, errors
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp"})
def _image_file_problem(path: str) -> str | None:
"""Why ``path`` cannot be a studio reference image (None = fine)."""
from calliope import config
target = Path(path)
if target.suffix.lower() not in _IMAGE_EXTS:
return "格式不支持,请使用 PNG / JPG / WebP"
try:
target.resolve().relative_to(config.settings.assets_dir.resolve())
except (ValueError, OSError):
return "只能使用在本页上传或从生成结果选入的图片"
if not target.is_file():
return "文件已不存在,请重新上传"
return None
+41
View File
@@ -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):
@@ -13,6 +13,7 @@ import httpx
from calliope import config
from calliope.comfyui.client import ComfyUIClient
from calliope.comfyui.dry_run import write_placeholder_mp4, write_placeholder_png
from calliope.comfyui.lint import blocking_problem
from calliope.comfyui.parser import parse_dynamic_inputs
from calliope.comfyui.patcher import patch_workflow
from calliope.comfyui.roles import input_has_role
@@ -200,6 +201,9 @@ class QueueWorker:
raise RuntimeError(
"找不到该任务使用的工作流(可能已被删除),请重新选择工作流后再生成"
)
broken = blocking_problem(workflow)
if broken:
raise RuntimeError(broken)
input_values = payload.get("input_values") or {}
if payload.get("continue_source") and kind == "video":
@@ -13,6 +13,7 @@ from pydantic import BaseModel, Field
from calliope import config
from calliope.agent.prompts import item_reference_prompt
from calliope.comfyui.lint import blocking_problem
from calliope.comfyui.parser import effective_input_schema
from calliope.comfyui.validate import validate_input_values
from calliope.db import get_db, row_to_dict
@@ -346,13 +347,17 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
status_code=400, detail="该工作流已停用,请到「ComfyUI 连接」页启用后再生成"
)
kind = wf["kind"]
workflow_json = json.loads(wf["workflow_json"])
schema = effective_input_schema(
json.loads(wf["input_schema"]) if wf["input_schema"] else [],
json.loads(wf["workflow_json"]),
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
)
finally:
conn.close()
broken = blocking_problem(workflow_json)
if broken:
raise HTTPException(status_code=400, detail=broken)
input_values, problems = validate_input_values(schema, payload.input_values)
if problems:
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
@@ -5,12 +5,15 @@ from typing import Any
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
@@ -25,6 +28,7 @@ def _serialize_workflow(row: Any) -> dict[str, Any]:
data["input_schema"] = effective_input_schema(stored, data["workflow_json"])
data["output_schema"] = json.loads(data["output_schema"]) if data.get("output_schema") else []
data["is_enabled"] = bool(data.get("is_enabled"))
data["warnings"] = workflow_warnings(data["workflow_json"])
return data
@@ -32,19 +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:
@@ -53,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(
@@ -115,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:
@@ -124,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()
@@ -178,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}
@@ -0,0 +1,322 @@
{
"1": {
"inputs": {
"value": "PASTE YOUR PROMPT HERE. For image-to-video describe the motion from the first frame; the frame fixes the look. Use the MiniMax-H3 prompt contract (integrated_multimodal_description / overall_soundscape / non_diegetic_music)."
},
"class_type": "PrimitiveStringMultiline",
"_meta": {
"title": "PROMPT (feeds every stage)"
}
},
"92": {
"inputs": {
"filename_prefix": "video/04_i2v_fl2v_4step_sla",
"format": "auto",
"format.codec": "auto",
"codec": "auto",
"video": [
"130",
0
]
},
"class_type": "SaveVideo",
"_meta": {
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
}
},
"115": {
"inputs": {
"aspect_ratio": "16:9 (Widescreen)",
"megapixels": 0.7,
"multiple": 32
},
"class_type": "ResolutionSelector",
"_meta": {
"title": "ResolutionSelector"
}
},
"116": {
"inputs": {
"expression": "a"
},
"class_type": "ComfyMathExpression",
"_meta": {
"title": "ComfyMathExpression"
}
},
"119": {
"inputs": {
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "VAELoader"
}
},
"120": {
"inputs": {
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "VAELoader"
}
},
"121": {
"inputs": {
"samples": [
"125",
0
],
"vae": [
"120",
0
]
},
"class_type": "VAEDecodeAudio",
"_meta": {
"title": "VAEDecodeAudio"
}
},
"122": {
"inputs": {
"samples": [
"125",
0
],
"vae": [
"119",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAEDecode"
}
},
"124": {
"inputs": {
"scheduler": "simple",
"steps": 4,
"denoise": 1,
"model": [
"142",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"125": {
"inputs": {
"noise": [
"129",
0
],
"guider": [
"126",
0
],
"sampler": [
"135",
0
],
"sigmas": [
"124",
0
],
"latent_image": [
"131",
1
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"126": {
"inputs": {
"model": [
"142",
0
],
"conditioning": [
"131",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "BasicGuider"
}
},
"127": {
"inputs": {
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "UNETLoader"
}
},
"128": {
"inputs": {
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
"type": "minimax",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "CLIPLoader"
}
},
"129": {
"inputs": {
"noise_seed": 42
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"130": {
"inputs": {
"fps": 24,
"bit_depth": 8,
"color_space": "sRGB",
"codec": "none",
"images": [
"122",
0
],
"audio": [
"121",
0
]
},
"class_type": "CreateVideo",
"_meta": {
"title": "CreateVideo"
}
},
"131": {
"inputs": {
"prompt": [
"1",
0
],
"width": [
"115",
0
],
"height": [
"115",
1
],
"length": [
"132",
1
],
"clip": [
"128",
0
],
"vae": [
"119",
0
],
"first_frame": [
"901",
0
]
},
"class_type": "MiniMaxH3ImageToVideo",
"_meta": {
"title": "MiniMaxH3ImageToVideo"
}
},
"132": {
"inputs": {
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
"values.a": [
"133",
0
]
},
"class_type": "ComfyMathExpression",
"_meta": {
"title": "ComfyMathExpression"
}
},
"133": {
"inputs": {
"value": 10
},
"class_type": "PrimitiveFloat",
"_meta": {
"title": "PrimitiveFloat"
}
},
"135": {
"inputs": {
"sampler_name": "res_multistep"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"142": {
"inputs": {
"shift_video": 12,
"shift_audio": 3,
"model": [
"900",
0
]
},
"class_type": "MiniMaxH3SigmaShift",
"_meta": {
"title": "MiniMaxH3SigmaShift"
}
},
"900": {
"inputs": {
"sparsity_ratio": 0.9,
"block_size": "64",
"min_seq_len": 8192,
"dense_last_steps": 0,
"protect_audio": true,
"enabled": true,
"dense_steps": "1",
"dense_backend": "comfy_kitchen",
"disable_fp16_accum": true,
"stabilize_motion": false,
"reference_protection": "Off",
"tail_correction": false,
"use_int8_qk": true,
"engine": "comfy_kitchen",
"model": [
"127",
0
]
},
"class_type": "H3SLAAttention",
"_meta": {
"title": "H3 SLA Attention 0.9/64 (their graph)"
}
},
"901": {
"inputs": {
"image": "first_frame.png"
},
"class_type": "LoadImage",
"_meta": {
"title": "FIRST FRAME (add a second LoadImage into last_frame for FL2V)"
}
}
}
@@ -0,0 +1,327 @@
{
"1": {
"inputs": {
"value": "PASTE YOUR PROMPT HERE, in the reference contract:\nsubject_definitions:\n<Subject 1> is the person in <Picture 1>; their face, hair and clothing follow <Picture 1>.\n\nsummary:\n[reference generation] one continuous shot of <Subject 1> doing X, appearance taken from <Picture 1>.\n\nretention_analysis:\n<Subject 1> is fully preserved from <Picture 1>.\n\ndetailed_description:\n[Shot 1] ... camera, action, timing ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: N/A"
},
"class_type": "PrimitiveStringMultiline",
"_meta": {
"title": "PROMPT (feeds every stage)"
}
},
"92": {
"inputs": {
"filename_prefix": "video/05_ref2va_4step_sla",
"format": "auto",
"format.codec": "auto",
"codec": "auto",
"video": [
"130",
0
]
},
"class_type": "SaveVideo",
"_meta": {
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
}
},
"115": {
"inputs": {
"aspect_ratio": "16:9 (Widescreen)",
"megapixels": 0.7,
"multiple": 32
},
"class_type": "ResolutionSelector",
"_meta": {
"title": "ResolutionSelector"
}
},
"116": {
"inputs": {
"expression": "a"
},
"class_type": "ComfyMathExpression",
"_meta": {
"title": "ComfyMathExpression"
}
},
"119": {
"inputs": {
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "VAELoader"
}
},
"120": {
"inputs": {
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "VAELoader"
}
},
"121": {
"inputs": {
"samples": [
"125",
0
],
"vae": [
"120",
0
]
},
"class_type": "VAEDecodeAudio",
"_meta": {
"title": "VAEDecodeAudio"
}
},
"122": {
"inputs": {
"samples": [
"125",
0
],
"vae": [
"119",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAEDecode"
}
},
"124": {
"inputs": {
"scheduler": "simple",
"steps": 4,
"denoise": 1,
"model": [
"142",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"125": {
"inputs": {
"noise": [
"129",
0
],
"guider": [
"126",
0
],
"sampler": [
"135",
0
],
"sigmas": [
"124",
0
],
"latent_image": [
"131",
1
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"126": {
"inputs": {
"model": [
"142",
0
],
"conditioning": [
"131",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "BasicGuider"
}
},
"127": {
"inputs": {
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "UNETLoader"
}
},
"128": {
"inputs": {
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
"type": "minimax",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "CLIPLoader"
}
},
"129": {
"inputs": {
"noise_seed": 42
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"130": {
"inputs": {
"fps": 24,
"bit_depth": 8,
"color_space": "sRGB",
"codec": "none",
"images": [
"122",
0
],
"audio": [
"121",
0
]
},
"class_type": "CreateVideo",
"_meta": {
"title": "CreateVideo"
}
},
"131": {
"inputs": {
"prompt": [
"1",
0
],
"width": [
"115",
0
],
"height": [
"115",
1
],
"length": [
"132",
1
],
"ref_image_size": "match",
"clip": [
"128",
0
],
"vae": [
"119",
0
],
"audio_vae": [
"120",
0
],
"ref_images.ref_image_0": [
"901",
0
]
},
"class_type": "MiniMaxH3ReferenceToVideo",
"_meta": {
"title": "MiniMaxH3ReferenceToVideo"
}
},
"132": {
"inputs": {
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
"values.a": [
"133",
0
]
},
"class_type": "ComfyMathExpression",
"_meta": {
"title": "ComfyMathExpression"
}
},
"133": {
"inputs": {
"value": 10
},
"class_type": "PrimitiveFloat",
"_meta": {
"title": "PrimitiveFloat"
}
},
"135": {
"inputs": {
"sampler_name": "res_multistep"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"142": {
"inputs": {
"shift_video": 12,
"shift_audio": 3,
"model": [
"900",
0
]
},
"class_type": "MiniMaxH3SigmaShift",
"_meta": {
"title": "MiniMaxH3SigmaShift"
}
},
"900": {
"inputs": {
"sparsity_ratio": 0.9,
"block_size": "64",
"min_seq_len": 8192,
"dense_last_steps": 0,
"protect_audio": true,
"enabled": true,
"dense_steps": "1",
"dense_backend": "comfy_kitchen",
"disable_fp16_accum": true,
"stabilize_motion": false,
"reference_protection": "Off",
"tail_correction": false,
"use_int8_qk": true,
"engine": "comfy_kitchen",
"model": [
"127",
0
]
},
"class_type": "H3SLAAttention",
"_meta": {
"title": "H3 SLA Attention 0.9/64 (their graph)"
}
},
"901": {
"inputs": {
"image": "reference_1.png"
},
"class_type": "LoadImage",
"_meta": {
"title": "REFERENCE IMAGE 1 (<Picture 1>); the node grows more image / video / audio slots"
}
}
}
@@ -0,0 +1,136 @@
{
"1": {
"inputs": {
"UNKNOWN": "qwen-image-2.1-Q4_K_M.gguf"
},
"_meta": {
"title": "Unet Loader (GGUF)"
}
},
"2": {
"inputs": {
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "加载CLIP"
}
},
"3": {
"inputs": {
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "加载VAE"
}
},
"4": {
"inputs": {
"prompt": "参考角色五官,重新生成图片:高细节皮肤质感",
"negative_prompt": "模糊,低质量,文字乱码,变形",
"resolution": 1024,
"clip": [
"2",
0
],
"images.image_1": [
"10",
0
],
"vae": [
"3",
0
]
},
"class_type": "TextEncodeQwenImage21",
"_meta": {
"title": "Text Encode Qwen Image 2.1"
}
},
"5": {
"inputs": {
"width": 1024,
"height": 1024,
"batch_size": 1
},
"class_type": "EmptyLatentImage",
"_meta": {
"title": "空Latent图像"
}
},
"6": {
"inputs": {
"seed": 278835418249884,
"steps": 25,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1,
"positive": [
"4",
0
],
"negative": [
"4",
1
],
"latent_image": [
"4",
2
]
},
"class_type": "KSampler",
"_meta": {
"title": "K采样器"
}
},
"7": {
"inputs": {
"samples": [
"6",
0
],
"vae": [
"3",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE解码"
}
},
"8": {
"inputs": {
"filename_prefix": "qwen_image",
"images": [
"7",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "保存图像"
}
},
"9": {
"inputs": {
"UNKNOWN": "auto"
},
"_meta": {
"title": "Patch Sage Attention KJ"
}
},
"10": {
"inputs": {
"image": "3f24a075cd7b4118829be1b222ca1ab9.png"
},
"class_type": "LoadImage",
"_meta": {
"title": "加载图像"
}
}
}
@@ -381,10 +381,21 @@ def test_comfy_test_requires_url(client):
def _import_workflow(client, name="H3 视频", kind="video", endpoint_id=None):
wf_json = {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "model.safetensors"},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "placeholder prompt", "clip": ["4", 1]},
},
# HEL-636: a workflow needs a recognizable output node to classify a
# usage and stay enabled; without it generation is refused.
"9": {
"class_type": "VHS_VideoCombine",
"inputs": {"images": ["6", 0]},
"_meta": {"title": "Video Output (Output:video)"},
},
}
payload = {"name": name, "kind": kind, "workflow_json": wf_json}
if endpoint_id is not None:
@@ -0,0 +1,477 @@
"""HEL-633: the three image-input workflows the user supplied — Qwen Image 2.1
图片编辑, MiniMax H3 首帧图生视频 (04) and H3 参考图生视频 (05, 1–9 refs).
Slots come from real wiring, every picked image is uploaded to ComfyUI and
lands in the node input it belongs to (in order, nothing stale), and a
broken export is refused before anything is queued."""
from __future__ import annotations
import json
import re
import threading
from http.server import ThreadingHTTPServer
from pathlib import Path
import pytest
from calliope.comfyui.lint import blocking_problem, workflow_warnings
from calliope.comfyui.parser import detect_untagged_inputs, parse_dynamic_inputs
from calliope.comfyui.patcher import patch_workflow
from calliope.comfyui.smart_fill import smart_fill_inputs
from calliope.comfyui.validate import validate_input_values
from calliope.config import settings
from tests.test_studio_generation import (
PNG_1PX,
_FakeComfyHandler,
_import_workflow,
_real_run,
_wait_done,
)
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
I2V = "h3_i2v_04_API.json" # 04_i2v_fl2v_4step_sla (1).json
REF = "h3_ref2va_05_API.json" # 05_ref2va_4step_sla (1).json
QWEN_EDIT = "qwen21_edit_sage_API.json" # qwen-image-2.1-图片编辑-sage加速.json
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
REF_KEY = "131@ref_images"
def _load(name: str) -> dict:
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
def _by_node(inputs: list[dict]) -> dict[str, dict]:
return {i["nodeId"]: i for i in inputs}
def _ref_links(wf: dict, node_id: str = "131") -> list[tuple[str, str]]:
"""[(input name, LoadImage image value), …] in slot order."""
inputs = wf[node_id]["inputs"]
names = sorted(
(k for k in inputs if k.startswith("ref_images.ref_image_")),
key=lambda k: int(k.rsplit("_", 1)[1]),
)
return [(k, wf[inputs[k][0]]["inputs"]["image"]) for k in names]
# ---------------------------------------------------------------------------
# Slot detection
# ---------------------------------------------------------------------------
def test_i2v_exposes_one_required_first_frame_and_no_last_frame():
inputs = parse_dynamic_inputs(_load(I2V))
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
assert media == [
{
"nodeId": "901",
"label": "首帧",
"role": "first_frame",
"kind": "image",
"defaultValue": None,
"required": True,
"auto": True,
}
]
# the title comment about FL2V is not a slot
assert not any(i.get("role") == "last_frame" for i in inputs)
def test_ref2v_exposes_one_group_of_one_to_nine_reference_images():
inputs = parse_dynamic_inputs(_load(REF))
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
assert len(media) == 1
group = media[0]
assert group["nodeId"] == REF_KEY
assert group["kind"] == "image_list"
assert group["role"] == "reference_image"
assert group["label"] == "参考图"
assert (group["min"], group["max"], group["required"]) == (1, 9, True)
# the parameters HEL-627 wired stay exposed
by_node = _by_node(inputs)
for key in ("1", "115@aspect_ratio", "115@megapixels", "124@steps", "124@denoise",
"129@noise_seed", "133"):
assert key in by_node, key
assert not any(i["kind"] == "video" for i in inputs)
def test_example_five_ref_workflow_folds_into_the_same_group():
# The repo example is (Input:…)-tagged and keeps its tagged slots; stripped
# of tags (as a plain ComfyUI export) its five loaders become one group.
wf = json.loads((EXAMPLES / "video_minimax_h3_r2v_5ref_API.json").read_text(encoding="utf-8"))
inputs = detect_untagged_inputs(wf)
groups = [i for i in inputs if i["kind"] == "image_list"]
assert [g["nodeId"] for g in groups] == ["178@ref_images"]
# its five LoadImages are the group, not five separate slots
assert not any(i["kind"] == "image" for i in inputs if i.get("role") != "mask")
def test_qwen_edit_exposes_input_image_real_resolution_and_hides_orphan_latent():
inputs = parse_dynamic_inputs(_load(QWEN_EDIT))
by_node = _by_node(inputs)
img = by_node["10"]
assert (img["kind"], img["role"], img["label"]) == ("image", "image", "输入图")
assert img["required"] is True
res = by_node["4@resolution"]
assert (res["role"], res["defaultValue"], res["min"], res["max"], res["step"]) == (
"resolution", 1024, 0, 4096, 32,
)
assert by_node["4@prompt"]["role"] == "prompt"
assert by_node["4@negative_prompt"]["role"] == "negative"
for key in ("6@seed", "6@steps", "6@cfg", "6@denoise"):
assert key in by_node
# EmptyLatentImage #5 feeds nothing — its width/height would do nothing
assert not any(i["nodeId"].startswith("5@") for i in inputs)
def test_unwired_load_image_gets_no_slot():
wf = _load(I2V)
wf["950"] = {
"class_type": "LoadImage",
"inputs": {"image": "last.png"},
"_meta": {"title": "LAST"},
}
assert "950" not in _by_node(parse_dynamic_inputs(wf))
# ---------------------------------------------------------------------------
# Broken exports
# ---------------------------------------------------------------------------
def test_broken_qwen_export_is_flagged_and_blocked():
warnings = workflow_warnings(_load(QWEN_EDIT))
errors = {(w["code"], w["node_id"]) for w in warnings if w["level"] == "error"}
assert errors == {
("missing_class_type", "1"),
("missing_class_type", "9"),
("missing_model_link", "6"),
}
assert ("orphan_latent", "5") in {(w["code"], w["node_id"]) for w in warnings}
msg = blocking_problem(_load(QWEN_EDIT))
assert msg and "Unet Loader (GGUF)" in msg and "重新导出" in msg
def test_h3_workflows_are_clean():
assert workflow_warnings(_load(I2V)) == []
assert workflow_warnings(_load(REF)) == []
# ---------------------------------------------------------------------------
# Patching 1 / 4 / 9 reference images
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("count", [1, 4, 9])
def test_patch_wires_each_reference_image_in_order(count):
base = _load(REF)
paths = [f"/assets/uploads/ref-{i}.png" for i in range(1, count + 1)]
wf = patch_workflow(base, {REF_KEY: paths})
links = _ref_links(wf)
assert links == [(f"ref_images.ref_image_{i}", p) for i, p in enumerate(paths)]
# every loader is a real, distinct LoadImage feeding node 131
loader_ids = [wf["131"]["inputs"][name][0] for name, _ in links]
assert len(set(loader_ids)) == count
assert all(wf[n]["class_type"] == "LoadImage" for n in loader_ids)
assert loader_ids[0] == "901"
# the base workflow is untouched
assert _ref_links(base) == [("ref_images.ref_image_0", "reference_1.png")]
def test_patch_shrinking_removes_stale_slots_and_loaders():
nine = patch_workflow(_load(REF), {REF_KEY: [f"/u/{i}.png" for i in range(9)]})
two = patch_workflow(nine, {REF_KEY: ["/u/b.png", "/u/a.png"]})
assert _ref_links(two) == [
("ref_images.ref_image_0", "/u/b.png"),
("ref_images.ref_image_1", "/u/a.png"),
]
load_images = [n for n, v in two.items() if v.get("class_type") == "LoadImage"]
assert len(load_images) == 2
assert not any("/u/8.png" in json.dumps(v) for v in two.values())
def test_single_slot_values_patch_first_frame_and_qwen_input():
i2v = patch_workflow(_load(I2V), {"901": "/assets/uploads/first.png"})
assert i2v["901"]["inputs"]["image"] == "/assets/uploads/first.png"
assert i2v["131"]["inputs"]["first_frame"] == ["901", 0]
qwen = patch_workflow(_load(QWEN_EDIT), {"10": "/assets/uploads/in.png", "4@resolution": 1536})
assert qwen["10"]["inputs"]["image"] == "/assets/uploads/in.png"
assert qwen["4"]["inputs"]["resolution"] == 1536
def test_legacy_job_values_keyed_by_load_image_still_patch():
"""Jobs queued before HEL-633 carry the LoadImage id, not the group key."""
wf = patch_workflow(_load(REF), {"901": "/assets/uploads/old.png"})
assert _ref_links(wf) == [("ref_images.ref_image_0", "/assets/uploads/old.png")]
def test_smart_fill_hands_reference_list_to_the_group():
inputs = parse_dynamic_inputs(_load(REF))
refs = [f"/r/{i}.png" for i in range(12)]
values = smart_fill_inputs(inputs, prompt="p", ref_images=refs)
assert values[REF_KEY] == refs[:9]
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
@pytest.fixture
def uploads_dir(client):
d = settings.assets_dir / "uploads"
d.mkdir(parents=True, exist_ok=True)
return d
def _pngs(d: Path, n: int) -> list[str]:
out = []
for i in range(n):
p = d / f"ref{i + 1}.png"
p.write_bytes(PNG_1PX)
out.append(str(p))
return out
def test_validate_reference_count_limits(uploads_dir):
schema = parse_dynamic_inputs(_load(REF))
base = {"1": "prompt"}
for n in (1, 4, 9):
clean, errs = validate_input_values(schema, {**base, REF_KEY: _pngs(uploads_dir, n)})
assert errs == [] and len(clean[REF_KEY]) == n
_, errs = validate_input_values(schema, {**base, REF_KEY: _pngs(uploads_dir, 10)})
assert errs == ["参考图最多 9 张(当前 10 张),请删除多余的图片"]
_, errs = validate_input_values(schema, base)
assert errs == ["需要至少 1 张参考图"]
_, errs = validate_input_values(schema, {**base, REF_KEY: []})
assert errs == ["需要至少 1 张参考图"]
def test_validate_rejects_bad_reference_files(uploads_dir):
schema = parse_dynamic_inputs(_load(REF))
gif = uploads_dir / "anim.gif"
gif.write_bytes(b"GIF89a")
ok = _pngs(uploads_dir, 1)[0]
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: [ok, str(gif)]})
assert errs == ["参考图 2:格式不支持,请使用 PNG / JPG / WebP"]
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: ["/etc/passwd.png"]})
assert errs == ["参考图 1:只能使用在本页上传或从生成结果选入的图片"]
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: [str(uploads_dir / "gone.png")]})
assert errs == ["参考图 1:文件已不存在,请重新上传"]
def test_validate_requires_first_frame_and_input_image():
_, errs = validate_input_values(parse_dynamic_inputs(_load(I2V)), {"1": "p"})
assert errs == ["请上传首帧"]
_, errs = validate_input_values(parse_dynamic_inputs(_load(QWEN_EDIT)), {"4@prompt": "p"})
assert errs == ["请上传输入图"]
# ---------------------------------------------------------------------------
# Full chain against a fake ComfyUI
# ---------------------------------------------------------------------------
class _NamedUploadHandler(_FakeComfyHandler):
"""/upload/image answers with the uploaded file's own name, like ComfyUI."""
def do_POST(self): # noqa: N802 — BaseHTTPRequestHandler hook name
if self.path != "/upload/image":
super().do_POST()
return
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""
self.server.requests.append(self.path)
self.server.uploads.append(body)
m = re.search(rb'filename="([^"]+)"', body)
name = m.group(1).decode() if m else "x.png"
self.server.upload_names.append(name)
self._json(200, {"name": name, "subfolder": "calliope", "type": "input"})
@pytest.fixture
def named_comfy():
server = ThreadingHTTPServer(("127.0.0.1", 0), _NamedUploadHandler)
server.requests = []
server.prompts = []
server.uploads = []
server.upload_names = []
server.history = {}
server.fail_prompt = False
server.out_name = "ref2v_00001_.mp4"
server.outputs_key = "images"
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
def _upload(client, name: str) -> str:
r = client.post("/api/playground/uploads", files={"file": (name, PNG_1PX, "image/png")})
assert r.status_code == 200, r.text
return r.json()["path"]
def _generate(client, wf_id: int, values: dict, source: str = "studio-video"):
return client.post(
"/api/playground/generate",
json={
"workflow_id": wf_id,
"input_values": values,
"source": source,
"endpoint_id": settings.comfyui_active_id,
},
)
@pytest.mark.parametrize("count", [1, 4, 9])
def test_ref2v_chain_uploads_every_image_and_wires_it_in_order(client, named_comfy, count):
with _real_run(client, named_comfy):
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
paths = [_upload(client, f"角色{i}.png") for i in range(1, count + 1)]
r = _generate(
client,
wf_id,
{"1": "<Subject 1> 走进雨夜码头", REF_KEY: paths, "124@steps": 4, "133": 2},
)
assert r.status_code == 200, r.text
job = _wait_done(client, r.json()["job"]["id"])
assert job["status"] == "done", job.get("error")
assert job["payload"]["input_values"][REF_KEY] == paths
assert len(named_comfy.prompts) == 1
prompt = named_comfy.prompts[0]["prompt"]
expected = [f"calliope/{Path(p).name}" for p in paths]
assert [img for _k, img in _ref_links(prompt)] == expected
assert named_comfy.upload_names == [Path(p).name for p in paths]
assert prompt["1"]["inputs"]["value"] == "<Subject 1> 走进雨夜码头"
assert prompt["124"]["inputs"]["steps"] == 4
assert prompt["133"]["inputs"]["value"] == 2
# no leftover default reference and no unconsumed LoadImage
assert "reference_1.png" not in json.dumps(prompt)
loaders = {n for n, v in prompt.items() if v["class_type"] == "LoadImage"}
wired = {v[0] for k, v in prompt["131"]["inputs"].items() if k.startswith("ref_images.")}
assert loaders == wired
def test_ref2v_replace_and_delete_leave_no_stale_image(client, named_comfy):
with _real_run(client, named_comfy):
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
a, b, c = (_upload(client, n) for n in ("a.png", "b.png", "c.png"))
r = _generate(client, wf_id, {"1": "x", REF_KEY: [a, b, c]})
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
# user replaced #2 with d and deleted #3
d = _upload(client, "d.png")
r = _generate(client, wf_id, {"1": "x", REF_KEY: [a, d]})
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
second = named_comfy.prompts[1]["prompt"]
assert [img for _k, img in _ref_links(second)] == [
f"calliope/{Path(a).name}",
f"calliope/{Path(d).name}",
]
dumped = json.dumps(second)
assert Path(b).name not in dumped and Path(c).name not in dumped
def test_ref2v_ten_images_rejected_before_queue(client, named_comfy):
with _real_run(client, named_comfy):
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
paths = [_upload(client, f"r{i}.png") for i in range(10)]
r = _generate(client, wf_id, {"1": "x", REF_KEY: paths})
assert r.status_code == 400
assert "参考图最多 9 张" in r.json()["detail"]
r = _generate(client, wf_id, {"1": "x"})
assert r.status_code == 400
assert "需要至少 1 张参考图" in r.json()["detail"]
assert named_comfy.prompts == [] and named_comfy.upload_names == []
def test_i2v_chain_uploads_first_frame(client, named_comfy):
with _real_run(client, named_comfy):
wf_id = _import_workflow(client, "H3 图生视频", "video", _load(I2V))
r = _generate(client, wf_id, {"1": "镜头缓慢推近"})
assert r.status_code == 400 and "请上传首帧" in r.json()["detail"]
first = _upload(client, "首帧.png")
r = _generate(client, wf_id, {"1": "镜头缓慢推近", "901": first, "124@denoise": 0.8})
assert r.status_code == 200, r.text
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
prompt = named_comfy.prompts[0]["prompt"]
assert prompt["901"]["inputs"]["image"] == f"calliope/{Path(first).name}"
assert prompt["131"]["inputs"]["first_frame"] == ["901", 0]
assert prompt["124"]["inputs"]["denoise"] == 0.8
def test_broken_qwen_import_warns_and_generation_is_refused(client, named_comfy):
with _real_run(client, named_comfy):
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": _load(QWEN_EDIT)})
assert analyzed.status_code == 200
codes = {w["code"] for w in analyzed.json()["warnings"]}
assert {"missing_class_type", "missing_model_link", "orphan_latent"} <= codes
# importing is allowed (the user may fix it later) …
wf_id = _import_workflow(client, "Qwen 图片编辑", "image", _load(QWEN_EDIT))
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
assert any(w["level"] == "error" for w in listed["warnings"])
# … but it never reaches ComfyUI and never shows as done
img = _upload(client, "门面.png")
r = _generate(client, wf_id, {"4@prompt": "换招牌", "10": img}, source="studio-image")
assert r.status_code == 400
assert "重新导出" in r.json()["detail"]
assert client.get("/api/playground/jobs").json() == []
assert named_comfy.prompts == [] and named_comfy.upload_names == []
def test_repaired_qwen_export_runs_with_its_input_image(client, named_comfy):
wf = _load(QWEN_EDIT)
wf["1"] = {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "qwen_image_2.1_int8_convrot.safetensors",
"weight_dtype": "default",
},
}
del wf["9"]
wf["6"]["inputs"]["model"] = ["1", 0]
with _real_run(client, named_comfy):
named_comfy.out_name = "qwen_image_00001_.png"
wf_id = _import_workflow(client, "Qwen 图片编辑(修复版)", "image", wf)
img = _upload(client, "门面.png")
r = _generate(
client, wf_id, {"4@prompt": "换招牌", "10": img, "4@resolution": 1536, "6@steps": 20},
source="studio-image",
)
assert r.status_code == 200, r.text
job = _wait_done(client, r.json()["job"]["id"])
assert job["status"] == "done" and job["output_paths"]
prompt = named_comfy.prompts[0]["prompt"]
assert prompt["10"]["inputs"]["image"] == f"calliope/{Path(img).name}"
assert prompt["4"]["inputs"]["images.image_1"] == ["10", 0]
assert prompt["4"]["inputs"]["resolution"] == 1536
assert prompt["6"]["inputs"]["steps"] == 20
def test_worker_refuses_broken_workflow_from_other_callers(client, named_comfy):
"""Workbench/agent jobs skip the playground check — the worker still stops them."""
from calliope.db import get_db
from calliope.queue.manager import queue_manager
from calliope.routers.playground import ensure_playground_project
with _real_run(client, named_comfy):
wf_id = _import_workflow(client, "Qwen 图片编辑", "image", _load(QWEN_EDIT))
conn = get_db(settings.db_path)
try:
pid = ensure_playground_project(conn)
finally:
conn.close()
job = queue_manager.enqueue(
project_id=pid, kind="image", workflow_id=wf_id, scene_id=None,
payload={"input_values": {"4@prompt": "x"}, "source": "playground"},
)
done = _wait_done(client, job["id"])
assert done["status"] == "failed"
assert "重新导出" in (done["error"] or "")
assert named_comfy.prompts == []
@@ -58,8 +58,9 @@ def test_native_qwen_edit_detects_image_and_mask_slots():
assert set(media) == {"78", "95"}
assert media["95"]["role"] == "mask"
assert "mask" in media["95"]["label"].lower() or "遮罩" in media["95"]["label"]
# the workflow's own image names stay valid, so the slots are optional
assert not media["78"]["required"] and not media["95"]["required"]
# HEL-633: a wired input image must be uploaded (the export's own file name
# rarely exists on the target ComfyUI); the mask stays optional
assert media["78"]["required"] and not media["95"]["required"]
def test_untagged_h3_video_detects_prompt_duration_seed():
@@ -0,0 +1,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"]
+12 -6
View File
@@ -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}`, {
+26
View File
@@ -0,0 +1,26 @@
import { t } from '$lib/i18n.svelte';
/** Studio image slots (ref-upload-spec §3): PNG / JPG / WebP, ≤ 20MB each. */
export const IMAGE_SLOT_ACCEPT = '.png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp';
export const IMAGE_SLOT_MAX_BYTES = 20 * 1024 * 1024;
const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
/** Why this file cannot fill an image slot (null = fine). */
export function imageFileProblem(file: { name: string; size: number }): string | null {
const dot = file.name.lastIndexOf('.');
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : '';
if (!IMAGE_EXTS.includes(ext)) return t('studio.errImageFormat', { name: file.name });
if (file.size > IMAGE_SLOT_MAX_BYTES) return t('studio.errImageSize', { name: file.name });
return null;
}
export function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
/** Display name of an uploaded path (strips the uuid8- prefix the upload endpoint writes). */
export function uploadBaseName(p: string): string {
const raw = p.split(/[/\\]/).pop() ?? p;
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
}
@@ -44,6 +44,7 @@ export function compactInputValues(
for (const [k, v] of Object.entries(values)) {
if (v === null || v === undefined) continue;
if (typeof v === 'string' && !v.trim()) continue;
if (Array.isArray(v) && !v.length) continue;
out[k] = v;
}
return out;
+60 -2
View File
@@ -6,9 +6,19 @@ export type ComfyInputKind =
| 'image'
| 'image_url'
| 'audio'
| 'video';
| 'video'
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
| 'image_list';
export type ComfyOutputKind = 'image' | 'video' | 'other';
/** Structural finding on an imported workflow; `error` blocks generation. */
export interface WorkflowWarning {
level: 'error' | 'warning';
code: string;
node_id: string;
message: string;
}
export interface ComfyDynamicInput {
nodeId: string;
label: string;
@@ -33,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 {
@@ -48,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[];
@@ -56,6 +113,7 @@ export interface Workflow {
is_enabled: boolean;
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
endpoint_id?: string | null;
warnings?: WorkflowWarning[];
}
export interface Job {
@@ -2,22 +2,31 @@
import Icon from '$lib/components/ui/Icon.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import { assetUrl } from '$lib/api';
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
import { t } from '$lib/i18n.svelte';
/**
* A-style media upload slot (spec-v2 §5 上传槽): empty = 1.5px dashed
* border + "click or drop", drag-hover = amber dashed, filled = solid
* border + thumbnail + mono name + ✕, error = red border + red reason.
* A-style media upload slot (spec-v2 §5 上传槽, ref-upload-spec §2):
* empty = 1.5px dashed border + "click or drop" + format line, drag-hover
* = amber dashed, filled = solid border + thumb + name + size + 替换 + ✕,
* error = red border + red reason.
*/
interface Props {
label: string;
/** Extra hint under the label, e.g. 可从生图结果送入. */
/** Hint on the right of the label, e.g. 必填 · 1 张 / 可从生图结果送入. */
hint?: string;
required?: boolean;
/** Current value: a server-side path ('' = empty). */
value: string;
kind: 'image' | 'video' | 'audio';
/** Thumb aspect: wide = 16:9 (first/last frame slots). */
aspect?: 'square' | 'wide';
/** Empty-state main line (defaults to 点击或拖入). */
emptyText?: string;
/** Empty-state mono line, e.g. accepted formats and size limit. */
emptySub?: string;
/** Byte size of the current file when known. */
size?: number;
uploading?: boolean;
uploadName?: string;
error?: string;
@@ -31,9 +40,13 @@
let {
label,
hint,
required = false,
value,
kind,
aspect = 'square',
emptyText,
emptySub,
size,
uploading = false,
uploadName,
error,
@@ -46,21 +59,24 @@
let fileInput = $state<HTMLInputElement | null>(null);
let dragDepth = $state(0);
let broken = $state(false);
let dims = $state('');
$effect(() => {
void value;
broken = false;
dims = '';
});
const meta = $derived([dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '));
function accept(): string {
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
if (kind === 'audio') return 'audio/*';
return 'image/*';
return IMAGE_SLOT_ACCEPT;
}
function baseName(p: string): string {
const raw = p.split(/[/\\]/).pop() ?? p;
// strip uuid8- prefix the upload endpoint writes
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
function noteDims(e: Event) {
const img = e.currentTarget as HTMLImageElement;
if (img.naturalWidth) dims = `${img.naturalWidth}×${img.naturalHeight}`;
}
function pick() {
@@ -80,7 +96,7 @@
<div class="media-slot" class:wide={aspect === 'wide'}>
<div class="slot-label">
<span class="lbl">{label}</span>
<span class="lbl">{label}{#if required}<em>*</em>{/if}</span>
{#if hint}<span class="slot-hint">{hint}</span>{/if}
</div>
@@ -100,8 +116,10 @@
{#if value && !broken}
<div class="slot-filled" class:invalid={!!error}>
<div class="slot-thumb">
{#if kind === 'image'}
<img src={assetUrl(value)} alt={label} onerror={() => (broken = true)} />
{#if uploading}
<Spinner size="sm" />
{:else if kind === 'image'}
<img src={assetUrl(value)} alt={label} onload={noteDims} onerror={() => (broken = true)} />
{:else if kind === 'video'}
<video src={assetUrl(value)} preload="metadata" muted></video>
<span class="kind-badge" aria-hidden="true"><Icon name="film" size={12} /></span>
@@ -110,22 +128,29 @@
{/if}
</div>
<div class="slot-meta">
<span class="mono name" title={baseName(value)}>{baseName(value)}</span>
<div class="slot-actions">
{#if allowFromResults && onFromResults}
<button type="button" class="mini-link" onclick={onFromResults}>
{t('studio.slotFromResults')}
</button>
{/if}
<button
type="button"
class="slot-clear"
aria-label={t('studio.deleteBtn')}
onclick={onClear}
>
<Icon name="close" size={13} />
<span class="name" title={uploading ? uploadName : uploadBaseName(value)}>
{uploading ? uploadName : uploadBaseName(value)}
</span>
{#if meta && !uploading}<span class="mono dims">{meta}</span>{/if}
</div>
<div class="slot-actions">
{#if allowFromResults && onFromResults}
<button type="button" class="mini-link" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
</button>
</div>
{/if}
<button type="button" class="mini-link" disabled={uploading} onclick={pick}>
{t('studio.refReplace')}
</button>
<button
type="button"
class="slot-clear"
aria-label={t('studio.deleteBtn')}
disabled={uploading}
onclick={onClear}
>
<Icon name="close" size={12} />
</button>
</div>
</div>
{:else}
@@ -151,7 +176,8 @@
<span class="pick-text">{uploadName ?? t('studio.slotPick')}</span>
{:else}
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'image'} size={16} />
<span class="pick-text">{t('studio.slotPick')}</span>
<span class="pick-text">{emptyText ?? t('studio.slotPick')}</span>
{#if emptySub}<span class="pick-sub mono">{emptySub}</span>{/if}
{/if}
</button>
{#if allowFromResults && onFromResults}
@@ -181,6 +207,11 @@
font-weight: 600;
color: var(--text-2);
}
.lbl em {
color: var(--error);
font-style: normal;
margin-left: 2px;
}
.slot-hint {
font-size: 10.5px;
color: var(--text-3);
@@ -234,7 +265,12 @@
opacity: 0.7;
}
.pick-text {
font-size: 12.5px;
color: var(--text-2);
}
.pick-sub {
font-size: 11px;
color: var(--text-3);
}
.from-results-link {
align-self: flex-end;
@@ -251,18 +287,21 @@
}
.slot-filled {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
gap: 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
padding: 8px;
}
.slot-filled.invalid {
border-color: rgba(229, 72, 77, 0.65);
border-color: var(--error);
}
.slot-thumb {
border-radius: 7px;
width: 56px;
height: 56px;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
background: var(--bg);
border: 1px solid var(--border);
@@ -271,39 +310,39 @@
justify-content: center;
position: relative;
}
.media-slot.wide .slot-thumb {
width: 88px;
height: 50px;
}
.slot-thumb img,
.slot-thumb video {
display: block;
width: 100%;
max-height: 140px;
object-fit: cover;
}
.media-slot.wide .slot-thumb {
aspect-ratio: 16 / 9;
}
.media-slot.wide .slot-thumb img,
.media-slot.wide .slot-thumb video {
max-height: none;
height: 100%;
object-fit: cover;
}
.slot-thumb .kind-badge {
color: var(--text-3);
}
.slot-meta {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.name {
flex: 1;
min-width: 0;
font-size: 10.5px;
color: var(--text-3);
font-size: 12.5px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dims {
font-size: 10.5px;
color: var(--text-3);
white-space: nowrap;
}
.slot-actions {
display: inline-flex;
align-items: center;
@@ -315,12 +354,17 @@
border: none;
padding: 0;
font-family: inherit;
font-size: 10.5px;
color: var(--accent);
font-size: 11.5px;
color: var(--text-2);
text-decoration: underline;
cursor: pointer;
}
.mini-link:hover {
text-decoration: underline;
.mini-link:hover:not(:disabled) {
color: var(--accent);
}
.mini-link:disabled {
opacity: 0.5;
cursor: default;
}
.slot-clear {
display: inline-flex;
@@ -330,7 +374,7 @@
height: 22px;
border: none;
border-radius: 6px;
background: transparent;
background: var(--surface-3);
color: var(--text-3);
cursor: pointer;
}
@@ -0,0 +1,446 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import { assetUrl } from '$lib/api';
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
import { t } from '$lib/i18n.svelte';
/**
* Reference-image grid (ref-upload-spec §2 `.refgrid`): numbered cells
* 参考图 1…N in the order they reach the workflow, each with replace and
* ✕; a dashed add cell while below `max`; rejected files show as error
* cells that take no place in the list.
*/
interface Reject {
name: string;
message: string;
}
interface Props {
label: string;
paths: string[];
max: number;
/** Byte sizes of files uploaded in this session, by server path. */
sizes?: Record<string, number>;
/** Name of the file being uploaded (add or replace), if any. */
uploadingName?: string;
/** Index being replaced while uploading (-1 = adding). */
uploadingIndex?: number;
rejects?: Reject[];
error?: string;
allowFromResults?: boolean;
onAdd: (files: File[]) => void;
onReplace: (index: number, file: File) => void;
onRemove: (index: number) => void;
onDismissReject: (index: number) => void;
onFromResults?: () => void;
}
let {
label,
paths,
max,
sizes = {},
uploadingName,
uploadingIndex = -1,
rejects = [],
error,
allowFromResults = false,
onAdd,
onReplace,
onRemove,
onDismissReject,
onFromResults,
}: Props = $props();
let addInput = $state<HTMLInputElement | null>(null);
let replaceInput = $state<HTMLInputElement | null>(null);
let replaceIndex = $state(-1);
let dragDepth = $state(0);
let dims = $state<Record<string, string>>({});
const uploading = $derived(!!uploadingName);
const remaining = $derived(Math.max(0, max - paths.length));
function pickAdd() {
if (uploading || !addInput) return;
addInput.value = '';
addInput.click();
}
function pickReplace(i: number) {
if (uploading || !replaceInput) return;
replaceIndex = i;
replaceInput.value = '';
replaceInput.click();
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragDepth = 0;
if (uploading) return;
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) onAdd(files);
}
function noteDims(path: string, e: Event) {
const img = e.currentTarget as HTMLImageElement;
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
}
function metaOf(path: string): string {
return [dims[path], sizes[path] != null ? formatBytes(sizes[path]) : null].filter(Boolean).join(' · ');
}
</script>
<div class="refgrid-field">
<div class="slot-label">
<span class="lbl">{label}<em>*</em></span>
<span class="slot-hint">{t('studio.refHint', { n: paths.length, max })}</span>
</div>
<input
bind:this={addInput}
type="file"
class="sr-file"
tabindex="-1"
aria-hidden="true"
accept={IMAGE_SLOT_ACCEPT}
multiple
onchange={(e) => {
const files = Array.from(e.currentTarget.files ?? []);
if (files.length) onAdd(files);
}}
/>
<input
bind:this={replaceInput}
type="file"
class="sr-file"
tabindex="-1"
aria-hidden="true"
accept={IMAGE_SLOT_ACCEPT}
onchange={(e) => {
const f = e.currentTarget.files?.[0];
if (f && replaceIndex >= 0) onReplace(replaceIndex, f);
}}
/>
<div class="refgrid">
{#each paths as path, i (path + i)}
<div class="ref-cell" class:busy={uploadingIndex === i}>
<button
type="button"
class="ref-thumb"
title={t('studio.refReplace')}
onclick={() => pickReplace(i)}
>
<img src={assetUrl(path)} alt={t('studio.slotRefN', { n: i + 1 })} onload={(e) => noteDims(path, e)} />
<span class="ref-no mono">{i + 1}</span>
{#if uploadingIndex === i}
<span class="ref-busy"><Spinner size="sm" /></span>
{/if}
</button>
<button
type="button"
class="ref-x"
aria-label={t('studio.refRemove', { n: i + 1 })}
onclick={() => onRemove(i)}
>
<Icon name="close" size={11} />
</button>
<span class="ref-name" title={uploadBaseName(path)}>{uploadBaseName(path)}</span>
<span class="ref-meta mono">
{metaOf(path)}
<button type="button" class="mini-link" onclick={() => pickReplace(i)}>{t('studio.refReplace')}</button>
</span>
</div>
{/each}
{#each rejects as rej, i (rej.name + i)}
<div class="ref-cell rejected" role="alert">
<div class="ref-thumb error">
<Icon name="close" size={16} />
<span class="mono">{t('studio.refRejectedShort')}</span>
</div>
<button
type="button"
class="ref-x"
aria-label={t('studio.deleteBtn')}
onclick={() => onDismissReject(i)}
>
<Icon name="close" size={11} />
</button>
<span class="ref-name err" title={rej.name}>{rej.name}</span>
</div>
{/each}
{#if remaining > 0}
<button
type="button"
class="ref-add"
class:drag={dragDepth > 0}
class:invalid={!!error}
disabled={uploading}
aria-busy={uploading || undefined}
onclick={pickAdd}
ondragenter={(e) => {
e.preventDefault();
dragDepth += 1;
}}
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
ondragover={(e) => e.preventDefault()}
ondrop={onDrop}
>
{#if uploading && uploadingIndex < 0}
<Spinner size="sm" />
<span class="add-main">{uploadingName}</span>
{:else}
<Icon name="plus" size={16} />
<span class="add-main">{t('studio.refAdd')}</span>
<span class="add-sub mono">{t('studio.refAddSub', { n: remaining, max })}</span>
{/if}
</button>
{/if}
</div>
{#if allowFromResults && onFromResults && remaining > 0}
<button type="button" class="from-results-link" onclick={onFromResults}>
{t('studio.slotFromResults')}
</button>
{/if}
{#if remaining === 0}
<span class="full-note">{t('studio.refFull', { max })}</span>
{/if}
{#each rejects as rej, i (rej.name + i)}
<span class="slot-error">{rej.message}</span>
{/each}
{#if error?.trim()}<span class="slot-error" role="alert">{error}</span>{/if}
</div>
<style>
.refgrid-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.slot-label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lbl {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
}
.lbl em {
color: var(--error);
font-style: normal;
margin-left: 2px;
}
.slot-hint {
font-size: 10.5px;
color: var(--text-3);
white-space: nowrap;
}
.sr-file {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.refgrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ref-cell {
position: relative;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding: 5px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
}
.ref-cell.rejected {
border-color: var(--error);
}
.ref-thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
background: var(--bg);
cursor: pointer;
}
.ref-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ref-thumb.error {
flex-direction: column;
gap: 4px;
cursor: default;
color: var(--error);
font-size: 10px;
}
.ref-no {
position: absolute;
left: 4px;
top: 4px;
min-width: 16px;
padding: 0 4px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.62);
color: var(--text);
font-size: 10px;
line-height: 16px;
text-align: center;
}
.ref-busy {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
}
.ref-x {
position: absolute;
top: 9px;
right: 9px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 5px;
background: rgba(0, 0, 0, 0.62);
color: var(--text);
cursor: pointer;
}
.ref-x:hover {
color: var(--error);
}
.ref-name {
font-size: 11px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ref-name.err {
color: var(--error);
}
.ref-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
font-size: 9.5px;
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
}
.mini-link {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 10.5px;
color: var(--text-2);
text-decoration: underline;
cursor: pointer;
flex-shrink: 0;
}
.mini-link:hover {
color: var(--accent);
}
.ref-add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 110px;
padding: 8px 4px;
border: 1.5px dashed var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
color: var(--text-3);
font-family: inherit;
cursor: pointer;
transition:
border-color 150ms,
color 150ms;
}
.ref-add:hover:not(:disabled) {
color: var(--text-2);
}
.ref-add.drag {
border-color: var(--accent);
color: var(--accent);
}
.ref-add.invalid {
border-color: var(--error);
}
.ref-add:disabled {
cursor: default;
opacity: 0.7;
}
.add-main {
font-size: 12px;
color: var(--text-2);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.add-sub {
font-size: 10px;
text-align: center;
}
.from-results-link {
align-self: flex-end;
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11px;
color: var(--accent);
cursor: pointer;
}
.from-results-link:hover {
text-decoration: underline;
}
.full-note {
font-size: 11px;
color: var(--text-3);
}
.slot-error {
font-size: 11.5px;
color: var(--error);
line-height: 1.5;
}
@media (max-width: 1023px) {
.refgrid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
}
</style>
@@ -6,6 +6,8 @@
import Modal from '$lib/components/ui/Modal.svelte';
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
import MediaSlot from '$lib/components/studio/MediaSlot.svelte';
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
import { imageFileProblem } from '$lib/comfy/imageFile';
import { toStore } from 'svelte/store';
import { tick } from 'svelte';
import AppShell from '$lib/components/shell/AppShell.svelte';
@@ -20,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';
@@ -60,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(() => {
@@ -123,7 +130,14 @@
let endpointTouched = $state(false);
let endpointId = $state('');
let workflowId = $state<number | null>(null);
let values = $state<Record<string, string | number>>({});
type FormValue = string | number | string[];
let values = $state<Record<string, FormValue>>({});
/** Per-slot rejected picks (format / size) — shown in place, never uploaded. */
let slotRejects = $state<Record<string, Array<{ name: string; message: string }>>>({});
/** Byte size of files uploaded here, by server path (for the slot meta line). */
let fileSizes = $state<Record<string, number>>({});
/** Reference index being replaced while its upload runs. */
let replacingIndex = $state(-1);
let showErrors = $state(false);
let submitting = $state(false);
let lightboxOpen = $state(false);
@@ -163,13 +177,14 @@
}
if (wf.id === lastWfId) return;
lastWfId = wf.id;
const next: Record<string, string | number> = {};
const next: Record<string, FormValue> = {};
for (const inp of wf.input_schema ?? []) {
if (inp.defaultValue !== undefined && inp.defaultValue !== '') {
next[inp.nodeId] = inp.defaultValue;
}
}
values = next;
slotRejects = {};
showErrors = false;
});
@@ -186,13 +201,36 @@
return `${inp.role ?? ''} ${inp.label}`.toLowerCase();
}
const isMedia = (inp: ComfyDynamicInput) =>
inp.kind === 'image' || inp.kind === 'image_url' || inp.kind === 'video' || inp.kind === 'audio';
inp.kind === 'image' ||
inp.kind === 'image_url' ||
inp.kind === 'video' ||
inp.kind === 'audio' ||
inp.kind === 'image_list';
// Auto-detected slots carry their role from the wiring; tagged (Input:…)
// workflows still name frames only in the title.
const isFirstFrame = (inp: ComfyDynamicInput) =>
role(inp) === 'first_frame' || (!inp.auto && kind === 'video' && /first|start|首帧/.test(hay(inp)));
const isLastFrame = (inp: ComfyDynamicInput) =>
role(inp) === 'last_frame' || (!inp.auto && kind === 'video' && /last|end|尾帧/.test(hay(inp)));
// ComfyUI Autogrow reference images (H3 ref_images): one ordered 1…max list.
const refGroup = $derived(schema.find((i) => i.kind === 'image_list') ?? null);
const refMax = $derived(refGroup?.max ?? 9);
const refPaths = $derived.by(() => {
const v = refGroup ? values[refGroup.nodeId] : undefined;
if (Array.isArray(v)) return v;
return typeof v === 'string' && v ? [v] : [];
});
const brokenReasons = $derived(
(currentWorkflow?.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message),
);
const promptInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'prompt') ?? null);
const negInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'negative') ?? null);
const seedInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'seed') ?? null);
const widthInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'width') ?? null);
const heightInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'height') ?? null);
const resolutionInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'resolution') ?? null);
const durationInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'duration') ?? null);
const stepsInp = $derived(schema.find((i) => /步数|steps/.test(hay(i)) && !isMedia(i)) ?? null);
const cfgInp = $derived(
@@ -220,38 +258,84 @@
mediaKind: 'image' | 'video' | 'audio';
aspect: 'square' | 'wide';
fromResults: boolean;
/** Required-slot message when left empty (ref-upload-spec §3). */
missing: string;
emptyText?: string;
}
const mediaSlots = $derived.by(() => {
const media = schema.filter(isMedia);
const media = schema.filter((i) => isMedia(i) && i.kind !== 'image_list');
const plainImages = media.filter(
(i) => i.kind === 'image' && role(i) !== 'mask' && !/mask|遮罩/.test(hay(i)) && !isFirstFrame(i) && !isLastFrame(i),
);
const editInput = kind === 'image' && plainImages.length === 1 ? plainImages[0] : null;
const out: SlotDef[] = [];
let refIdx = 0;
for (const inp of media) {
const h = hay(inp);
const base = { inp, missing: '' };
if (inp.kind === 'video') {
out.push({ inp, label: t('studio.slotRefVideo'), mediaKind: 'video', aspect: 'square', fromResults: false });
out.push({ ...base, label: t('studio.slotRefVideo'), mediaKind: 'video', aspect: 'square', fromResults: false });
} else if (inp.kind === 'audio') {
out.push({ inp, label: t('studio.slotAudio'), mediaKind: 'audio', aspect: 'square', fromResults: false });
} else if (/mask|遮罩/.test(h)) {
out.push({ inp, label: t('studio.slotMask'), mediaKind: 'image', aspect: 'square', fromResults: false });
} else if (kind === 'video' && /first|start|首帧/.test(h)) {
out.push({ inp, label: t('studio.slotFirst'), mediaKind: 'image', aspect: 'wide', fromResults: true });
} else if (kind === 'video' && /last|end|尾帧/.test(h)) {
out.push({ inp, label: t('studio.slotLast'), mediaKind: 'image', aspect: 'wide', fromResults: true });
out.push({ ...base, label: t('studio.slotAudio'), mediaKind: 'audio', aspect: 'square', fromResults: false });
} else if (role(inp) === 'mask' || /mask|遮罩/.test(h)) {
out.push({ ...base, label: t('studio.slotMask'), mediaKind: 'image', aspect: 'square', fromResults: false });
} else if (isFirstFrame(inp)) {
out.push({
...base,
label: t('studio.slotFirst'),
mediaKind: 'image',
aspect: 'wide',
fromResults: true,
missing: t('studio.errNeedFirstFrame'),
emptyText: t('studio.slotEmptyFirst'),
});
} else if (isLastFrame(inp)) {
out.push({ ...base, label: t('studio.slotLast'), mediaKind: 'image', aspect: 'wide', fromResults: true });
} else if (inp === editInput) {
out.push({
...base,
label: t('studio.slotInput'),
mediaKind: 'image',
aspect: 'square',
fromResults: false,
missing: t('studio.errNeedInputImage'),
emptyText: t('studio.slotEmptyInput'),
});
} else {
refIdx += 1;
const label = inp.auto
? inp.label
: refIdx === 1 && media.length === 1
? t('studio.slotRef')
: t('studio.slotRefN', { n: refIdx });
out.push({
inp,
label: refIdx === 1 && media.length === 1 ? t('studio.slotRef') : t('studio.slotRefN', { n: refIdx }),
...base,
label,
mediaKind: 'image',
aspect: kind === 'video' ? 'wide' : 'square',
fromResults: kind === 'video',
missing: t('studio.errUpload', { label }),
});
}
}
return out;
});
/** 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'))
return t('studio.modeImageEdit');
if (mediaSlots.length) return mediaSlots.map((s) => s.label).join(' · ');
return kind === 'image' ? t('studio.modeTextToImage') : t('studio.modeTextToVideo');
});
const structuredIds = $derived(
new Set(
[
@@ -260,6 +344,7 @@
seedInp,
widthInp,
heightInp,
resolutionInp,
durationInp,
stepsInp,
cfgInp,
@@ -314,8 +399,9 @@
if (!v.trim()) errs[promptInp.nodeId] = t('studio.errPrompt');
else if (v.length > 2000) errs[promptInp.nodeId] = t('studio.errPromptLong');
}
const first = mediaSlots.find((s) => /first|start|首帧/.test(hay(s.inp)));
const last = mediaSlots.find((s) => /last|end|尾帧/.test(hay(s.inp)));
if (brokenReasons.length) errs.workflow = t('studio.errWfBroken');
const first = mediaSlots.find((s) => isFirstFrame(s.inp));
const last = mediaSlots.find((s) => isLastFrame(s.inp));
if (first && last) {
const a = String(values[first.inp.nodeId] ?? '');
const b = String(values[last.inp.nodeId] ?? '');
@@ -328,14 +414,14 @@
for (const s of mediaSlots) {
if (!s.inp.required) continue;
if (!String(values[s.inp.nodeId] ?? '').trim()) {
const isPlainRef = kind === 'image' && !/mask|遮罩|first|start|首帧|last|end|尾帧/.test(hay(s.inp));
errs[s.inp.nodeId] =
mediaSlots.filter((x) => x.mediaKind === 'image' && !/mask|遮罩/.test(hay(x.inp))).length === 1 &&
isPlainRef
? t('studio.errRefImage')
: t('studio.errUpload', { label: s.label });
errs[s.inp.nodeId] = s.missing || t('studio.errUpload', { label: s.label });
}
}
if (refGroup) {
if (refPaths.length < (refGroup.min ?? 1)) errs[refGroup.nodeId] = t('studio.errNeedRef');
else if (refPaths.length > refMax)
errs[refGroup.nodeId] = t('studio.errRefMax', { max: refMax, n: refPaths.length, k: refPaths.length - refMax });
}
for (const inp of restInputs) {
if (inp.required && !String(values[inp.nodeId] ?? '').trim()) {
errs[inp.nodeId] = t('studio.errFill', { label: inp.label });
@@ -343,7 +429,7 @@
}
for (const inp of schema) {
const raw = values[inp.nodeId];
if (raw === undefined || String(raw).trim() === '' || errs[inp.nodeId]) continue;
if (raw === undefined || Array.isArray(raw) || String(raw).trim() === '' || errs[inp.nodeId]) continue;
const msg = valueError(inp, raw);
if (msg) errs[inp.nodeId] = msg;
}
@@ -367,7 +453,7 @@
return null;
}
function setVal(nodeId: string, v: string | number) {
function setVal(nodeId: string, v: FormValue) {
values = { ...values, [nodeId]: v };
}
function numVal(nodeId: string, raw: string) {
@@ -382,11 +468,72 @@
// ------------------------------------------------------------------
// Uploads & media slots
// ------------------------------------------------------------------
async function slotFile(nodeId: string, file: File) {
function setRejects(nodeId: string, list: Array<{ name: string; message: string }>) {
slotRejects = { ...slotRejects, [nodeId]: list };
}
async function uploadImage(nodeId: string, file: File): Promise<string | null> {
const path = await uploads.uploadSafe(nodeId, file);
if (path) fileSizes = { ...fileSizes, [path]: file.size };
return path;
}
async function slotFile(nodeId: string, file: File, mediaKind: 'image' | 'video' | 'audio' = 'image') {
if (mediaKind === 'image') {
const problem = imageFileProblem(file);
if (problem) {
setRejects(nodeId, [{ name: file.name, message: problem }]);
return;
}
}
setRejects(nodeId, []);
const path = await uploadImage(nodeId, file);
if (path) setVal(nodeId, path);
}
/** Add picked/dropped files in order; a batch that would pass `max` is refused whole. */
async function refAdd(files: File[]) {
if (!refGroup) return;
const key = refGroup.nodeId;
const ok = files.filter((f) => !imageFileProblem(f));
const rejected = files
.filter((f) => imageFileProblem(f))
.map((f) => ({ name: f.name, message: imageFileProblem(f)! }));
setRejects(key, rejected);
if (refPaths.length + ok.length > refMax) {
toast.error(t('studio.errRefMax', { max: refMax, n: refPaths.length, k: ok.length }));
return;
}
for (const f of ok) {
const path = await uploadImage(key, f);
if (!path) break;
setVal(key, [...refPaths, path]);
}
}
async function refReplace(index: number, file: File) {
if (!refGroup) return;
const key = refGroup.nodeId;
const problem = imageFileProblem(file);
if (problem) {
setRejects(key, [{ name: file.name, message: problem }]);
return;
}
setRejects(key, []);
replacingIndex = index;
try {
const path = await uploadImage(key, file);
if (path) setVal(key, refPaths.map((p, i) => (i === index ? path : p)));
} finally {
replacingIndex = -1;
}
}
function refRemove(index: number) {
if (!refGroup) return;
setVal(refGroup.nodeId, refPaths.filter((_, i) => i !== index));
}
const imageResults = $derived(
allJobs
.filter((j) => j.kind === 'image' && jobStatusInfo(j).tone === 'ok')
@@ -398,16 +545,24 @@
pickerFor = nodeId;
}
function pickImageResult(path: string) {
if (pickerFor) setVal(pickerFor, path);
if (pickerFor && refGroup && pickerFor === refGroup.nodeId) {
if (refPaths.length >= refMax) toast.error(t('studio.refFull', { max: refMax }));
else setVal(pickerFor, [...refPaths, path]);
} else if (pickerFor) {
setVal(pickerFor, path);
}
pickerFor = null;
}
function sendToImg2Img(path: string) {
let wf = currentWorkflow;
let target = mediaSlots.find((s) => s.mediaKind === 'image')?.inp ?? null;
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'));
@@ -629,16 +784,24 @@
const wf = ($workflowsQuery.data ?? []).find((w) => w.id === selectedEntry.job.workflow_id);
if (!wf) return [];
const vals = (selectedEntry.job.payload?.input_values ?? {}) as Record<string, unknown>;
const out: Array<{ label: string; path: string }> = [];
const out: Array<{ label: string; path: string; ref?: boolean }> = [];
for (const inp of wf.input_schema ?? []) {
const h = `${inp.role ?? ''} ${inp.label}`.toLowerCase();
const v = vals[inp.nodeId];
if (inp.kind === 'image_list') {
const list = Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : [];
list.forEach((p, i) => {
if (typeof p === 'string') out.push({ label: t('studio.slotRefN', { n: i + 1 }), path: p, ref: true });
});
continue;
}
if (typeof v !== 'string' || !v) continue;
if (/first|start|首帧/.test(h)) out.push({ label: t('studio.slotFirst'), path: v });
else if (/last|end|尾帧/.test(h)) out.push({ label: t('studio.slotLast'), path: v });
}
return out;
});
const selectedRefCount = $derived(selectedFrames.filter((f) => f.ref).length);
// ------------------------------------------------------------------
// History actions
@@ -650,14 +813,16 @@
toast.error(t('studio.reuseMissingWf'));
return;
}
const next: Record<string, string | number> = {};
const next: Record<string, FormValue> = {};
for (const inp of wf.input_schema ?? []) {
if (inp.defaultValue !== undefined && inp.defaultValue !== '') next[inp.nodeId] = inp.defaultValue;
}
const past = (job.payload?.input_values ?? {}) as Record<string, unknown>;
for (const [k, v] of Object.entries(past)) {
if (typeof v === 'string' || typeof v === 'number') next[k] = v;
else if (Array.isArray(v) && v.every((p) => typeof p === 'string')) next[k] = v as string[];
}
slotRejects = {};
workflowId = wf.id;
lastWfId = wf.id;
values = next;
@@ -764,13 +929,7 @@
<div class="panel-head">
<h2>{t('studio.genParams')}</h2>
{#if currentWorkflow}
<span class="panel-hint">
{mediaSlots.length
? mediaSlots.map((s) => s.label).join(' · ')
: kind === 'image'
? t('studio.modeTextToImage')
: t('studio.modeTextToVideo')}
</span>
<span class="panel-hint">{modeLabel}</span>
{/if}
</div>
{#if dryRun}
@@ -839,11 +998,22 @@
{:else if schema.some((i) => i.auto)}
<p class="missing-note">{t('studio.wfAutoParamsNote', { n: schema.length })}</p>
{/if}
{#if brokenReasons.length}
<div class="missing-note" role="alert">
<span class="f-error">{t('studio.wfBrokenTitle')}</span>
{#each brokenReasons as reason (reason)}
<span class="f-error">· {reason}</span>
{/each}
<span class="f-error">{t('studio.wfBrokenHint')}</span>
</div>
{/if}
</div>
{#if mediaSlots.length}
{@const pairKey = `${mediaSlots.find((s) => isFirstFrame(s.inp))?.inp.nodeId ?? ''}|${mediaSlots.find((s) => isLastFrame(s.inp))?.inp.nodeId ?? ''}`}
<div class="slots">
{#each mediaSlots as s (s.inp.nodeId)}
{@const reject = slotRejects[s.inp.nodeId]?.[0]}
{#if s.inp.kind === 'video' || s.inp.kind === 'audio'}
<div class="slot-span">
<MediaSlot
@@ -853,7 +1023,7 @@
uploading={uploads.isUploading(s.inp.nodeId)}
uploadName={uploads.uploading[s.inp.nodeId]}
error={showErrors ? errors[s.inp.nodeId] : undefined}
onFile={(f) => slotFile(s.inp.nodeId, f)}
onFile={(f) => slotFile(s.inp.nodeId, f, s.mediaKind)}
onClear={() => setVal(s.inp.nodeId, '')}
/>
</div>
@@ -861,29 +1031,56 @@
<div class="slot-cell" class:span2={s.aspect === 'wide' || mediaSlots.length === 1}>
<MediaSlot
label={s.label}
hint={kind === 'video' && s.fromResults ? t('studio.slotFromResults') : undefined}
required={s.inp.required}
hint={s.fromResults ? t('studio.slotFromResults') : s.inp.required ? t('studio.slotHintOne') : undefined}
value={String(values[s.inp.nodeId] ?? '')}
kind="image"
aspect={s.aspect}
emptyText={s.emptyText}
emptySub={t('studio.slotEmptySub')}
size={fileSizes[String(values[s.inp.nodeId] ?? '')]}
uploading={uploads.isUploading(s.inp.nodeId)}
uploadName={uploads.uploading[s.inp.nodeId]}
error={showErrors ? errors[s.inp.nodeId] : undefined}
error={reject?.message ?? (showErrors ? errors[s.inp.nodeId] : undefined)}
allowFromResults={s.fromResults}
onFile={(f) => slotFile(s.inp.nodeId, f)}
onFromResults={() => openPicker(s.inp.nodeId)}
onClear={() => setVal(s.inp.nodeId, '')}
onClear={() => {
setRejects(s.inp.nodeId, []);
setVal(s.inp.nodeId, '');
}}
/>
</div>
{/if}
{/each}
</div>
{#if showErrors && errors[`${mediaSlots.find((s) => /first|start|首帧/.test(hay(s.inp)))?.inp.nodeId ?? ''}|${mediaSlots.find((s) => /last|end|尾帧/.test(hay(s.inp)))?.inp.nodeId ?? ''}`]}
<span class="f-error">
{errors[`${mediaSlots.find((s) => /first|start|首帧/.test(hay(s.inp)))?.inp.nodeId ?? ''}|${mediaSlots.find((s) => /last|end|尾帧/.test(hay(s.inp)))?.inp.nodeId ?? ''}`]}
</span>
{#if showErrors && errors[pairKey]}
<span class="f-error">{errors[pairKey]}</span>
{/if}
{/if}
{#if refGroup}
<div class="f-field">
<RefImageGrid
label={refGroup.label}
paths={refPaths}
max={refMax}
sizes={fileSizes}
uploadingName={uploads.uploading[refGroup.nodeId]}
uploadingIndex={replacingIndex}
rejects={slotRejects[refGroup.nodeId] ?? []}
error={showErrors ? errors[refGroup.nodeId] : undefined}
allowFromResults
onAdd={refAdd}
onReplace={refReplace}
onRemove={refRemove}
onDismissReject={(i) =>
setRejects(refGroup.nodeId, (slotRejects[refGroup.nodeId] ?? []).filter((_, j) => j !== i))}
onFromResults={() => openPicker(refGroup.nodeId)}
/>
</div>
{/if}
{#if promptInp}
<label class="f-field">
<span class="f-label">
@@ -1014,6 +1211,22 @@
{@render fieldError(widthInp)}
{@render fieldError(heightInp)}
</div>
{:else if resolutionInp}
<label class="f-field">
<span class="f-label">{t('studio.resolutionLabel')}</span>
<input
class="f-input mono"
class:invalid={showErrors && errors[resolutionInp.nodeId]}
type="number"
min={resolutionInp.min}
max={resolutionInp.max}
step={resolutionInp.step ?? 32}
value={String(values[resolutionInp.nodeId] ?? '')}
oninput={(e) => numVal(resolutionInp.nodeId, e.currentTarget.value)}
/>
<span class="panel-hint">{t('studio.resolutionHint')}</span>
{@render fieldError(resolutionInp)}
</label>
{/if}
{#if stepsInp || cfgInp || denoiseInp || batchInp || durationInp || fpsInp}
@@ -1343,7 +1556,9 @@
</div>
{#if selectedFrames.length}
<div class="frame-strip">
<span class="strip-label">{t('studio.videoRefStrip')}</span>
<span class="strip-label">
{selectedRefCount ? t('studio.refStrip', { n: selectedRefCount }) : t('studio.videoRefStrip')}
</span>
{#each selectedFrames as fr (fr.path)}
<div class="frame-item">
<img src={assetUrl(fr.path)} alt={fr.label} />
+65
View File
@@ -1398,6 +1398,43 @@ export const de: Dict = {
'conn.wfJsonInvalid': 'Kein gültiges API-Format-JSON (erwartet: Objekt aus Knoten-ID → {{…class_type…}})',
'conn.wfJsonPh': 'ComfyUI-API-JSON hier einfügen oder rechts eine Datei wählen',
'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) ---
@@ -1499,6 +1536,34 @@ export const de: Dict = {
'studio.wfNoParamsNote': 'Dieser Workflow hat keine ausfüllbaren Parameter und läuft mit eingebautem Prompt und Einstellungen. Um sie hier auszufüllen, Knotentitel in ComfyUI mit (Input:prompt) usw. markieren und neu importieren.',
'studio.wfAutoParamsNote': 'Keine (Input:…)-Titelmarken – {n} Parameter anhand der Knotentypen erkannt; leere Felder behalten die Workflow-Werte.',
'studio.promptNotDeclared': 'Dieser Workflow hat keinen Prompt-Knoten',
'studio.modeImageEdit': 'Image edit',
'studio.modeImageToVideo': 'Image to video',
'studio.modeRefToVideo': 'Reference to video',
'studio.slotInput': 'Input image',
'studio.slotHintOne': 'Required · 1 image',
'studio.slotEmptyInput': 'Click or drop the input image',
'studio.slotEmptyFirst': 'Click or drop the first frame',
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
'studio.slotFromResultsShort': 'From results',
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
'studio.errNeedInputImage': 'Image edit needs an input image',
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
'studio.refHint': 'Required · 1–{max} · {n} selected',
'studio.refAdd': 'Add reference image',
'studio.refAddSub': '{n} more · max {max}',
'studio.refReplace': 'Replace',
'studio.refRemove': 'Remove reference {n}',
'studio.refRejectedShort': 'Not added',
'studio.refFull': 'Limit of {max} reached — remove one to add another',
'studio.refStrip': 'Reference images ({n} used)',
'studio.resolutionLabel': 'Output resolution',
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
'studio.cancelToast': 'Abbruch angefordert',
'studio.deleteBtn': 'Löschen',
'studio.delTitle': 'Aufgabeneintrag löschen',
+65
View File
@@ -1398,6 +1398,43 @@ export const en = {
'conn.wfJsonInvalid': 'Not valid API-format JSON (expected an object of node id to class_type)',
'conn.wfJsonPh': 'Paste the ComfyUI API-format JSON here, or pick a file',
'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',
@@ -1498,6 +1535,34 @@ export const en = {
'studio.wfNoParamsNote': 'This workflow has no fillable parameters and will run with its built-in prompt and settings. To fill them here, tag node titles with (Input:prompt) etc. in ComfyUI and re-import.',
'studio.wfAutoParamsNote': 'No (Input:…) title tags found — {n} parameters were detected from node types; blank fields keep the workflow values.',
'studio.promptNotDeclared': 'This workflow has no prompt node — nothing to fill',
'studio.modeImageEdit': 'Image edit',
'studio.modeImageToVideo': 'Image to video',
'studio.modeRefToVideo': 'Reference to video',
'studio.slotInput': 'Input image',
'studio.slotHintOne': 'Required · 1 image',
'studio.slotEmptyInput': 'Click or drop the input image',
'studio.slotEmptyFirst': 'Click or drop the first frame',
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
'studio.slotFromResultsShort': 'From results',
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
'studio.errNeedInputImage': 'Image edit needs an input image',
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
'studio.refHint': 'Required · 1–{max} · {n} selected',
'studio.refAdd': 'Add reference image',
'studio.refAddSub': '{n} more · max {max}',
'studio.refReplace': 'Replace',
'studio.refRemove': 'Remove reference {n}',
'studio.refRejectedShort': 'Not added',
'studio.refFull': 'Limit of {max} reached — remove one to add another',
'studio.refStrip': 'Reference images ({n} used)',
'studio.resolutionLabel': 'Output resolution',
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
'studio.cancelToast': 'Cancel requested',
'studio.deleteBtn': 'Delete',
'studio.delTitle': 'Delete task record',
+65
View File
@@ -1398,6 +1398,43 @@ export const es: Dict = {
'conn.wfJsonInvalid': 'JSON no válido en formato API (se esperaba un objeto id de nodo → {{…class_type…}})',
'conn.wfJsonPh': 'Pega aquí el JSON en formato API de ComfyUI o elige un archivo',
'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) ---
@@ -1499,6 +1536,34 @@ export const es: Dict = {
'studio.wfNoParamsNote': 'Este flujo no tiene parámetros rellenables y se ejecutará con su prompt y ajustes internos. Para rellenarlos aquí, etiqueta los títulos de nodo con (Input:prompt) etc. en ComfyUI y vuelve a importar.',
'studio.wfAutoParamsNote': 'Sin etiquetas (Input:…): se detectaron {n} parámetros por tipo de nodo; los campos vacíos conservan los valores del flujo.',
'studio.promptNotDeclared': 'Este flujo no tiene nodo de prompt',
'studio.modeImageEdit': 'Image edit',
'studio.modeImageToVideo': 'Image to video',
'studio.modeRefToVideo': 'Reference to video',
'studio.slotInput': 'Input image',
'studio.slotHintOne': 'Required · 1 image',
'studio.slotEmptyInput': 'Click or drop the input image',
'studio.slotEmptyFirst': 'Click or drop the first frame',
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
'studio.slotFromResultsShort': 'From results',
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
'studio.errNeedInputImage': 'Image edit needs an input image',
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
'studio.refHint': 'Required · 1–{max} · {n} selected',
'studio.refAdd': 'Add reference image',
'studio.refAddSub': '{n} more · max {max}',
'studio.refReplace': 'Replace',
'studio.refRemove': 'Remove reference {n}',
'studio.refRejectedShort': 'Not added',
'studio.refFull': 'Limit of {max} reached — remove one to add another',
'studio.refStrip': 'Reference images ({n} used)',
'studio.resolutionLabel': 'Output resolution',
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
'studio.cancelToast': 'Cancelación solicitada',
'studio.deleteBtn': 'Eliminar',
'studio.delTitle': 'Eliminar registro de tarea',
+65
View File
@@ -1398,6 +1398,43 @@ export const fr: Dict = {
'conn.wfJsonInvalid': 'JSON invalide au format API (attendu : objet id de nœud → {{…class_type…}})',
'conn.wfJsonPh': 'Collez ici le JSON format API ComfyUI ou choisissez un fichier',
'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) ---
@@ -1499,6 +1536,34 @@ export const fr: Dict = {
'studio.wfNoParamsNote': 'Ce workflow n\'a aucun paramètre à remplir et sera exécuté avec son prompt et ses réglages intégrés. Pour les remplir ici, ajoutez (Input:prompt) etc. aux titres de nœuds dans ComfyUI puis réimportez.',
'studio.wfAutoParamsNote': 'Aucune balise (Input:…) : {n} paramètres détectés par type de nœud ; les champs vides gardent les valeurs du workflow.',
'studio.promptNotDeclared': 'Ce workflow n\'a pas de nœud de prompt',
'studio.modeImageEdit': 'Image edit',
'studio.modeImageToVideo': 'Image to video',
'studio.modeRefToVideo': 'Reference to video',
'studio.slotInput': 'Input image',
'studio.slotHintOne': 'Required · 1 image',
'studio.slotEmptyInput': 'Click or drop the input image',
'studio.slotEmptyFirst': 'Click or drop the first frame',
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
'studio.slotFromResultsShort': 'From results',
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
'studio.errNeedInputImage': 'Image edit needs an input image',
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
'studio.refHint': 'Required · 1–{max} · {n} selected',
'studio.refAdd': 'Add reference image',
'studio.refAddSub': '{n} more · max {max}',
'studio.refReplace': 'Replace',
'studio.refRemove': 'Remove reference {n}',
'studio.refRejectedShort': 'Not added',
'studio.refFull': 'Limit of {max} reached — remove one to add another',
'studio.refStrip': 'Reference images ({n} used)',
'studio.resolutionLabel': 'Output resolution',
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
'studio.cancelToast': 'Annulation demandée',
'studio.deleteBtn': 'Supprimer',
'studio.delTitle': 'Supprimer la tâche',
+65
View File
@@ -1398,6 +1398,43 @@ export const ja: Dict = {
'conn.wfJsonInvalid': '有効な API 形式 JSON ではありません(ノード ID → {{…class_type…}} のオブジェクトが必要)',
'conn.wfJsonPh': 'ここに ComfyUI の API 形式 JSON を貼り付けるか、右のボタンでファイルを選択',
'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)---
@@ -1499,6 +1536,34 @@ export const ja: Dict = {
'studio.wfNoParamsNote': 'このワークフローには記入可能なパラメータがなく、内蔵のプロンプトと設定で実行されます。ここで記入するには ComfyUI でノードタイトルに (Input:prompt) などを付けて再度取り込んでください。',
'studio.wfAutoParamsNote': '(Input:…) タグがないため、ノード種別から {n} 件のパラメータを自動検出しました。空欄はワークフローの値を使います。',
'studio.promptNotDeclared': 'このワークフローにはプロンプトノードがありません',
'studio.modeImageEdit': 'Image edit',
'studio.modeImageToVideo': 'Image to video',
'studio.modeRefToVideo': 'Reference to video',
'studio.slotInput': 'Input image',
'studio.slotHintOne': 'Required · 1 image',
'studio.slotEmptyInput': 'Click or drop the input image',
'studio.slotEmptyFirst': 'Click or drop the first frame',
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
'studio.slotFromResultsShort': 'From results',
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
'studio.errNeedInputImage': 'Image edit needs an input image',
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
'studio.refHint': 'Required · 1–{max} · {n} selected',
'studio.refAdd': 'Add reference image',
'studio.refAddSub': '{n} more · max {max}',
'studio.refReplace': 'Replace',
'studio.refRemove': 'Remove reference {n}',
'studio.refRejectedShort': 'Not added',
'studio.refFull': 'Limit of {max} reached — remove one to add another',
'studio.refStrip': 'Reference images ({n} used)',
'studio.resolutionLabel': 'Output resolution',
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
'studio.cancelToast': 'キャンセルを要求しました',
'studio.deleteBtn': '削除',
'studio.delTitle': 'タスク記録を削除',
+65
View File
@@ -1398,6 +1398,43 @@ export const ko: Dict = {
'conn.wfJsonInvalid': '유효한 API 형식 JSON이 아닙니다(노드 ID → {{…class_type…}} 객체 필요)',
'conn.wfJsonPh': '여기에 ComfyUI API 형식 JSON을 붙여넣거나 오른쪽 버튼으로 파일을 선택하세요',
'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) ---
@@ -1499,6 +1536,34 @@ export const ko: Dict = {
'studio.wfNoParamsNote': '이 워크플로에는 채울 매개변수가 없어 내장 프롬프트와 설정으로 실행됩니다. 여기서 입력하려면 ComfyUI에서 노드 제목에 (Input:prompt) 등을 붙인 뒤 다시 가져오세요.',
'studio.wfAutoParamsNote': '(Input:…) 제목 태그가 없어 노드 유형으로 매개변수 {n}개를 자동 감지했습니다. 빈 칸은 워크플로 값을 유지합니다.',
'studio.promptNotDeclared': '이 워크플로에는 프롬프트 노드가 없습니다',
'studio.modeImageEdit': 'Image edit',
'studio.modeImageToVideo': 'Image to video',
'studio.modeRefToVideo': 'Reference to video',
'studio.slotInput': 'Input image',
'studio.slotHintOne': 'Required · 1 image',
'studio.slotEmptyInput': 'Click or drop the input image',
'studio.slotEmptyFirst': 'Click or drop the first frame',
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
'studio.slotFromResultsShort': 'From results',
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
'studio.errNeedInputImage': 'Image edit needs an input image',
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
'studio.refHint': 'Required · 1–{max} · {n} selected',
'studio.refAdd': 'Add reference image',
'studio.refAddSub': '{n} more · max {max}',
'studio.refReplace': 'Replace',
'studio.refRemove': 'Remove reference {n}',
'studio.refRejectedShort': 'Not added',
'studio.refFull': 'Limit of {max} reached — remove one to add another',
'studio.refStrip': 'Reference images ({n} used)',
'studio.resolutionLabel': 'Output resolution',
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
'studio.cancelToast': '취소를 요청했습니다',
'studio.deleteBtn': '삭제',
'studio.delTitle': '작업 기록 삭제',
+75 -9
View File
@@ -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,14 +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.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 连接」共用',
@@ -1498,6 +1535,35 @@ export const zh: Dict = {
'studio.wfNoParamsNote': '该工作流没有可填写的参数,将按工作流内置的提示词和设置直接生成。如需在这里填写,请在 ComfyUI 中把节点标题加上 (Input:prompt) 等标记后重新导入。',
'studio.wfAutoParamsNote': '工作流未使用 (Input:…) 标题标记,已按节点类型自动识别出 {n} 个参数;留空的项沿用工作流原值。',
'studio.promptNotDeclared': '该工作流没有提示词节点,无需填写',
// --- 图片输入工作流(HEL-633,ref-upload-spec)---
'studio.modeImageEdit': '图片编辑',
'studio.modeImageToVideo': '图生视频',
'studio.modeRefToVideo': '参考生视频',
'studio.slotInput': '输入图',
'studio.slotHintOne': '必填 · 1 张',
'studio.slotEmptyInput': '点击或拖入输入图',
'studio.slotEmptyFirst': '点击或拖入首帧图',
'studio.slotEmptySub': 'PNG / JPG / WebP · 单张 ≤ 20MB',
'studio.slotFromResultsShort': '从结果选',
'studio.errImageFormat': '「{name}」格式不支持,请使用 PNG / JPG / WebP;该文件不会参与生成',
'studio.errImageSize': '「{name}」超过 20MB,请压缩后再传',
'studio.errNeedInputImage': '图片编辑需要上传输入图',
'studio.errNeedFirstFrame': '图生视频需要上传首帧图',
'studio.errNeedRef': '参考生视频需要至少 1 张参考图',
'studio.errRefMax': '参考图最多 {max} 张:已选 {n} 张,本次的 {k} 张未添加',
'studio.refHint': '必填 · 1–{max} 张 · 已选 {n}',
'studio.refAdd': '添加参考图',
'studio.refAddSub': '还可加 {n} 张 · 上限 {max}',
'studio.refReplace': '替换',
'studio.refRemove': '移除参考图 {n}',
'studio.refRejectedShort': '未添加',
'studio.refFull': '已达上限 {max} 张,移除后才能再添加',
'studio.refStrip': '参考图({n} 张参与生成)',
'studio.resolutionLabel': '输出分辨率',
'studio.resolutionHint': '约 {v}×{v} 像素,按输入图比例;0 = 保持原尺寸',
'studio.wfBrokenTitle': '该工作流文件不完整,ComfyUI 无法执行,已禁止生成',
'studio.wfBrokenHint': '请在已安装对应自定义节点的 ComfyUI 中重新导出 API 格式 JSON,再到「ComfyUI 连接」重新导入。',
'studio.errWfBroken': '工作流文件不完整,需重新导出后才能生成',
'studio.cancelToast': '已发送取消请求',
'studio.deleteBtn': '删除',
'studio.delTitle': '删除任务记录',
+479 -111
View File
@@ -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 } 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,32 +361,102 @@
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 } : {}),
});
},
onSuccess: (wf) => {
client.invalidateQueries({ queryKey: ['workflows'] });
const n = wf.input_schema?.length ?? 0;
if (n) toast.success(t('conn.wfImportedN', { n }));
const problems = wfProblems(wf);
if (problems.length) toast.error(t('conn.wfImportedBroken', { detail: problems.join(';') }));
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(
@@ -391,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 }),
@@ -621,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">
@@ -675,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">
@@ -714,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>
@@ -733,61 +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>
<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>
@@ -1087,6 +1313,10 @@
color: var(--accent);
background: var(--accent-dim);
}
.tag-error {
color: var(--error);
background: var(--error-dim);
}
.card-actions {
margin-left: auto;
display: inline-flex;
@@ -1289,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;
@@ -1311,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 {
@@ -1547,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;