Compare commits

..
Author SHA1 Message Date
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
60c043db7c fix(HEL-627): 补齐真实工作流参数并修正虚假完成
- 解析 ResolutionSelector 比例/总像素、KSampler/BasicScheduler 降噪,附带 ComfyUI 取值范围
- 已导入的自动识别工作流读取时按最新规则重新解析,无需重导
- 提交前后端校验参数(中文原因),非法值 400 拒绝不入队
- 任务新增 phase / comfy_prompt_id:连接中→已接收(前方排队)→执行中→保存中→完成
- 空产出、队列与历史都丢失、执行中断均判失败;Dry-run 结果标为占位,不算完成
- ComfyUI 缺模型报错写明缺哪个文件
- 生图/生视频/任务页统一用 jobStatusInfo 展示真实状态,结果区支持复用参数

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 23:05:34 +08:00
38 changed files with 4193 additions and 315 deletions
@@ -41,12 +41,24 @@ def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
if not isinstance(node_err, dict):
continue
errors = node_err.get("errors")
node = f"{node_id}({node_err['class_type']})" if node_err.get("class_type") else node_id
if isinstance(errors, list):
for e in errors:
if isinstance(e, dict):
msg = e.get("message") or e.get("details")
if msg:
parts.append(f"节点 {node_id}:{msg}")
if not isinstance(e, dict):
continue
if e.get("type") == "value_not_in_list":
extra = e.get("extra_info") or {}
parts.append(
f"节点 {node}:ComfyUI 上没有「{extra.get('received_value', '?')}」"
f"({extra.get('input_name', '参数')} 不在可选列表中,"
"多为模型文件未安装)"
)
continue
msg = e.get("message") or e.get("details")
if msg and e.get("details") and e["details"] != msg:
msg = f"{msg}({e['details']})"
if msg:
parts.append(f"节点 {node}:{msg}")
detail = ";".join(parts)
suffix = f":{detail}" if detail else ""
return RuntimeError(f"{prefix}(HTTP {resp.status_code}){suffix}")
@@ -217,6 +229,37 @@ class ComfyUIClient:
data = resp.json()
return data.get(prompt_id)
async def locate_prompt(self, prompt_id: str) -> tuple[str, int]:
"""Where ``prompt_id`` sits in ComfyUI's ``/queue``.
Returns ``("running", 0)``, ``("pending", <prompts ahead>)``,
``("missing", 0)`` when the queue does not list it, or
``("unknown", 0)`` when this server has no ``/queue`` route.
"""
resp = await self._http.get(f"{self.base_url}/queue")
if resp.status_code == 404:
return "unknown", 0
resp.raise_for_status()
data = resp.json() if resp.content else {}
running = data.get("queue_running") or []
pending = data.get("queue_pending") or []
def pid(item: Any) -> Any:
return item[1] if isinstance(item, list) and len(item) > 1 else None
if any(pid(item) == prompt_id for item in running):
return "running", 0
mine = next((item for item in pending if pid(item) == prompt_id), None)
if mine is None:
return "missing", 0
ahead = sum(
1
for item in pending
if isinstance(item, list) and item and isinstance(item[0], (int, float))
and isinstance(mine[0], (int, float)) and item[0] < mine[0]
)
return "pending", len(running) + ahead
async def download_image(
self,
filename: str,
@@ -226,7 +269,10 @@ class ComfyUIClient:
) -> Path:
params = {"filename": filename, "subfolder": subfolder, "type": folder_type}
resp = await self._http.get(f"{self.base_url}/view", params=params)
resp.raise_for_status()
if resp.status_code >= 400:
raise _surface_error(f"从 ComfyUI 下载结果文件 {filename} 失败", resp)
if not resp.content:
raise RuntimeError(f"从 ComfyUI 下载的结果文件 {filename} 是空文件")
if dest is None:
dest = settings.assets_dir / "comfy_downloads" / filename
dest.parent.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,94 @@
"""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, node)}没有接到任何节点,"
"它的宽高不会生效,已不在表单中显示",
}
)
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}。"
+242 -11
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import math
from typing import Any
from calliope.comfyui.patcher import FIELD_KEY_SEP, resolve_patch_field
@@ -67,6 +68,20 @@ def parse_dynamic_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
return results
def effective_input_schema(
stored: list[dict[str, Any]] | None, workflow: dict[str, Any]
) -> list[dict[str, Any]]:
"""The schema a stored workflow exposes now.
Auto-detected (and empty) schemas are re-derived from the workflow JSON
on every read, so parser upgrades reach workflows imported earlier
without a re-import. Tagged ``(Input:…)`` schemas are kept as stored.
"""
if not stored or any(isinstance(e, dict) and e.get("auto") for e in stored):
return parse_dynamic_inputs(workflow)
return stored
# ---------------------------------------------------------------------------
# Untagged detection
# ---------------------------------------------------------------------------
@@ -185,9 +200,15 @@ def _duration_hint(
def _entry(
key: str, label: str, role: str | None, kind: str, default: Any, required: bool
key: str,
label: str,
role: str | None,
kind: str,
default: Any,
required: bool,
**extra: Any,
) -> dict[str, Any]:
return {
entry = {
"nodeId": key,
"label": label,
"role": role,
@@ -196,6 +217,140 @@ def _entry(
"required": required,
"auto": True,
}
entry.update({k: v for k, v in extra.items() if v is not None})
return entry
# ComfyUI's own widget limits (nodes.py / comfy_extras) for the widgets we
# expose; the studio validates against them before a job is queued. Seeds
# stop at 2**53-1 (ComfyUI allows 2**64-1): larger values lose precision in
# the browser's JSON numbers.
_NUMBER_LIMITS: dict[str, dict[str, Any]] = {
"seed": {"min": 0, "max": 2**53 - 1, "step": 1, "integer": True},
"steps": {"min": 1, "max": 10000, "step": 1, "integer": True},
"cfg": {"min": 0.0, "max": 100.0, "step": 0.1},
"denoise": {"min": 0.0, "max": 1.0, "step": 0.01},
"width": {"min": 16, "max": 16384, "step": 8, "integer": True},
"height": {"min": 16, "max": 16384, "step": 8, "integer": True},
"batch": {"min": 1, "max": 4096, "step": 1, "integer": True},
"megapixels": {"min": 0.1, "max": 16.0, "step": 0.1},
}
# Text encoders whose ``resolution`` widget sizes the generated image when an
# input image is wired (edit mode); in text-to-image it only sizes references.
_RESOLUTION_ENCODERS: dict[str, dict[str, Any]] = {
"TextEncodeQwenImage21": {"min": 0, "max": 4096, "step": 32, "integer": True},
}
# comfy_extras/nodes_resolution.py — option strings must match exactly.
RESOLUTION_ASPECT_RATIOS: dict[str, tuple[int, int]] = {
"1:1 (Square)": (1, 1),
"2:3 (Portrait Photo)": (2, 3),
"3:2 (Photo)": (3, 2),
"3:4 (Portrait Standard)": (3, 4),
"4:3 (Standard)": (4, 3),
"9:16 (Portrait Widescreen)": (9, 16),
"16:9 (Widescreen)": (16, 9),
"21:9 (Ultrawide)": (21, 9),
}
def resolution_selector_size(aspect_ratio: str, megapixels: float, multiple: int) -> tuple[int, int]:
"""Width/height exactly as ComfyUI's ResolutionSelector computes them."""
w_ratio, h_ratio = RESOLUTION_ASPECT_RATIOS[aspect_ratio]
scale = math.sqrt(megapixels * 1024 * 1024 / (w_ratio * h_ratio))
multiple = max(1, int(multiple))
return (
round(w_ratio * scale / multiple) * multiple,
round(h_ratio * scale / multiple) * multiple,
)
# ComfyUI Autogrow image inputs a studio form fills as one 1…max list
# (values read from /object_info). The workflow wires ``<group>.<prefix>N``;
# extra images are cloned LoadImage nodes added at patch time (patcher.py).
IMAGE_GROUPS: dict[str, dict[str, dict[str, Any]]] = {
"MiniMaxH3ReferenceToVideo": {
"ref_images": {"prefix": "ref_image_", "max": 9, "label": "参考图"},
},
}
# 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_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 _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, spec in IMAGE_GROUPS.get(str(node.get("class_type") or ""), {}).items():
sources: list[str] = []
for _n, _name, link in image_group_slots(node, group, spec["prefix"]):
if not (isinstance(link, list) and len(link) == 2):
continue
src = workflow.get(str(link[0]))
if isinstance(src, dict) and src.get("class_type") in IMAGE_CLASSES:
sources.append(str(link[0]))
if not sources:
continue
absorbed.update(sources)
entries.append(
_entry(
f"{nid}{FIELD_KEY_SEP}{group}",
spec["label"],
"reference_image",
"image_list",
None,
True,
min=1,
max=spec["max"],
)
)
return entries, absorbed
def _image_slot(
nid: str, workflow: dict[str, Any], consumers: dict[str, list[tuple[str, str]]]
) -> tuple[str, str] | None:
"""(role, label) of a LoadImage from what consumes it; None when unwired."""
uses = consumers.get(nid, [])
if not uses:
return None
for _cid, name in uses:
if name in _FRAME_SLOT_ROLES:
return _FRAME_SLOT_ROLES[name]
for cid, name in uses:
ct = str((workflow.get(cid) or {}).get("class_type") or "")
if "TextEncode" in ct and name.startswith("image"):
return "image", "输入图"
return "image", ""
def _key(node_id: str, node: dict[str, Any], field: str) -> str:
@@ -221,6 +376,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()
@@ -232,7 +388,12 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
if not _is_scalar(value) or isinstance(value, str):
return
taken_roles.add(role)
numbers.append(_entry(_key(nid, node, field), label, role, "number", value, False))
numbers.append(
_entry(
_key(nid, node, field), label, role, "number", value, False,
**_NUMBER_LIMITS.get(role, {}),
)
)
for nid, node in nodes:
ct = str(node.get("class_type") or "")
@@ -241,19 +402,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
@@ -279,23 +452,72 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
texts.append((nid, node, neg_field, True))
elif field:
texts.append((nid, node, field, _is_negative(nid, lowered, consumers)))
edits_image = any(
k.startswith("images.") and isinstance(v, list) for k, v in inputs.items()
)
if ct in _RESOLUTION_ENCODERS and edits_image and "resolution" not in taken_roles:
value = inputs.get("resolution")
if _is_scalar(value) and not isinstance(value, str):
taken_roles.add("resolution")
numbers.append(
_entry(
_key(nid, node, "resolution"),
"输出分辨率",
"resolution",
"number",
value,
False,
**_RESOLUTION_ENCODERS[ct],
)
)
continue
if ct in _SAMPLER_CLASSES:
add_number(nid, node, "seed" if "seed" in inputs else "noise_seed", "seed", "随机种子")
add_number(nid, node, "steps", "steps", "采样步数")
add_number(nid, node, "cfg", "cfg", "CFG")
add_number(nid, node, "denoise", "denoise", "降噪强度")
continue
if ct == "RandomNoise":
add_number(nid, node, "noise_seed", "seed", "随机种子")
continue
if ct == "BasicScheduler":
add_number(nid, node, "steps", "steps", "采样步数")
add_number(nid, node, "denoise", "denoise", "降噪强度")
continue
if ct == "ResolutionSelector" and nid in consumers:
ratio = inputs.get("aspect_ratio")
if (
"aspect_ratio" not in taken_roles
and isinstance(ratio, str)
and ratio in RESOLUTION_ASPECT_RATIOS
):
taken_roles.add("aspect_ratio")
multiple = inputs.get("multiple")
numbers.append(
_entry(
_key(nid, node, "aspect_ratio"),
"画面比例",
"aspect_ratio",
"select",
ratio,
False,
options=list(RESOLUTION_ASPECT_RATIOS),
resolution={
"megapixels": _key(nid, node, "megapixels"),
"multiple": multiple if isinstance(multiple, int) else 8,
},
)
)
add_number(nid, node, "megapixels", "megapixels", "总像素(百万)")
continue
if ct == "CFGGuider":
add_number(nid, node, "cfg", "cfg", "CFG")
continue
if ct.startswith("Empty") and "Latent" in ct:
if nid not in consumers:
# Orphan latent (Qwen edit sizes from its encoder): editing it changes nothing.
continue
add_number(nid, node, "width", "width", "宽度")
add_number(nid, node, "height", "height", "高度")
add_number(nid, node, "batch_size", "batch", "批次数量")
@@ -316,8 +538,16 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
role = None
if role:
taken_roles.add(role)
limits = dict(_NUMBER_LIMITS.get(role or "", {}))
if ct in ("PrimitiveInt", "INT"):
limits["integer"] = True
if role == "duration":
limits.setdefault("min", 0.1)
numbers.append(
_entry(_key(nid, node, "value"), label, role, "number", inputs["value"], False)
_entry(
_key(nid, node, "value"), label, role, "number", inputs["value"], False,
**limits,
)
)
results: list[dict[str, Any]] = []
@@ -359,6 +589,7 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
)
results.extend(numbers)
results.extend(media)
results.extend(groups)
return results
@@ -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_slots, image_group_spec
node = workflow.get(node_id)
if not isinstance(node, dict):
return
spec = image_group_spec(str(node.get("class_type") or ""), group)
if spec is None:
return
prefix = spec["prefix"]
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"{spec['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):
@@ -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):
@@ -0,0 +1,127 @@
"""Check and coerce studio form values against a workflow's input schema."""
from __future__ import annotations
import math
from pathlib import Path
from typing import Any
def _fmt(n: Any) -> str:
return f"{n:g}" if isinstance(n, float) else str(n)
def validate_input_values(
schema: list[dict[str, Any]], values: dict[str, Any]
) -> tuple[dict[str, Any], list[str]]:
"""Return ``(clean_values, errors)``.
Numbers are coerced to int/float per the schema entry (``integer``,
``min``/``max``); select values must be one of ``options``. Blank values
are dropped so the workflow keeps its own value. Keys not in the schema
pass through untouched (other callers patch extra keys, e.g. uploads).
Auto-detected required entries (positive prompt, wired image slots,
reference-image lists) must be present — tagged workflows mark every
input required, and blank ones fall back to the workflow's value.
"""
by_key = {str(e.get("nodeId")): e for e in schema if isinstance(e, dict)}
clean: dict[str, Any] = {}
errors: list[str] = []
for key, raw in (values or {}).items():
entry = by_key.get(str(key))
if entry is None:
clean[key] = raw
continue
if raw is None or (isinstance(raw, str) and not raw.strip()):
continue
label = str(entry.get("label") or key)
kind = entry.get("kind")
if kind == "number":
if isinstance(raw, bool):
errors.append(f"「{label}」必须是数字")
continue
try:
num = float(raw)
except (TypeError, ValueError):
errors.append(f"「{label}」必须是数字")
continue
if not math.isfinite(num):
errors.append(f"「{label}」必须是有限数字")
continue
if entry.get("integer"):
if num != int(num):
errors.append(f"「{label}」必须是整数")
continue
value: int | float = int(num)
else:
value = int(num) if isinstance(raw, int) else num
lo, hi = entry.get("min"), entry.get("max")
if lo is not None and value < lo:
errors.append(f"「{label}」不能小于 {_fmt(lo)}")
continue
if hi is not None and value > hi:
errors.append(f"「{label}」不能大于 {_fmt(hi)}")
continue
clean[key] = value
elif kind == "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:
errors.append(f"「{label}」的取值「{raw}」不在工作流支持的选项中")
continue
clean[key] = raw
else:
clean[key] = raw
for key, entry in by_key.items():
required = entry.get("auto") and entry.get("required")
label = entry.get("label") or key
if required and entry.get("role") == "prompt":
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
if key not in clean and not has_fallback:
errors.append(f"请填写「{label}」")
elif required and entry.get("kind") == "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
+10 -1
View File
@@ -138,7 +138,9 @@ CREATE TABLE IF NOT EXISTS jobs (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
completed_at TIMESTAMP,
retry_count INTEGER NOT NULL DEFAULT 0
retry_count INTEGER NOT NULL DEFAULT 0,
comfy_prompt_id TEXT,
phase TEXT
);
CREATE TABLE IF NOT EXISTS agent_sessions (
@@ -420,6 +422,13 @@ async def migrate_db(db_path: Path) -> None:
WHERE kind = 'video' AND scene_id IS NOT NULL AND clip_id IS NULL
"""
)
# HEL-627: the ComfyUI prompt id a job was accepted under, and where the
# job really is (connecting / comfy_queued / comfy_running / saving;
# finished = real results saved; dry_run = placeholder, ComfyUI never called).
if "comfy_prompt_id" not in job_cols:
conn.execute("ALTER TABLE jobs ADD COLUMN comfy_prompt_id TEXT")
if "phase" not in job_cols:
conn.execute("ALTER TABLE jobs ADD COLUMN phase TEXT")
# Build Scene's per-scene sessions: origin stamps WHERE a blind session was
# born ('chat' = user-created in AI Canvas, 'scene' = auto-created by the
# Build Scene rail). The session list excludes origin='scene' so scene
+17 -2
View File
@@ -19,7 +19,8 @@ class QueueManager:
conn = get_db(config.settings.db_path)
try:
cur = conn.execute(
"UPDATE jobs SET status = 'pending', started_at = NULL WHERE status = 'running'"
"UPDATE jobs SET status = 'pending', started_at = NULL, phase = NULL, "
"comfy_prompt_id = NULL WHERE status = 'running'"
)
conn.commit()
return cur.rowcount
@@ -105,6 +106,20 @@ class QueueManager:
finally:
conn.close()
def set_phase(self, job_id: int, phase: str, comfy_prompt_id: str | None = None) -> None:
conn = get_db(config.settings.db_path)
try:
if comfy_prompt_id:
conn.execute(
"UPDATE jobs SET phase = ?, comfy_prompt_id = ? WHERE id = ?",
(phase, comfy_prompt_id, job_id),
)
else:
conn.execute("UPDATE jobs SET phase = ? WHERE id = ?", (phase, job_id))
conn.commit()
finally:
conn.close()
def mark_done(self, job_id: int, output_paths: list[str]) -> None:
conn = get_db(config.settings.db_path)
try:
@@ -142,7 +157,7 @@ class QueueManager:
conn.execute(
"""
UPDATE jobs SET status = 'pending', error = NULL, started_at = NULL,
completed_at = NULL WHERE id = ?
completed_at = NULL, phase = NULL, comfy_prompt_id = NULL WHERE id = ?
""",
(job_id,),
)
+92 -11
View File
@@ -8,9 +8,12 @@ from typing import Any
from pathlib import Path as _fs_path
import httpx
from calliope import config
from calliope.comfyui.client import ComfyUIClient
from calliope.comfyui.dry_run import write_placeholder_mp4, write_placeholder_png
from calliope.comfyui.lint import blocking_problem
from calliope.comfyui.parser import parse_dynamic_inputs
from calliope.comfyui.patcher import patch_workflow
from calliope.comfyui.roles import input_has_role
@@ -26,12 +29,22 @@ def _comfy_messages(messages: list[Any]) -> str:
"""Pull the readable part out of ComfyUI's history status messages."""
parts: list[str] = []
for msg in messages:
if isinstance(msg, (list, tuple)) and len(msg) == 2 and msg[0] == "execution_error":
body = msg[1] if isinstance(msg[1], dict) else {}
node = body.get("node_type") or body.get("node_id") or "?"
if not (isinstance(msg, (list, tuple)) and len(msg) == 2):
continue
body = msg[1] if isinstance(msg[1], dict) else {}
node = body.get("node_type") or body.get("node_id") or "?"
if msg[0] == "execution_error":
reason = body.get("exception_message") or body.get("exception_type") or "未知错误"
parts.append(f"节点 {node}:{str(reason).strip()}")
return ";".join(parts) or str(messages)[:500]
elif msg[0] == "execution_interrupted":
parts.append(f"任务在节点 {node} 处被中断(在 ComfyUI 端被取消,或 ComfyUI 已重启)")
return ";".join(parts) or "ComfyUI 未给出原因"
# /queue lookups that must agree before a prompt counts as lost, and /history
# or /queue request failures in a row before the job gives up.
_MISSING_POLLS_LIMIT = 3
_POLL_ERRORS_LIMIT = 5
class _CancelledByUser(RuntimeError):
@@ -91,6 +104,7 @@ class QueueWorker:
job_payload = json.loads(job["payload_json"] or "{}")
except (json.JSONDecodeError, TypeError):
job_payload = {}
row = queue_manager.get_job(job["id"]) or {}
await event_bus.publish(
"job.completed",
{
@@ -102,6 +116,9 @@ class QueueWorker:
# Source stamp lets the UI attribute sandbox jobs
# to their enqueuer without racing the jobs poll.
"source": job_payload.get("source"),
"comfy_prompt_id": row.get("comfy_prompt_id"),
# Placeholder output — ComfyUI was never called.
"dry_run": row.get("phase") == "dry_run",
"message": f"{self._job_label(job)} · {len(outputs)} file(s)",
},
)
@@ -167,8 +184,10 @@ class QueueWorker:
client = ComfyUIClient(base_url, headers=headers)
try:
if use_dry:
queue_manager.set_phase(job["id"], "dry_run")
return await self._dry_run(job, payload)
queue_manager.set_phase(job["id"], "connecting")
healthy = await client.health()
if not healthy:
raise RuntimeError(
@@ -182,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":
@@ -191,21 +213,42 @@ class QueueWorker:
patched = patch_workflow(workflow, input_values)
patched = await client.prepare_media_inputs(patched)
prompt_id = await client.queue_prompt(patched)
queue_manager.set_phase(job["id"], "comfy_queued", prompt_id)
await event_bus.publish(
"job.progress",
{
"job_id": job["id"],
"prompt_id": prompt_id,
"phase": "comfy_queued",
"message": f"ComfyUI 已接收任务(ID {prompt_id})",
},
)
history = await self._poll_history(client, prompt_id, job["id"])
if not history:
if queue_manager.is_cancelled(job["id"]):
raise _CancelledByUser()
raise RuntimeError(
"等待 ComfyUI 出结果超时:可在「设置 → 队列」调大超时,或到 ComfyUI 检查该任务"
f"等待 ComfyUI 出结果超时(任务 ID {prompt_id}):可在「设置 → 队列」调大超时,"
"或到 ComfyUI 检查该任务"
)
status = history.get("status") or {}
if status.get("status_str") == "error" or status.get("completed") is False:
messages = status.get("messages") or []
raise RuntimeError(f"ComfyUI 执行出错:{_comfy_messages(messages)}")
raise RuntimeError(
f"ComfyUI 执行出错(任务 ID {prompt_id}):{_comfy_messages(messages)}"
)
outputs_meta = client.extract_outputs(history)
saved = [m for m in outputs_meta if m.get("type") == "output"]
outputs_meta = saved or outputs_meta
if not outputs_meta:
raise RuntimeError(
f"ComfyUI 显示任务已结束(任务 ID {prompt_id}),但没有返回任何图片或视频,"
"不算生成完成。请确认工作流带有保存/预览节点,并到 ComfyUI 历史中核对该任务。"
)
queue_manager.set_phase(job["id"], "saving")
dest_dir = config.settings.assets_dir / str(project_id) / kind
dest_dir.mkdir(parents=True, exist_ok=True)
paths: list[str] = []
@@ -221,6 +264,7 @@ class QueueWorker:
paths.append(str(dest))
self._apply_outputs_to_entities(job, payload, paths)
queue_manager.set_phase(job["id"], "finished")
return paths
finally:
await client.close()
@@ -360,6 +404,9 @@ class QueueWorker:
# configurable (default 30 min) instead of a hardcoded 600 seconds.
attempts = None if timeout <= 0 else int(timeout / interval)
n = 0
phase = "comfy_queued"
missing = 0
errors = 0
while attempts is None or n < attempts:
n += 1
if self._stop.is_set():
@@ -370,17 +417,51 @@ class QueueWorker:
if queue_manager.is_cancelled(job_id):
await client.interrupt()
return None
history = await client.get_history(prompt_id)
if history:
return history
await asyncio.sleep(interval)
try:
history = await client.get_history(prompt_id)
if history:
return history
where, ahead = await client.locate_prompt(prompt_id)
except httpx.HTTPError as exc:
errors += 1
if errors >= _POLL_ERRORS_LIMIT:
raise RuntimeError(
f"连续 {errors} 次无法从 ComfyUI 查询任务状态(任务 ID {prompt_id}):"
f"{exc or type(exc).__name__}"
) from exc
await asyncio.sleep(interval)
continue
errors = 0
if where == "missing":
missing += 1
if missing >= _MISSING_POLLS_LIMIT:
raise RuntimeError(
f"ComfyUI 的队列和历史里都找不到该任务(任务 ID {prompt_id}),"
"可能 ComfyUI 已重启或任务被清除,没有产出结果"
)
else:
missing = 0
if where == "running":
phase = "comfy_running"
message = f"ComfyUI 正在执行(任务 ID {prompt_id})"
elif where == "pending":
phase = "comfy_queued"
message = f"ComfyUI 排队中,前面还有 {ahead} 个任务(任务 ID {prompt_id})"
else:
message = f"等待 ComfyUI 返回结果(任务 ID {prompt_id})"
if job_id is not None:
queue_manager.set_phase(job_id, phase)
await event_bus.publish(
"job.progress",
{
"job_id": job_id,
"prompt_id": prompt_id,
"message": f"Waiting on ComfyUI ({prompt_id[:8]}…)",
"phase": phase,
"queue_ahead": ahead if where == "pending" else None,
"message": message,
},
)
await asyncio.sleep(interval)
return None
def _mark_project_completed(self, project_id: int) -> None:
@@ -13,6 +13,9 @@ 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
from calliope.events.bus import event_bus
from calliope.queue.manager import queue_manager
@@ -332,7 +335,7 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
try:
pid = ensure_playground_project(conn)
wf = conn.execute(
"SELECT id, kind, is_enabled FROM workflows WHERE id = ?",
"SELECT id, kind, is_enabled, workflow_json, input_schema FROM workflows WHERE id = ?",
(payload.workflow_id,),
).fetchone()
if not wf:
@@ -344,16 +347,28 @@ 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 [], workflow_json
)
finally:
conn.close()
broken = blocking_problem(workflow_json)
if broken:
raise HTTPException(status_code=400, detail=broken)
input_values, problems = validate_input_values(schema, payload.input_values)
if problems:
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
job = queue_manager.enqueue(
project_id=pid,
kind=kind,
workflow_id=payload.workflow_id,
scene_id=None,
payload={
"input_values": payload.input_values,
"input_values": input_values,
"source": payload.source,
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
},
@@ -5,7 +5,12 @@ from typing import Any
from fastapi import APIRouter, HTTPException
from calliope.comfyui.parser import parse_dynamic_inputs, parse_dynamic_outputs
from calliope.comfyui.lint import workflow_warnings
from calliope.comfyui.parser import (
effective_input_schema,
parse_dynamic_inputs,
parse_dynamic_outputs,
)
from calliope.comfyui.profiles import detect_prompt_profile
from calliope.config import settings
from calliope.db import get_db, row_to_dict
@@ -17,12 +22,11 @@ router = APIRouter()
def _serialize_workflow(row: Any) -> dict[str, Any]:
data = row_to_dict(row)
data["workflow_json"] = json.loads(data["workflow_json"])
data["input_schema"] = json.loads(data["input_schema"]) if data.get("input_schema") else []
if not data["input_schema"]:
# An empty stored schema is re-derived so untagged workflows still expose inputs.
data["input_schema"] = parse_dynamic_inputs(data["workflow_json"])
stored = json.loads(data["input_schema"]) if data.get("input_schema") else []
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
@@ -34,6 +38,7 @@ async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
"inputs": inputs,
"outputs": outputs,
"suggested_profile": detect_prompt_profile(payload.workflow_json),
"warnings": workflow_warnings(payload.workflow_json),
}
@@ -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,6 +381,10 @@ 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]},
@@ -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 == []
@@ -0,0 +1,411 @@
"""HEL-627: the two workflows the user runs (Qwen Image 2.1 文生图 and
MiniMax H3 reference 文生视频) expose every widget they really have, the
values land in the /prompt payload, and a job is only "done" once ComfyUI
returned real files — empty, failed, lost and placeholder runs never are."""
from __future__ import annotations
import json
import threading
import time
from http.server import ThreadingHTTPServer
from pathlib import Path
import pytest
from calliope.comfyui.parser import (
RESOLUTION_ASPECT_RATIOS,
effective_input_schema,
parse_dynamic_inputs,
resolution_selector_size,
)
from calliope.comfyui.validate import validate_input_values
from calliope.config import settings
from calliope.db import get_db
from tests.test_studio_generation import (
_FakeComfyHandler,
_import_workflow,
_real_run,
)
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
T2I = "wen_sheng_tu_API.json" # image_qwen_image_2_1_t2i (2).json
T2V = "wen_sheng_shipin_API.json" # 01_reference_4step_sla (1).json
def _load(name: str) -> dict:
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
def _by_role(inputs: list[dict]) -> dict[str, dict]:
return {i["role"]: i for i in inputs if i.get("role")}
class _StatusHandler(_FakeComfyHandler):
"""Fake ComfyUI with /queue and scriptable history outcomes."""
def do_GET(self):
srv = self.server
if self.path == "/queue":
if srv.queue_mode is None:
self._json(404, {"error": "not found"})
return
self.server.requests.append(self.path)
items = [[i, pid, {}, {}, []] for i, pid in enumerate(srv.history)]
if srv.queue_mode == "running":
self._json(200, {"queue_running": items, "queue_pending": []})
elif srv.queue_mode == "pending":
blocker = [[-1, "someone-else", {}, {}, []]]
self._json(200, {"queue_running": blocker, "queue_pending": items})
else:
self._json(200, {"queue_running": [], "queue_pending": []})
return
if self.path.startswith("/history/"):
if srv.lose_history or srv.hidden_history_polls > 0:
srv.hidden_history_polls = max(0, srv.hidden_history_polls - 1)
self.server.requests.append(self.path)
self._json(200, {})
return
super().do_GET()
def do_POST(self):
super().do_POST()
if self.path == "/prompt" and self.server.history_patch:
for entry in self.server.history.values():
entry.update(self.server.history_patch)
@pytest.fixture
def status_comfy():
server = ThreadingHTTPServer(("127.0.0.1", 0), _StatusHandler)
server.requests = []
server.prompts = []
server.uploads = []
server.history = {}
server.fail_prompt = False
server.out_name = "out-1.png"
server.outputs_key = "images"
server.queue_mode = None
server.lose_history = False
server.hidden_history_polls = 0
server.history_patch = None
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
def _wait_final(client, job_id: int, timeout: float = 25.0) -> dict:
deadline = time.time() + timeout
while time.time() < deadline:
for j in client.get("/api/playground/jobs").json():
if j["id"] == job_id and j["status"] in ("done", "failed"):
return j
time.sleep(0.2)
raise AssertionError(f"job {job_id} did not finish")
def _generate(client, wf_id: int, values: dict, source: str = "studio-image"):
return client.post(
"/api/playground/generate",
json={
"workflow_id": wf_id,
"input_values": values,
"source": source,
"endpoint_id": settings.comfyui_active_id,
},
)
# ---------------------------------------------------------------------------
# Parameter detection on the two real workflows
# ---------------------------------------------------------------------------
def test_real_t2i_exposes_ratio_megapixels_steps_denoise():
roles = _by_role(parse_dynamic_inputs(_load(T2I)))
assert set(roles) == {
"prompt", "negative", "aspect_ratio", "megapixels", "batch",
"seed", "steps", "cfg", "denoise",
}
ratio = roles["aspect_ratio"]
assert ratio["nodeId"] == "13@aspect_ratio"
assert ratio["kind"] == "select"
assert ratio["defaultValue"] == "1:1 (Square)"
assert ratio["options"] == list(RESOLUTION_ASPECT_RATIOS)
assert ratio["resolution"] == {"megapixels": "13@megapixels", "multiple": 8}
assert roles["megapixels"]["defaultValue"] == 1
assert roles["steps"]["nodeId"] == "459:458@steps"
assert roles["steps"]["defaultValue"] == 25 and roles["steps"]["integer"] is True
assert roles["denoise"]["nodeId"] == "459:458@denoise"
assert (roles["denoise"]["min"], roles["denoise"]["max"]) == (0.0, 1.0)
# Width/height are wired from ResolutionSelector; they are not faked as
# free inputs, and the encoder's reference-image `resolution` is not exposed.
assert "width" not in roles and "height" not in roles
assert all("resolution" not in i["nodeId"] for i in parse_dynamic_inputs(_load(T2I)))
def test_real_t2v_exposes_ratio_steps_denoise_seed_duration():
inputs = parse_dynamic_inputs(_load(T2V))
roles = _by_role(inputs)
assert set(roles) == {
"prompt", "aspect_ratio", "megapixels", "steps", "denoise", "seed", "duration",
}
assert roles["aspect_ratio"]["defaultValue"] == "16:9 (Widescreen)"
assert roles["aspect_ratio"]["resolution"]["multiple"] == 32
assert roles["megapixels"]["defaultValue"] == 0.7
assert roles["steps"]["nodeId"] == "124@steps" and roles["steps"]["defaultValue"] == 4
assert roles["seed"]["nodeId"] == "129@noise_seed"
assert roles["duration"]["nodeId"] == "133" and roles["duration"]["defaultValue"] == 10
# BasicGuider has no CFG; the frame expression hard-codes 24 fps, so no fps field.
assert "cfg" not in roles and "fps" not in roles
def test_resolution_matches_comfyui_resolution_selector():
# The H3 workflow's own title documents 1152x640 for 16:9 · 0.7 MP · ×32.
assert resolution_selector_size("16:9 (Widescreen)", 0.7, 32) == (1152, 640)
assert resolution_selector_size("1:1 (Square)", 1, 8) == (1024, 1024)
assert resolution_selector_size("9:16 (Portrait Widescreen)", 1, 8) == (768, 1368)
def test_validation_rejects_bad_values_in_chinese():
schema = parse_dynamic_inputs(_load(T2I))
clean, errors = validate_input_values(
schema,
{
"459:452@prompt": "猫",
"13@aspect_ratio": "5:4",
"459:458@steps": 12.5,
"459:458@denoise": 1.5,
"13@megapixels": "abc",
},
)
assert any("画面比例" in e and "5:4" in e for e in errors)
assert any("采样步数" in e and "整数" in e for e in errors)
assert any("降噪强度" in e and "不能大于 1" in e for e in errors)
assert any("总像素" in e and "数字" in e for e in errors)
clean, errors = validate_input_values(schema, {"459:458@steps": "30", "13@megapixels": 2})
assert errors == [] # an omitted prompt falls back to the workflow's own text
assert clean["459:458@steps"] == 30 and isinstance(clean["459:458@steps"], int)
blank = [dict(e, defaultValue="") if e.get("role") == "prompt" else e for e in schema]
_, errors = validate_input_values(blank, {"459:452@prompt": " "})
assert errors == ["请填写「正向提示词」"]
def test_stale_auto_schema_is_refreshed_without_reimport(client):
wf = _load(T2I)
stale = [
{"nodeId": "459:452@prompt", "label": "正向提示词", "role": "prompt",
"kind": "textarea", "defaultValue": "x", "required": True, "auto": True},
]
conn = get_db(settings.db_path)
try:
cur = conn.execute(
"INSERT INTO workflows"
" (name, kind, workflow_json, input_schema, output_schema, is_enabled)"
" VALUES (?, 'image', ?, ?, '[]', 1)",
("旧导入·Qwen2.1", json.dumps(wf), json.dumps(stale)),
)
conn.commit()
wf_id = cur.lastrowid
finally:
conn.close()
roles = _by_role(client.get(f"/api/workflows/{wf_id}").json()["input_schema"])
assert {"aspect_ratio", "megapixels", "steps", "denoise"} <= set(roles)
def test_tagged_schema_is_kept_as_stored():
stored = [{"nodeId": "6", "label": "P", "role": "prompt", "kind": "textarea",
"required": True}]
assert effective_input_schema(stored, _load(T2I)) == stored
# ---------------------------------------------------------------------------
# Submit payload + job status against a fake ComfyUI
# ---------------------------------------------------------------------------
def test_t2i_values_reach_the_prompt_payload(client, status_comfy):
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(参数)", "image", _load(T2I))
r = _generate(
client,
wf_id,
{
"459:452@prompt": "雨夜霓虹街道",
"459:452@negative_prompt": "模糊",
"13@aspect_ratio": "16:9 (Widescreen)",
"13@megapixels": 2,
"459:458@steps": "30",
"459:458@cfg": 2.5,
"459:458@denoise": 0.8,
"459:458@seed": 7,
},
)
assert r.status_code == 200, r.text
job = _wait_final(client, r.json()["job"]["id"])
assert job["status"] == "done", job.get("error")
queued = status_comfy.prompts[-1]["prompt"]
assert queued["13"]["inputs"]["aspect_ratio"] == "16:9 (Widescreen)"
assert queued["13"]["inputs"]["megapixels"] == 2
assert queued["459:458"]["inputs"]["steps"] == 30
assert queued["459:458"]["inputs"]["cfg"] == 2.5
assert queued["459:458"]["inputs"]["denoise"] == 0.8
assert queued["459:458"]["inputs"]["seed"] == 7
assert queued["459:452"]["inputs"]["prompt"] == "雨夜霓虹街道"
# Wiring intact: the latent still takes its size from the selector.
assert queued["459:456"]["inputs"]["width"] == ["13", 0]
assert queued["459:456"]["inputs"]["height"] == ["13", 1]
assert job["comfy_prompt_id"] in status_comfy.history
assert job["phase"] == "finished"
assert job["output_paths"] and Path(job["output_paths"][0]).stat().st_size > 0
def test_t2v_values_reach_the_prompt_payload(client, status_comfy):
status_comfy.outputs_key = "images" # SaveVideo reports under "images" + animated
status_comfy.out_name = "clip.mp4"
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·H3 参考生视频", "video", _load(T2V))
r = _generate(
client,
wf_id,
{
"1": "地铁车厢里的长镜头",
"115@aspect_ratio": "9:16 (Portrait Widescreen)",
"115@megapixels": 0.5,
"124@steps": 6,
"124@denoise": 1,
"129@noise_seed": 1234,
"133": 5,
},
source="studio-video",
)
assert r.status_code == 200, r.text
job = _wait_final(client, r.json()["job"]["id"])
assert job["status"] == "done", job.get("error")
queued = status_comfy.prompts[-1]["prompt"]
assert queued["1"]["inputs"]["value"] == "地铁车厢里的长镜头"
assert queued["115"]["inputs"]["aspect_ratio"] == "9:16 (Portrait Widescreen)"
assert queued["115"]["inputs"]["megapixels"] == 0.5
assert queued["124"]["inputs"]["steps"] == 6
assert queued["129"]["inputs"]["noise_seed"] == 1234
assert queued["133"]["inputs"]["value"] == 5
assert queued["132"]["inputs"]["values.a"] == ["133", 0]
def test_invalid_values_are_rejected_before_queueing(client, status_comfy):
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(校验)", "image", _load(T2I))
r = _generate(client, wf_id, {"459:452@prompt": "猫", "459:458@steps": 0})
assert r.status_code == 400
assert "采样步数" in r.json()["detail"] and "不能小于 1" in r.json()["detail"]
r = _generate(client, wf_id, {"459:452@prompt": "猫", "13@megapixels": 99})
assert r.status_code == 400 and "总像素" in r.json()["detail"]
assert "/prompt" not in status_comfy.requests
def test_empty_outputs_never_count_as_done(client, status_comfy):
status_comfy.history_patch = {"outputs": {}}
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(空结果)", "image", _load(T2I))
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
assert job["status"] == "failed"
assert "没有返回任何图片或视频" in job["error"]
assert job["comfy_prompt_id"] and job["comfy_prompt_id"] in job["error"]
assert job["output_paths"] == []
def test_comfy_execution_error_is_failed_with_reason(client, status_comfy):
status_comfy.history_patch = {
"status": {
"status_str": "error",
"completed": False,
"messages": [
["execution_start", {}],
["execution_error", {"node_type": "UNETLoader",
"exception_message": "模型文件不存在"}],
],
},
"outputs": {},
}
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(执行错)", "image", _load(T2I))
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
assert job["status"] == "failed"
assert "ComfyUI 执行出错" in job["error"] and "UNETLoader" in job["error"]
assert "模型文件不存在" in job["error"]
def test_prompt_lost_from_queue_and_history_fails(client, status_comfy):
status_comfy.lose_history = True
status_comfy.queue_mode = "missing"
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(丢失)", "image", _load(T2I))
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
assert job["status"] == "failed"
assert "队列和历史里都找不到该任务" in job["error"]
def test_running_then_finished_reports_real_phase(client, status_comfy):
status_comfy.queue_mode = "running"
status_comfy.hidden_history_polls = 3
with _real_run(client, status_comfy):
wf_id = _import_workflow(client, "真实·Qwen2.1(执行中)", "image", _load(T2I))
job_id = _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"]
seen: set[str] = set()
deadline = time.time() + 20
while time.time() < deadline:
row = next(j for j in client.get("/api/playground/jobs").json() if j["id"] == job_id)
if row.get("phase"):
seen.add(row["phase"])
if row["status"] in ("done", "failed"):
break
time.sleep(0.1)
assert row["status"] == "done", row.get("error")
assert "comfy_running" in seen
assert "/queue" in status_comfy.requests
def test_missing_model_rejection_names_the_file():
"""Body captured from the user's ComfyUI 0.37.4 when Qwen 2.1 models are absent."""
import httpx
from calliope.comfyui.client import _surface_error
body = {
"error": {"type": "prompt_outputs_failed_validation"},
"node_errors": {
"459:451": {
"errors": [{
"type": "value_not_in_list",
"message": "Value not in list",
"details": "unet_name: 'qwen_image_2.1_int8_convrot.safetensors' not in [...]",
"extra_info": {
"input_name": "unet_name",
"received_value": "qwen_image_2.1_int8_convrot.safetensors",
},
}],
"class_type": "UNETLoader",
}
},
}
msg = str(_surface_error("ComfyUI 拒绝执行该工作流", httpx.Response(400, json=body)))
assert "节点 459:451(UNETLoader)" in msg
assert "qwen_image_2.1_int8_convrot.safetensors" in msg and "模型文件未安装" in msg
def test_dry_run_result_is_marked_placeholder(client):
wf_id = _import_workflow(client, "真实·Qwen2.1(dry)", "image", _load(T2I))
prev = settings.dry_run
settings.dry_run = True
try:
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
finally:
settings.dry_run = prev
assert job["status"] == "done"
assert job["phase"] == "dry_run"
assert job["comfy_prompt_id"] is None
@@ -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():
+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;
+41
View File
@@ -0,0 +1,41 @@
/**
* ComfyUI ResolutionSelector (comfy_extras/nodes_resolution.py) — same
* ratios and rounding, so the size shown is the size ComfyUI renders.
*/
const RATIOS: Record<string, [number, number]> = {
'1:1 (Square)': [1, 1],
'2:3 (Portrait Photo)': [2, 3],
'3:2 (Photo)': [3, 2],
'3:4 (Portrait Standard)': [3, 4],
'4:3 (Standard)': [4, 3],
'9:16 (Portrait Widescreen)': [9, 16],
'16:9 (Widescreen)': [16, 9],
'21:9 (Ultrawide)': [21, 9],
};
/** Python's round(): halves go to the even neighbour. */
function roundHalfEven(x: number): number {
const f = Math.floor(x);
const diff = x - f;
if (diff > 0.5) return f + 1;
if (diff < 0.5) return f;
return f % 2 === 0 ? f : f + 1;
}
export function resolutionSelectorSize(
aspectRatio: string,
megapixels: number,
multiple: number,
): { width: number; height: number } | null {
const ratio = RATIOS[aspectRatio];
if (!ratio || !Number.isFinite(megapixels) || megapixels <= 0) return null;
const [w, h] = ratio;
const m = Math.max(1, Math.floor(multiple) || 8);
const scale = Math.sqrt((megapixels * 1024 * 1024) / (w * h));
return { width: roundHalfEven((w * scale) / m) * m, height: roundHalfEven((h * scale) / m) * m };
}
/** "16:9 (Widescreen)" → "16:9". */
export function ratioShort(option: string): string {
return option.split(' ')[0] ?? option;
}
+33 -1
View File
@@ -1,6 +1,24 @@
export type ComfyInputKind = 'text' | 'textarea' | 'number' | 'image' | 'image_url' | 'audio' | 'video';
export type ComfyInputKind =
| 'text'
| 'textarea'
| 'number'
| 'select'
| 'image'
| 'image_url'
| 'audio'
| 'video'
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
| 'image_list';
export type ComfyOutputKind = 'image' | 'video' | 'other';
/** Structural finding on an imported workflow; `error` blocks generation. */
export interface WorkflowWarning {
level: 'error' | 'warning';
code: string;
node_id: string;
message: string;
}
export interface ComfyDynamicInput {
nodeId: string;
label: string;
@@ -11,6 +29,15 @@ export interface ComfyDynamicInput {
required: boolean;
/** Inferred from node classes/wiring — the workflow carries no (Input) tags. */
auto?: boolean;
/** ComfyUI widget limits (number kind). */
min?: number;
max?: number;
step?: number;
integer?: boolean;
/** Allowed values (select kind). */
options?: string[];
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
resolution?: { megapixels: string; multiple: number };
}
export interface ComfyDynamicOutput {
@@ -39,6 +66,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 {
@@ -57,6 +85,10 @@ export interface Job {
started_at: string | null;
completed_at: string | null;
retry_count: number;
/** Prompt id ComfyUI accepted this job under (null until /prompt succeeds). */
comfy_prompt_id?: string | null;
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
phase?: string | null;
}
export interface SceneVideoSettings {
@@ -260,6 +260,18 @@
oninput={(e) => setNumberValue(inp.nodeId, e.currentTarget.value)}
onblur={() => markTouched(inp.nodeId)}
/>
{:else if inp.kind === 'select'}
<select
{id}
class:invalid={errored}
value={current || String(inp.defaultValue ?? '')}
onchange={(e) => setValue(inp.nodeId, e.currentTarget.value)}
onblur={() => markTouched(inp.nodeId)}
>
{#each inp.options ?? [] as opt (opt)}
<option value={opt}>{opt}</option>
{/each}
</select>
{:else if allowUpload && isUploadKind(inp.kind)}
{@const uploadingName = uploading[inp.nodeId]}
{@const isUpload = !!current && !assetOptions.some((o) => o.path === current)}
@@ -346,6 +346,16 @@
value={values[nodeId] ?? ctrl.input.defaultValue ?? ''}
oninput={(e) => setValue(nodeId, Number(e.currentTarget.value) || 0)}
/>
{:else if ctrl.input.kind === 'select'}
<select
class="adv-input"
value={String(values[nodeId] ?? ctrl.input.defaultValue ?? '')}
onchange={(e) => setValue(nodeId, e.currentTarget.value)}
>
{#each ctrl.input.options ?? [] as opt (opt)}
<option value={opt}>{opt}</option>
{/each}
</select>
{:else}
<input
class="adv-input"
@@ -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>
File diff suppressed because it is too large Load Diff
+65
View File
@@ -1398,6 +1398,8 @@ 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',
'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 +1501,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',
@@ -1761,4 +1791,39 @@ export const de: Dict = {
'home.errScriptShort': 'Drehbuch zu kurz — prüfe den eingefügten Text (mind. 30 Zeichen)',
'home.stageProgress': 'Fünf-Phasen-Fortschritt',
'tasks.sourceWorkbench': 'Kreativ-Fluss',
'jobst.idSuffix': ' · ComfyUI-Prompt-ID {id}',
'jobst.done': 'Fertig',
'jobst.doneDetail': 'ComfyUI ist fertig, Ergebnisse gespeichert',
'jobst.dryRun': 'Platzhalter',
'jobst.dryRunDetail': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
'jobst.noOutput': 'Keine Ausgabe',
'jobst.noOutputDetail': 'Der Job endete ohne Ergebnisdatei – keine fertige Generierung',
'jobst.queued': 'Wartet',
'jobst.queuedDetail': 'Wartet in der Workbench-Warteschlange auf ComfyUI',
'jobst.connecting': 'Verbinde',
'jobst.connectingDetail': 'Verbinde mit ComfyUI und lade Eingaben hoch',
'jobst.comfyQueued': 'Angenommen',
'jobst.comfyQueuedDetail': 'ComfyUI hat den Job angenommen und eingereiht',
'jobst.comfyQueuedAhead': 'ComfyUI hat den Job angenommen – {n} davor in der Warteschlange',
'jobst.comfyRunning': 'Läuft',
'jobst.comfyRunningDetail': 'ComfyUI führt den Job aus',
'jobst.saving': 'Speichert',
'jobst.savingDetail': 'ComfyUI hat Ergebnisse geliefert, lade herunter',
'jobst.failed': 'Fehlgeschlagen',
'jobst.cancelled': 'Abgebrochen',
'studio.ratioLabel': 'Seitenverhältnis',
'studio.megapixelsLabel': 'Megapixel',
'studio.outputSize': 'Ausgabe {w} × {h}',
'studio.denoiseLabel': 'Entrauschen',
'studio.errNumber': '„{label}“ muss eine Zahl sein',
'studio.errInteger': '„{label}“ muss eine ganze Zahl sein',
'studio.errMin': '„{label}“ muss mindestens {v} sein',
'studio.errMax': '„{label}“ darf höchstens {v} sein',
'studio.errOption': '„{label}“ ist keine Option des Workflows',
'studio.reuseParams': 'Einstellungen übernehmen',
'studio.reusedToast': 'Einstellungen dieses Jobs übernommen – anpassen und neu generieren',
'studio.reuseMissingWf': 'Der Workflow dieses Jobs existiert nicht mehr',
'studio.doneToast': 'Fertig: {n} Datei(en) (ComfyUI-Prompt-ID {id})',
'studio.dryRunToast': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
'studio.dryRunNote': 'Dry-run ist aktiv: Generieren schreibt nur Platzhalter und ruft ComfyUI nie auf. In den Einstellungen abschalten.',
};
+65
View File
@@ -1398,6 +1398,8 @@ 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',
'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 +1500,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',
@@ -1760,6 +1790,41 @@ export const en = {
'home.errScriptShort': 'The script looks too short — check the paste is complete (min 30 characters)',
'home.stageProgress': 'Five-stage progress',
'tasks.sourceWorkbench': 'Creation flow',
'jobst.idSuffix': ' · ComfyUI prompt ID {id}',
'jobst.done': 'Done',
'jobst.doneDetail': 'ComfyUI finished and the results are saved',
'jobst.dryRun': 'Placeholder',
'jobst.dryRunDetail': 'Placeholder only: Dry-run is on, ComfyUI was not called',
'jobst.noOutput': 'No output',
'jobst.noOutputDetail': 'The job ended without any result file — not a finished generation',
'jobst.queued': 'Queued',
'jobst.queuedDetail': 'Waiting in the workbench queue to be sent to ComfyUI',
'jobst.connecting': 'Connecting',
'jobst.connectingDetail': 'Connecting to ComfyUI and uploading inputs',
'jobst.comfyQueued': 'Accepted',
'jobst.comfyQueuedDetail': 'ComfyUI accepted the job and queued it',
'jobst.comfyQueuedAhead': 'ComfyUI accepted the job — {n} ahead in its queue',
'jobst.comfyRunning': 'Running',
'jobst.comfyRunningDetail': 'ComfyUI is running the job',
'jobst.saving': 'Saving',
'jobst.savingDetail': 'ComfyUI returned results, downloading them',
'jobst.failed': 'Failed',
'jobst.cancelled': 'Cancelled',
'studio.ratioLabel': 'Aspect ratio',
'studio.megapixelsLabel': 'Megapixels',
'studio.outputSize': 'Output {w} × {h}',
'studio.denoiseLabel': 'Denoise',
'studio.errNumber': '“{label}” must be a number',
'studio.errInteger': '“{label}” must be a whole number',
'studio.errMin': '“{label}” must be at least {v}',
'studio.errMax': '“{label}” must be at most {v}',
'studio.errOption': '“{label}” is not one of the workflow\'s options',
'studio.reuseParams': 'Reuse settings',
'studio.reusedToast': 'Settings from this job are filled in — adjust and generate again',
'studio.reuseMissingWf': 'The workflow this job used no longer exists',
'studio.doneToast': 'Done: {n} file(s) (ComfyUI prompt ID {id})',
'studio.dryRunToast': 'Placeholder only: Dry-run is on, ComfyUI was not called',
'studio.dryRunNote': 'Dry-run is on: Generate only writes placeholder files and never calls ComfyUI. Turn it off in Settings.',
};
export type Dict = typeof en;
+65
View File
@@ -1398,6 +1398,8 @@ 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',
'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 +1501,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',
@@ -1761,4 +1791,39 @@ export const es: Dict = {
'home.errScriptShort': 'El guion es demasiado corto — revisa el pegado (mín. 30 caracteres)',
'home.stageProgress': 'Progreso de cinco etapas',
'tasks.sourceWorkbench': 'Flujo de creación',
'jobst.idSuffix': ' · ID de prompt de ComfyUI {id}',
'jobst.done': 'Listo',
'jobst.doneDetail': 'ComfyUI terminó y los resultados se guardaron',
'jobst.dryRun': 'Marcador',
'jobst.dryRunDetail': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
'jobst.noOutput': 'Sin salida',
'jobst.noOutputDetail': 'El trabajo terminó sin archivos de resultado: no cuenta como generado',
'jobst.queued': 'En cola',
'jobst.queuedDetail': 'Esperando en la cola del taller para enviarse a ComfyUI',
'jobst.connecting': 'Conectando',
'jobst.connectingDetail': 'Conectando con ComfyUI y subiendo entradas',
'jobst.comfyQueued': 'Aceptado',
'jobst.comfyQueuedDetail': 'ComfyUI aceptó el trabajo y lo puso en cola',
'jobst.comfyQueuedAhead': 'ComfyUI aceptó el trabajo: {n} por delante en su cola',
'jobst.comfyRunning': 'Ejecutando',
'jobst.comfyRunningDetail': 'ComfyUI está ejecutando el trabajo',
'jobst.saving': 'Guardando',
'jobst.savingDetail': 'ComfyUI devolvió resultados, descargándolos',
'jobst.failed': 'Fallido',
'jobst.cancelled': 'Cancelado',
'studio.ratioLabel': 'Relación de aspecto',
'studio.megapixelsLabel': 'Megapíxeles',
'studio.outputSize': 'Salida {w} × {h}',
'studio.denoiseLabel': 'Eliminación de ruido',
'studio.errNumber': '«{label}» debe ser un número',
'studio.errInteger': '«{label}» debe ser un número entero',
'studio.errMin': '«{label}» debe ser al menos {v}',
'studio.errMax': '«{label}» debe ser como máximo {v}',
'studio.errOption': '«{label}» no es una opción del flujo',
'studio.reuseParams': 'Reutilizar ajustes',
'studio.reusedToast': 'Ajustes de este trabajo aplicados: modifícalos y genera de nuevo',
'studio.reuseMissingWf': 'El flujo que usó este trabajo ya no existe',
'studio.doneToast': 'Listo: {n} archivo(s) (ID de prompt de ComfyUI {id})',
'studio.dryRunToast': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
'studio.dryRunNote': 'Dry-run activo: Generar solo escribe marcadores y nunca llama a ComfyUI. Desactívalo en Ajustes.',
};
+65
View File
@@ -1398,6 +1398,8 @@ 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',
'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 +1501,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',
@@ -1761,4 +1791,39 @@ export const fr: Dict = {
'home.errScriptShort': 'Scénario trop court — vérifiez le collage (30 caractères min.)',
'home.stageProgress': 'Progression en cinq étapes',
'tasks.sourceWorkbench': 'Flux de création',
'jobst.idSuffix': ' · ID de prompt ComfyUI {id}',
'jobst.done': 'Terminé',
'jobst.doneDetail': 'ComfyUI a terminé, résultats enregistrés',
'jobst.dryRun': 'Substitut',
'jobst.dryRunDetail': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
'jobst.noOutput': 'Aucune sortie',
'jobst.noOutputDetail': 'La tâche s\'est terminée sans fichier de résultat : pas une génération',
'jobst.queued': 'En attente',
'jobst.queuedDetail': 'En attente dans la file de l\'atelier avant envoi à ComfyUI',
'jobst.connecting': 'Connexion',
'jobst.connectingDetail': 'Connexion à ComfyUI et envoi des entrées',
'jobst.comfyQueued': 'Acceptée',
'jobst.comfyQueuedDetail': 'ComfyUI a accepté la tâche et l\'a mise en file',
'jobst.comfyQueuedAhead': 'ComfyUI a accepté la tâche — {n} devant dans sa file',
'jobst.comfyRunning': 'En cours',
'jobst.comfyRunningDetail': 'ComfyUI exécute la tâche',
'jobst.saving': 'Enregistrement',
'jobst.savingDetail': 'ComfyUI a renvoyé des résultats, téléchargement',
'jobst.failed': 'Échec',
'jobst.cancelled': 'Annulée',
'studio.ratioLabel': 'Format d\'image',
'studio.megapixelsLabel': 'Mégapixels',
'studio.outputSize': 'Sortie {w} × {h}',
'studio.denoiseLabel': 'Débruitage',
'studio.errNumber': '« {label} » doit être un nombre',
'studio.errInteger': '« {label} » doit être un entier',
'studio.errMin': '« {label} » doit valoir au moins {v}',
'studio.errMax': '« {label} » doit valoir au plus {v}',
'studio.errOption': '« {label} » n\'est pas une option du workflow',
'studio.reuseParams': 'Réutiliser les réglages',
'studio.reusedToast': 'Réglages de cette tâche repris : ajustez puis relancez',
'studio.reuseMissingWf': 'Le workflow de cette tâche n\'existe plus',
'studio.doneToast': 'Terminé : {n} fichier(s) (ID de prompt ComfyUI {id})',
'studio.dryRunToast': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
'studio.dryRunNote': 'Dry-run actif : Générer n\'écrit que des substituts et n\'appelle jamais ComfyUI. Désactivez-le dans Réglages.',
};
+65
View File
@@ -1398,6 +1398,8 @@ 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',
'conn.missingMaterial': 'Qwen Image 2.1 専用のワークフロー JSON はまだリポジトリにありません。ComfyUI から書き出してここで取り込んでください。汎用の取り込み経路は用意済みです。',
// --- 独立画像/動画生成(a3/a4、HEL-612)---
@@ -1499,6 +1501,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': 'タスク記録を削除',
@@ -1761,4 +1791,39 @@ export const ja: Dict = {
'home.errScriptShort': '脚本が短すぎます。貼り付けが完全か確認してください(30 文字以上)',
'home.stageProgress': '五段階の進捗',
'tasks.sourceWorkbench': '制作フロー',
'jobst.idSuffix': ' · ComfyUI プロンプト ID {id}',
'jobst.done': '完了',
'jobst.doneDetail': 'ComfyUI の実行が完了し、結果を保存しました',
'jobst.dryRun': 'プレースホルダー',
'jobst.dryRunDetail': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
'jobst.noOutput': '出力なし',
'jobst.noOutputDetail': '結果ファイルなしで終了しました(生成完了ではありません)',
'jobst.queued': '待機中',
'jobst.queuedDetail': 'ワークベンチのキューで ComfyUI への送信を待っています',
'jobst.connecting': '接続中',
'jobst.connectingDetail': 'ComfyUI に接続して素材をアップロードしています',
'jobst.comfyQueued': '受付済み',
'jobst.comfyQueuedDetail': 'ComfyUI が受け付け、実行待ちです',
'jobst.comfyQueuedAhead': 'ComfyUI が受け付けました。前に {n} 件あります',
'jobst.comfyRunning': '実行中',
'jobst.comfyRunningDetail': 'ComfyUI が実行中です',
'jobst.saving': '保存中',
'jobst.savingDetail': 'ComfyUI の結果をダウンロードしています',
'jobst.failed': '失敗',
'jobst.cancelled': 'キャンセル済み',
'studio.ratioLabel': 'アスペクト比',
'studio.megapixelsLabel': '総画素数(MP)',
'studio.outputSize': '出力 {w} × {h}',
'studio.denoiseLabel': 'ノイズ除去',
'studio.errNumber': '「{label}」は数値で入力してください',
'studio.errInteger': '「{label}」は整数で入力してください',
'studio.errMin': '「{label}」は {v} 以上にしてください',
'studio.errMax': '「{label}」は {v} 以下にしてください',
'studio.errOption': '「{label}」はワークフローの選択肢にありません',
'studio.reuseParams': '設定を再利用',
'studio.reusedToast': 'このジョブの設定を反映しました。調整して再生成できます',
'studio.reuseMissingWf': 'このジョブのワークフローはもう存在しません',
'studio.doneToast': '完了:{n} ファイル(ComfyUI プロンプト ID {id})',
'studio.dryRunToast': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
'studio.dryRunNote': 'Dry-run が有効です:生成はプレースホルダーを書き出すだけで ComfyUI を呼び出しません。設定でオフにできます。',
};
+65
View File
@@ -1398,6 +1398,8 @@ 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',
'conn.missingMaterial': 'Qwen Image 2.1 전용 워크플로 JSON이 아직 저장소에 없습니다. ComfyUI에서 내보내 여기서 가져오세요. 범용 가져오기 경로는 준비되어 있습니다.',
// --- 독립 이미지/비디오 생성 (a3/a4, HEL-612) ---
@@ -1499,6 +1501,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': '작업 기록 삭제',
@@ -1761,4 +1791,39 @@ export const ko: Dict = {
'home.errScriptShort': '대본이 너무 짧습니다. 붙여넣기가 완전한지 확인하세요(30자 이상)',
'home.stageProgress': '5단계 진행',
'tasks.sourceWorkbench': '제작 흐름',
'jobst.idSuffix': ' · ComfyUI 프롬프트 ID {id}',
'jobst.done': '완료',
'jobst.doneDetail': 'ComfyUI 실행이 끝나 결과를 저장했습니다',
'jobst.dryRun': '자리표시자',
'jobst.dryRunDetail': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
'jobst.noOutput': '출력 없음',
'jobst.noOutputDetail': '결과 파일 없이 끝났습니다 — 생성 완료가 아닙니다',
'jobst.queued': '대기 중',
'jobst.queuedDetail': '워크벤치 대기열에서 ComfyUI 전송을 기다리는 중',
'jobst.connecting': '연결 중',
'jobst.connectingDetail': 'ComfyUI에 연결하고 입력을 업로드하는 중',
'jobst.comfyQueued': '접수됨',
'jobst.comfyQueuedDetail': 'ComfyUI가 작업을 접수해 대기열에 넣었습니다',
'jobst.comfyQueuedAhead': 'ComfyUI가 접수함 — 앞에 {n}개 대기',
'jobst.comfyRunning': '실행 중',
'jobst.comfyRunningDetail': 'ComfyUI가 실행 중입니다',
'jobst.saving': '저장 중',
'jobst.savingDetail': 'ComfyUI 결과를 내려받는 중',
'jobst.failed': '실패',
'jobst.cancelled': '취소됨',
'studio.ratioLabel': '화면 비율',
'studio.megapixelsLabel': '메가픽셀',
'studio.outputSize': '출력 {w} × {h}',
'studio.denoiseLabel': '디노이즈',
'studio.errNumber': '‘{label}’은(는) 숫자여야 합니다',
'studio.errInteger': '‘{label}’은(는) 정수여야 합니다',
'studio.errMin': '‘{label}’은(는) {v} 이상이어야 합니다',
'studio.errMax': '‘{label}’은(는) {v} 이하여야 합니다',
'studio.errOption': '‘{label}’ 값이 워크플로 옵션에 없습니다',
'studio.reuseParams': '설정 재사용',
'studio.reusedToast': '이 작업의 설정을 채웠습니다. 수정 후 다시 생성하세요',
'studio.reuseMissingWf': '이 작업의 워크플로가 더 이상 없습니다',
'studio.doneToast': '완료: 파일 {n}개 (ComfyUI 프롬프트 ID {id})',
'studio.dryRunToast': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
'studio.dryRunNote': 'Dry-run이 켜져 있습니다: 생성은 자리표시자만 쓰고 ComfyUI를 호출하지 않습니다. 설정에서 끌 수 있습니다.',
};
+66
View File
@@ -1398,6 +1398,8 @@ export const zh: Dict = {
'conn.wfJsonInvalid': '不是有效的 API 格式 JSON(应为节点 id 到 class_type 的对象)',
'conn.wfJsonPh': '在此粘贴 ComfyUI 导出的 API 格式 JSON,或点右侧按钮选择文件',
'conn.wfDeleted': '工作流已删除',
'conn.wfImportedBroken': '已导入,但工作流文件不完整,生成前需重新导出:{detail}',
'conn.wfBrokenBadge': '文件不完整,无法生成',
'conn.missingMaterial': 'Qwen Image 2.1 的专用工作流 JSON 仓库中暂缺:请从 ComfyUI 导出后在此导入,通用导入通道已就绪。',
// --- 独立生图 / 生视频(a3/a4,HEL-612)---
'studio.image.title': '生图',
@@ -1498,6 +1500,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': '删除任务记录',
@@ -1760,4 +1791,39 @@ export const zh: Dict = {
'home.errScriptShort': '剧本内容太短,请检查粘贴是否完整(至少 30 字)',
'home.stageProgress': '五段进度',
'tasks.sourceWorkbench': '创作流程',
'jobst.idSuffix': ' · ComfyUI 任务 ID {id}',
'jobst.done': '完成',
'jobst.doneDetail': 'ComfyUI 已执行完成,结果已保存',
'jobst.dryRun': '占位结果',
'jobst.dryRunDetail': '占位结果:当前开启了 Dry-run,没有调用 ComfyUI,不是真实生成',
'jobst.noOutput': '无产出',
'jobst.noOutputDetail': '任务结束但没有任何结果文件,不算生成完成',
'jobst.queued': '排队中',
'jobst.queuedDetail': '在工作台队列中等待提交到 ComfyUI',
'jobst.connecting': '连接中',
'jobst.connectingDetail': '正在连接 ComfyUI 并上传素材',
'jobst.comfyQueued': '已接收',
'jobst.comfyQueuedDetail': 'ComfyUI 已接收,排队等待执行',
'jobst.comfyQueuedAhead': 'ComfyUI 已接收,前面还有 {n} 个任务',
'jobst.comfyRunning': '执行中',
'jobst.comfyRunningDetail': 'ComfyUI 正在执行',
'jobst.saving': '保存中',
'jobst.savingDetail': 'ComfyUI 已出结果,正在下载保存',
'jobst.failed': '失败',
'jobst.cancelled': '已取消',
'studio.ratioLabel': '画面比例',
'studio.megapixelsLabel': '总像素(百万)',
'studio.outputSize': '输出 {w} × {h}',
'studio.denoiseLabel': '降噪',
'studio.errNumber': '「{label}」必须是数字',
'studio.errInteger': '「{label}」必须是整数',
'studio.errMin': '「{label}」不能小于 {v}',
'studio.errMax': '「{label}」不能大于 {v}',
'studio.errOption': '「{label}」的取值不在工作流支持的选项中',
'studio.reuseParams': '复用参数',
'studio.reusedToast': '已回填该任务的参数,可修改后重新生成',
'studio.reuseMissingWf': '该任务使用的工作流已不存在,无法回填',
'studio.doneToast': '生成完成:{n} 个文件(ComfyUI 任务 ID {id})',
'studio.dryRunToast': '占位完成:当前开启了 Dry-run,没有调用 ComfyUI,结果不是真实生成',
'studio.dryRunNote': '已开启 Dry-run:点生成只会写占位文件,不会调用 ComfyUI。可在「设置」中关闭。',
};
+12 -7
View File
@@ -8,14 +8,14 @@ import { t } from './i18n.svelte';
* Backend reality (see calliope-backend/src/calliope/queue/worker.py):
* - `job.created` { job_id, kind, ... }
* - `job.started` { job_id, kind, message } ← message = job label
* - `job.progress` { prompt_id, message } ← NO job_id, NO percent
* - `job.progress` { job_id, prompt_id, phase, queue_ahead, message } ← NO percent
* - `job.completed` { job_id, outputs, message }
* - `job.failed` { job_id, error, message }
* - `job.deleted` { job_id }
*
* Because progress ticks carry neither a job id nor a percent, the store:
* 1. Attributes a tick to the currently running job (tracked from job.started;
* explicit `job_id` wins if a future backend sends one).
* Because progress ticks carry no percent (and ticks without a job id exist), the store:
* 1. Attributes a tick to its `job_id`, else to the currently running job
* (tracked from job.started).
* 2. Honors a numeric payload field (`progress` / `percent` / `value`, 0–1 or
* 0–100) when present, otherwise synthesizes an asymptotic value that
* approaches TICK_CAP but never reaches it — only job.completed sets 100.
@@ -32,6 +32,10 @@ export type JobProgressEntry = {
progress: number;
/** Latest human-readable status line from the worker. */
message?: string;
/** Worker phase from job.progress (comfy_queued / comfy_running …). */
phase?: string;
/** Prompts ahead of this one in ComfyUI's own queue (comfy_queued only). */
queueAhead?: number | null;
/** True once the job reached a terminal state (done/failed). */
final: boolean;
/** Terminal status when final: 'done' | 'failed'. */
@@ -115,6 +119,8 @@ class JobProgressStore {
this.map.set(id, {
progress,
message: str(d.message) || prev?.message,
phase: str(d.phase) || prev?.phase,
queueAhead: d.queue_ahead === undefined ? prev?.queueAhead : num(d.queue_ahead),
final: false,
updatedAt: Date.now(),
});
@@ -151,9 +157,8 @@ class JobProgressStore {
}
/**
* Today's job.progress payload is {prompt_id, message} with no job id.
* Attribute the tick to the only running job, else the most recently
* started one. Explicit job_id wins when a backend sends it.
* Explicit job_id wins; a tick without one goes to the only running job,
* else the most recently started one.
*/
private resolveJobId(d: Record<string, unknown>): number | null {
const explicit = num(d.job_id);
+53
View File
@@ -0,0 +1,53 @@
import type { Job } from './comfy/types';
import { progressFor } from './jobProgress';
import { t } from './i18n.svelte';
export type JobStatusTone = 'ok' | 'run' | 'queue' | 'fail';
export interface JobStatusInfo {
tone: JobStatusTone;
/** Short label for status columns. */
label: string;
/** Full sentence for the task card / tooltip. */
detail: string;
}
/**
* What a generation job really is — "done" only when ComfyUI returned files.
* A dry-run placeholder or a done row without outputs never reads as done.
*/
export function jobStatusInfo(job: Job): JobStatusInfo {
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase ?? null;
const idSuffix = job.comfy_prompt_id ? t('jobst.idSuffix', { id: job.comfy_prompt_id }) : '';
switch (job.status) {
case 'done':
if (phase === 'dry_run')
return { tone: 'queue', label: t('jobst.dryRun'), detail: t('jobst.dryRunDetail') };
if (!(job.output_paths?.length ?? 0))
return { tone: 'fail', label: t('jobst.noOutput'), detail: t('jobst.noOutputDetail') };
return { tone: 'ok', label: t('jobst.done'), detail: t('jobst.doneDetail') + idSuffix };
case 'pending':
return { tone: 'queue', label: t('jobst.queued'), detail: t('jobst.queuedDetail') };
case 'running':
if (phase === 'comfy_queued') {
const ahead = live?.queueAhead;
const detail =
ahead != null && ahead > 0
? t('jobst.comfyQueuedAhead', { n: ahead })
: t('jobst.comfyQueuedDetail');
return { tone: 'queue', label: t('jobst.comfyQueued'), detail: detail + idSuffix };
}
if (phase === 'comfy_running')
return { tone: 'run', label: t('jobst.comfyRunning'), detail: t('jobst.comfyRunningDetail') + idSuffix };
if (phase === 'saving')
return { tone: 'run', label: t('jobst.saving'), detail: t('jobst.savingDetail') + idSuffix };
return { tone: 'run', label: t('jobst.connecting'), detail: t('jobst.connectingDetail') };
default:
return {
tone: 'fail',
label: job.error === 'cancelled' ? t('jobst.cancelled') : t('jobst.failed'),
detail: job.error ?? t('jobst.failed'),
};
}
}
@@ -7,7 +7,7 @@
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 { comfy, settings, workflows, type ComfyEndpoint, type Workflow } from '$lib/api';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { classifyEndpoint, endpointTypeLabel, type EndpointKind } from '$lib/endpointKind';
@@ -362,6 +362,10 @@
wfImportEndpoint = '';
}
function wfProblems(wf: Workflow): string[] {
return (wf.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message);
}
const wfImportMutation = createMutation({
mutationFn: async () => {
if (!wfParsed.valid || !wfParsed.json) throw new Error(t('conn.wfJsonInvalid'));
@@ -375,7 +379,9 @@
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.wfImportedN', { n }));
else toast.info(t('conn.wfImportedNoParams'));
wfImportOpen = false;
wfJsonText = '';
@@ -761,6 +767,11 @@
<Icon name={wf.kind === 'image' ? 'image' : 'film'} size={14} />
</span>
<span class="wf-name" title={wf.description ?? wf.name}>{wf.name}</span>
{#if wfProblems(wf).length}
<span class="tag tag-error" title={wfProblems(wf).join('\n')}>
{t('conn.wfBrokenBadge')}
</span>
{/if}
<span class="wf-params">{t('conn.wfParams', { n: wf.input_schema?.length ?? 0 })}</span>
<select
class="a-select"
@@ -1087,6 +1098,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;
+6 -15
View File
@@ -14,7 +14,8 @@
} from '$lib/api';
import { connectEvents } from '$lib/events';
import { parseDbTs } from '$lib/dbtime';
import { handleJobEvent, progressFor } from '$lib/jobProgress';
import { handleJobEvent } from '$lib/jobProgress';
import { jobStatusInfo } from '$lib/jobStatus';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { isStandaloneJob, jobSourceLabel } from '$lib/jobLabels';
@@ -75,18 +76,6 @@
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function stInfo(job: Job): { cls: string; label: string; mono?: boolean } {
switch (job.status) {
case 'done':
return { cls: 'ok', label: t('tasks.done') };
case 'running':
return { cls: 'run', label: `${Math.round(progressFor(job.id)?.progress ?? 3)}%`, mono: true };
case 'pending':
return { cls: 'queue', label: t('tasks.queued') };
default:
return { cls: 'fail', label: t('tasks.failed') };
}
}
let busyId = $state<number | null>(null);
async function retry(job: Job) {
@@ -164,7 +153,7 @@
{:else}
<div class="task-table">
{#each shown as job (job.id)}
{@const st = stInfo(job)}
{@const st = jobStatusInfo(job)}
<div class="trow" class:failed={job.status === 'failed'}>
<span class="tkind {job.kind}">
<Icon name={job.kind === 'image' ? 'image' : 'film'} size={14} />
@@ -188,6 +177,8 @@
</div>
{#if job.status === 'failed' && job.error}
<div class="terror">{job.error}</div>
{:else if st.tone === 'fail' || (job.status === 'done' && job.phase === 'dry_run')}
<div class="terror">{st.detail}</div>
{/if}
</div>
<div class="tout">
@@ -205,7 +196,7 @@
</div>
{/if}
</div>
<span class="tst {st.cls}" class:mono={st.mono}>{st.label}</span>
<span class="tst {st.tone}" title={st.detail}>{st.label}</span>
<span class="tactions">
{#if job.status === 'failed'}
<button type="button" class="link-accent" disabled={busyId === job.id} onclick={() => retry(job)}>