Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30e8e85766 |
@@ -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}。"
|
||||
@@ -236,6 +236,12 @@ _NUMBER_LIMITS: dict[str, dict[str, Any]] = {
|
||||
"megapixels": {"min": 0.1, "max": 16.0, "step": 0.1},
|
||||
}
|
||||
|
||||
# Text encoders whose ``resolution`` widget sizes the generated image when an
|
||||
# input image is wired (edit mode); in text-to-image it only sizes references.
|
||||
_RESOLUTION_ENCODERS: dict[str, dict[str, Any]] = {
|
||||
"TextEncodeQwenImage21": {"min": 0, "max": 4096, "step": 32, "integer": True},
|
||||
}
|
||||
|
||||
# comfy_extras/nodes_resolution.py — option strings must match exactly.
|
||||
RESOLUTION_ASPECT_RATIOS: dict[str, tuple[int, int]] = {
|
||||
"1:1 (Square)": (1, 1),
|
||||
@@ -260,6 +266,93 @@ def resolution_selector_size(aspect_ratio: str, megapixels: float, multiple: int
|
||||
)
|
||||
|
||||
|
||||
# ComfyUI Autogrow image inputs a studio form fills as one 1…max list
|
||||
# (values read from /object_info). The workflow wires ``<group>.<prefix>N``;
|
||||
# extra images are cloned LoadImage nodes added at patch time (patcher.py).
|
||||
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:
|
||||
"""Plain nodeId when that already patches ``field``; else ``nodeId@field``."""
|
||||
return (
|
||||
@@ -283,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()
|
||||
@@ -308,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
|
||||
@@ -346,6 +452,24 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
texts.append((nid, node, neg_field, True))
|
||||
elif field:
|
||||
texts.append((nid, node, field, _is_negative(nid, lowered, consumers)))
|
||||
edits_image = any(
|
||||
k.startswith("images.") and isinstance(v, list) for k, v in inputs.items()
|
||||
)
|
||||
if ct in _RESOLUTION_ENCODERS and edits_image and "resolution" not in taken_roles:
|
||||
value = inputs.get("resolution")
|
||||
if _is_scalar(value) and not isinstance(value, str):
|
||||
taken_roles.add("resolution")
|
||||
numbers.append(
|
||||
_entry(
|
||||
_key(nid, node, "resolution"),
|
||||
"输出分辨率",
|
||||
"resolution",
|
||||
"number",
|
||||
value,
|
||||
False,
|
||||
**_RESOLUTION_ENCODERS[ct],
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if ct in _SAMPLER_CLASSES:
|
||||
@@ -391,6 +515,9 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
add_number(nid, node, "cfg", "cfg", "CFG")
|
||||
continue
|
||||
if ct.startswith("Empty") and "Latent" in ct:
|
||||
if nid not in consumers:
|
||||
# Orphan latent (Qwen edit sizes from its encoder): editing it changes nothing.
|
||||
continue
|
||||
add_number(nid, node, "width", "width", "宽度")
|
||||
add_number(nid, node, "height", "height", "高度")
|
||||
add_number(nid, node, "batch_size", "batch", "批次数量")
|
||||
@@ -462,6 +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):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -19,8 +20,9 @@ def validate_input_values(
|
||||
``min``/``max``); select values must be one of ``options``. Blank values
|
||||
are dropped so the workflow keeps its own value. Keys not in the schema
|
||||
pass through untouched (other callers patch extra keys, e.g. uploads).
|
||||
Only an auto-detected positive prompt must be present — tagged workflows
|
||||
mark every input required, and blank ones fall back to the workflow's value.
|
||||
Auto-detected required entries (positive prompt, wired image slots,
|
||||
reference-image lists) must be present — tagged workflows mark every
|
||||
input required, and blank ones fall back to the workflow's value.
|
||||
"""
|
||||
by_key = {str(e.get("nodeId")): e for e in schema if isinstance(e, dict)}
|
||||
clean: dict[str, Any] = {}
|
||||
@@ -61,6 +63,26 @@ def validate_input_values(
|
||||
errors.append(f"「{label}」不能大于 {_fmt(hi)}")
|
||||
continue
|
||||
clean[key] = value
|
||||
elif kind == "image_list":
|
||||
paths = [raw] if isinstance(raw, str) else raw
|
||||
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
|
||||
errors.append(f"「{label}」的取值格式不对,请重新上传")
|
||||
continue
|
||||
paths = [p for p in paths if p.strip()]
|
||||
hi = entry.get("max")
|
||||
if hi is not None and len(paths) > hi:
|
||||
errors.append(f"{label}最多 {hi} 张(当前 {len(paths)} 张),请删除多余的图片")
|
||||
continue
|
||||
bad = [
|
||||
f"{label} {i}:{msg}"
|
||||
for i, p in enumerate(paths, 1)
|
||||
if (msg := _image_file_problem(p))
|
||||
]
|
||||
if bad:
|
||||
errors.extend(bad)
|
||||
continue
|
||||
if paths:
|
||||
clean[key] = paths
|
||||
elif kind == "select":
|
||||
options = entry.get("options") or []
|
||||
if options and raw not in options:
|
||||
@@ -70,8 +92,36 @@ def validate_input_values(
|
||||
else:
|
||||
clean[key] = raw
|
||||
for key, entry in by_key.items():
|
||||
required_prompt = entry.get("auto") and entry.get("required") and entry.get("role") == "prompt"
|
||||
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
|
||||
if required_prompt and key not in clean and not has_fallback:
|
||||
errors.append(f"请填写「{entry.get('label') or key}」")
|
||||
required = entry.get("auto") and entry.get("required")
|
||||
label = entry.get("label") or key
|
||||
if required and entry.get("role") == "prompt":
|
||||
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
|
||||
if key not in clean and not has_fallback:
|
||||
errors.append(f"请填写「{label}」")
|
||||
elif required and entry.get("kind") == "image_list":
|
||||
lo = entry.get("min") or 1
|
||||
if len(clean.get(key) or []) < lo and not any(label in e for e in errors):
|
||||
errors.append(f"需要至少 {lo} 张{label}")
|
||||
elif required and entry.get("kind") == "image" and entry.get("role") != "mask":
|
||||
if key not in clean:
|
||||
errors.append(f"请上传{label}")
|
||||
return clean, errors
|
||||
|
||||
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp"})
|
||||
|
||||
|
||||
def _image_file_problem(path: str) -> str | None:
|
||||
"""Why ``path`` cannot be a studio reference image (None = fine)."""
|
||||
from calliope import config
|
||||
|
||||
target = Path(path)
|
||||
if target.suffix.lower() not in _IMAGE_EXTS:
|
||||
return "格式不支持,请使用 PNG / JPG / WebP"
|
||||
try:
|
||||
target.resolve().relative_to(config.settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return "只能使用在本页上传或从生成结果选入的图片"
|
||||
if not target.is_file():
|
||||
return "文件已不存在,请重新上传"
|
||||
return None
|
||||
|
||||
@@ -13,6 +13,7 @@ import httpx
|
||||
from calliope import config
|
||||
from calliope.comfyui.client import ComfyUIClient
|
||||
from calliope.comfyui.dry_run import write_placeholder_mp4, write_placeholder_png
|
||||
from calliope.comfyui.lint import blocking_problem
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.roles import input_has_role
|
||||
@@ -200,6 +201,9 @@ class QueueWorker:
|
||||
raise RuntimeError(
|
||||
"找不到该任务使用的工作流(可能已被删除),请重新选择工作流后再生成"
|
||||
)
|
||||
broken = blocking_problem(workflow)
|
||||
if broken:
|
||||
raise RuntimeError(broken)
|
||||
|
||||
input_values = payload.get("input_values") or {}
|
||||
if payload.get("continue_source") and kind == "video":
|
||||
|
||||
@@ -13,6 +13,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from calliope import config
|
||||
from calliope.agent.prompts import item_reference_prompt
|
||||
from calliope.comfyui.lint import blocking_problem
|
||||
from calliope.comfyui.parser import effective_input_schema
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.db import get_db, row_to_dict
|
||||
@@ -346,13 +347,17 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
status_code=400, detail="该工作流已停用,请到「ComfyUI 连接」页启用后再生成"
|
||||
)
|
||||
kind = wf["kind"]
|
||||
workflow_json = json.loads(wf["workflow_json"])
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [],
|
||||
json.loads(wf["workflow_json"]),
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
broken = blocking_problem(workflow_json)
|
||||
if broken:
|
||||
raise HTTPException(status_code=400, detail=broken)
|
||||
|
||||
input_values, problems = validate_input_values(schema, payload.input_values)
|
||||
if problems:
|
||||
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from calliope.comfyui.lint import workflow_warnings
|
||||
from calliope.comfyui.parser import (
|
||||
effective_input_schema,
|
||||
parse_dynamic_inputs,
|
||||
@@ -25,6 +26,7 @@ def _serialize_workflow(row: Any) -> dict[str, Any]:
|
||||
data["input_schema"] = effective_input_schema(stored, data["workflow_json"])
|
||||
data["output_schema"] = json.loads(data["output_schema"]) if data.get("output_schema") else []
|
||||
data["is_enabled"] = bool(data.get("is_enabled"))
|
||||
data["warnings"] = workflow_warnings(data["workflow_json"])
|
||||
return data
|
||||
|
||||
|
||||
@@ -36,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 == []
|
||||
@@ -58,8 +58,9 @@ def test_native_qwen_edit_detects_image_and_mask_slots():
|
||||
assert set(media) == {"78", "95"}
|
||||
assert media["95"]["role"] == "mask"
|
||||
assert "mask" in media["95"]["label"].lower() or "遮罩" in media["95"]["label"]
|
||||
# the workflow's own image names stay valid, so the slots are optional
|
||||
assert not media["78"]["required"] and not media["95"]["required"]
|
||||
# HEL-633: a wired input image must be uploaded (the export's own file name
|
||||
# rarely exists on the target ComfyUI); the mask stays optional
|
||||
assert media["78"]["required"] and not media["95"]["required"]
|
||||
|
||||
|
||||
def test_untagged_h3_video_detects_prompt_duration_seed():
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
|
||||
@@ -6,9 +6,19 @@ export type ComfyInputKind =
|
||||
| 'image'
|
||||
| 'image_url'
|
||||
| 'audio'
|
||||
| 'video';
|
||||
| 'video'
|
||||
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
|
||||
| 'image_list';
|
||||
export type ComfyOutputKind = 'image' | 'video' | 'other';
|
||||
|
||||
/** Structural finding on an imported workflow; `error` blocks generation. */
|
||||
export interface WorkflowWarning {
|
||||
level: 'error' | 'warning';
|
||||
code: string;
|
||||
node_id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ComfyDynamicInput {
|
||||
nodeId: string;
|
||||
label: string;
|
||||
@@ -56,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 {
|
||||
|
||||
@@ -2,22 +2,31 @@
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* A-style media upload slot (spec-v2 §5 上传槽): empty = 1.5px dashed
|
||||
* border + "click or drop", drag-hover = amber dashed, filled = solid
|
||||
* border + thumbnail + mono name + ✕, error = red border + red reason.
|
||||
* A-style media upload slot (spec-v2 §5 上传槽, ref-upload-spec §2):
|
||||
* empty = 1.5px dashed border + "click or drop" + format line, drag-hover
|
||||
* = amber dashed, filled = solid border + thumb + name + size + 替换 + ✕,
|
||||
* error = red border + red reason.
|
||||
*/
|
||||
interface Props {
|
||||
label: string;
|
||||
/** Extra hint under the label, e.g. 可从生图结果送入. */
|
||||
/** Hint on the right of the label, e.g. 必填 · 1 张 / 可从生图结果送入. */
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
/** Current value: a server-side path ('' = empty). */
|
||||
value: string;
|
||||
kind: 'image' | 'video' | 'audio';
|
||||
/** Thumb aspect: wide = 16:9 (first/last frame slots). */
|
||||
aspect?: 'square' | 'wide';
|
||||
/** Empty-state main line (defaults to 点击或拖入). */
|
||||
emptyText?: string;
|
||||
/** Empty-state mono line, e.g. accepted formats and size limit. */
|
||||
emptySub?: string;
|
||||
/** Byte size of the current file when known. */
|
||||
size?: number;
|
||||
uploading?: boolean;
|
||||
uploadName?: string;
|
||||
error?: string;
|
||||
@@ -31,9 +40,13 @@
|
||||
let {
|
||||
label,
|
||||
hint,
|
||||
required = false,
|
||||
value,
|
||||
kind,
|
||||
aspect = 'square',
|
||||
emptyText,
|
||||
emptySub,
|
||||
size,
|
||||
uploading = false,
|
||||
uploadName,
|
||||
error,
|
||||
@@ -46,21 +59,24 @@
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let dragDepth = $state(0);
|
||||
let broken = $state(false);
|
||||
let dims = $state('');
|
||||
$effect(() => {
|
||||
void value;
|
||||
broken = false;
|
||||
dims = '';
|
||||
});
|
||||
|
||||
const meta = $derived([dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '));
|
||||
|
||||
function accept(): string {
|
||||
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
|
||||
if (kind === 'audio') return 'audio/*';
|
||||
return 'image/*';
|
||||
return IMAGE_SLOT_ACCEPT;
|
||||
}
|
||||
|
||||
function baseName(p: string): string {
|
||||
const raw = p.split(/[/\\]/).pop() ?? p;
|
||||
// strip uuid8- prefix the upload endpoint writes
|
||||
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
|
||||
function noteDims(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.naturalWidth) dims = `${img.naturalWidth}×${img.naturalHeight}`;
|
||||
}
|
||||
|
||||
function pick() {
|
||||
@@ -80,7 +96,7 @@
|
||||
|
||||
<div class="media-slot" class:wide={aspect === 'wide'}>
|
||||
<div class="slot-label">
|
||||
<span class="lbl">{label}</span>
|
||||
<span class="lbl">{label}{#if required}<em>*</em>{/if}</span>
|
||||
{#if hint}<span class="slot-hint">{hint}</span>{/if}
|
||||
</div>
|
||||
|
||||
@@ -100,8 +116,10 @@
|
||||
{#if value && !broken}
|
||||
<div class="slot-filled" class:invalid={!!error}>
|
||||
<div class="slot-thumb">
|
||||
{#if kind === 'image'}
|
||||
<img src={assetUrl(value)} alt={label} onerror={() => (broken = true)} />
|
||||
{#if uploading}
|
||||
<Spinner size="sm" />
|
||||
{:else if kind === 'image'}
|
||||
<img src={assetUrl(value)} alt={label} onload={noteDims} onerror={() => (broken = true)} />
|
||||
{:else if kind === 'video'}
|
||||
<video src={assetUrl(value)} preload="metadata" muted></video>
|
||||
<span class="kind-badge" aria-hidden="true"><Icon name="film" size={12} /></span>
|
||||
@@ -110,22 +128,29 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="slot-meta">
|
||||
<span class="mono name" title={baseName(value)}>{baseName(value)}</span>
|
||||
<div class="slot-actions">
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="mini-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResults')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="slot-clear"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
onclick={onClear}
|
||||
>
|
||||
<Icon name="close" size={13} />
|
||||
<span class="name" title={uploading ? uploadName : uploadBaseName(value)}>
|
||||
{uploading ? uploadName : uploadBaseName(value)}
|
||||
</span>
|
||||
{#if meta && !uploading}<span class="mono dims">{meta}</span>{/if}
|
||||
</div>
|
||||
<div class="slot-actions">
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="mini-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<button type="button" class="mini-link" disabled={uploading} onclick={pick}>
|
||||
{t('studio.refReplace')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="slot-clear"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
disabled={uploading}
|
||||
onclick={onClear}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -151,7 +176,8 @@
|
||||
<span class="pick-text">{uploadName ?? t('studio.slotPick')}</span>
|
||||
{:else}
|
||||
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'image'} size={16} />
|
||||
<span class="pick-text">{t('studio.slotPick')}</span>
|
||||
<span class="pick-text">{emptyText ?? t('studio.slotPick')}</span>
|
||||
{#if emptySub}<span class="pick-sub mono">{emptySub}</span>{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if allowFromResults && onFromResults}
|
||||
@@ -181,6 +207,11 @@
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lbl em {
|
||||
color: var(--error);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.slot-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
@@ -234,7 +265,12 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
.pick-text {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.pick-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.from-results-link {
|
||||
align-self: flex-end;
|
||||
@@ -251,18 +287,21 @@
|
||||
}
|
||||
.slot-filled {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
padding: 8px;
|
||||
}
|
||||
.slot-filled.invalid {
|
||||
border-color: rgba(229, 72, 77, 0.65);
|
||||
border-color: var(--error);
|
||||
}
|
||||
.slot-thumb {
|
||||
border-radius: 7px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
@@ -271,39 +310,39 @@
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.media-slot.wide .slot-thumb {
|
||||
width: 88px;
|
||||
height: 50px;
|
||||
}
|
||||
.slot-thumb img,
|
||||
.slot-thumb video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 140px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.media-slot.wide .slot-thumb {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
.media-slot.wide .slot-thumb img,
|
||||
.media-slot.wide .slot-thumb video {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.slot-thumb .kind-badge {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.slot-meta {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
font-size: 12.5px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dims {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.slot-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -315,12 +354,17 @@
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 10.5px;
|
||||
color: var(--accent);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-link:hover {
|
||||
text-decoration: underline;
|
||||
.mini-link:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini-link:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.slot-clear {
|
||||
display: inline-flex;
|
||||
@@ -330,7 +374,7 @@
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Reference-image grid (ref-upload-spec §2 `.refgrid`): numbered cells
|
||||
* 参考图 1…N in the order they reach the workflow, each with replace and
|
||||
* ✕; a dashed add cell while below `max`; rejected files show as error
|
||||
* cells that take no place in the list.
|
||||
*/
|
||||
interface Reject {
|
||||
name: string;
|
||||
message: string;
|
||||
}
|
||||
interface Props {
|
||||
label: string;
|
||||
paths: string[];
|
||||
max: number;
|
||||
/** Byte sizes of files uploaded in this session, by server path. */
|
||||
sizes?: Record<string, number>;
|
||||
/** Name of the file being uploaded (add or replace), if any. */
|
||||
uploadingName?: string;
|
||||
/** Index being replaced while uploading (-1 = adding). */
|
||||
uploadingIndex?: number;
|
||||
rejects?: Reject[];
|
||||
error?: string;
|
||||
allowFromResults?: boolean;
|
||||
onAdd: (files: File[]) => void;
|
||||
onReplace: (index: number, file: File) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onDismissReject: (index: number) => void;
|
||||
onFromResults?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
paths,
|
||||
max,
|
||||
sizes = {},
|
||||
uploadingName,
|
||||
uploadingIndex = -1,
|
||||
rejects = [],
|
||||
error,
|
||||
allowFromResults = false,
|
||||
onAdd,
|
||||
onReplace,
|
||||
onRemove,
|
||||
onDismissReject,
|
||||
onFromResults,
|
||||
}: Props = $props();
|
||||
|
||||
let addInput = $state<HTMLInputElement | null>(null);
|
||||
let replaceInput = $state<HTMLInputElement | null>(null);
|
||||
let replaceIndex = $state(-1);
|
||||
let dragDepth = $state(0);
|
||||
let dims = $state<Record<string, string>>({});
|
||||
|
||||
const uploading = $derived(!!uploadingName);
|
||||
const remaining = $derived(Math.max(0, max - paths.length));
|
||||
|
||||
function pickAdd() {
|
||||
if (uploading || !addInput) return;
|
||||
addInput.value = '';
|
||||
addInput.click();
|
||||
}
|
||||
|
||||
function pickReplace(i: number) {
|
||||
if (uploading || !replaceInput) return;
|
||||
replaceIndex = i;
|
||||
replaceInput.value = '';
|
||||
replaceInput.click();
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragDepth = 0;
|
||||
if (uploading) return;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) onAdd(files);
|
||||
}
|
||||
|
||||
function noteDims(path: string, e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
|
||||
}
|
||||
|
||||
function metaOf(path: string): string {
|
||||
return [dims[path], sizes[path] != null ? formatBytes(sizes[path]) : null].filter(Boolean).join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="refgrid-field">
|
||||
<div class="slot-label">
|
||||
<span class="lbl">{label}<em>*</em></span>
|
||||
<span class="slot-hint">{t('studio.refHint', { n: paths.length, max })}</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
bind:this={addInput}
|
||||
type="file"
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={IMAGE_SLOT_ACCEPT}
|
||||
multiple
|
||||
onchange={(e) => {
|
||||
const files = Array.from(e.currentTarget.files ?? []);
|
||||
if (files.length) onAdd(files);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
bind:this={replaceInput}
|
||||
type="file"
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={IMAGE_SLOT_ACCEPT}
|
||||
onchange={(e) => {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
if (f && replaceIndex >= 0) onReplace(replaceIndex, f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="refgrid">
|
||||
{#each paths as path, i (path + i)}
|
||||
<div class="ref-cell" class:busy={uploadingIndex === i}>
|
||||
<button
|
||||
type="button"
|
||||
class="ref-thumb"
|
||||
title={t('studio.refReplace')}
|
||||
onclick={() => pickReplace(i)}
|
||||
>
|
||||
<img src={assetUrl(path)} alt={t('studio.slotRefN', { n: i + 1 })} onload={(e) => noteDims(path, e)} />
|
||||
<span class="ref-no mono">{i + 1}</span>
|
||||
{#if uploadingIndex === i}
|
||||
<span class="ref-busy"><Spinner size="sm" /></span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ref-x"
|
||||
aria-label={t('studio.refRemove', { n: i + 1 })}
|
||||
onclick={() => onRemove(i)}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
</button>
|
||||
<span class="ref-name" title={uploadBaseName(path)}>{uploadBaseName(path)}</span>
|
||||
<span class="ref-meta mono">
|
||||
{metaOf(path)}
|
||||
<button type="button" class="mini-link" onclick={() => pickReplace(i)}>{t('studio.refReplace')}</button>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each rejects as rej, i (rej.name + i)}
|
||||
<div class="ref-cell rejected" role="alert">
|
||||
<div class="ref-thumb error">
|
||||
<Icon name="close" size={16} />
|
||||
<span class="mono">{t('studio.refRejectedShort')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ref-x"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
onclick={() => onDismissReject(i)}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
</button>
|
||||
<span class="ref-name err" title={rej.name}>{rej.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if remaining > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="ref-add"
|
||||
class:drag={dragDepth > 0}
|
||||
class:invalid={!!error}
|
||||
disabled={uploading}
|
||||
aria-busy={uploading || undefined}
|
||||
onclick={pickAdd}
|
||||
ondragenter={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
}}
|
||||
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if uploading && uploadingIndex < 0}
|
||||
<Spinner size="sm" />
|
||||
<span class="add-main">{uploadingName}</span>
|
||||
{:else}
|
||||
<Icon name="plus" size={16} />
|
||||
<span class="add-main">{t('studio.refAdd')}</span>
|
||||
<span class="add-sub mono">{t('studio.refAddSub', { n: remaining, max })}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if allowFromResults && onFromResults && remaining > 0}
|
||||
<button type="button" class="from-results-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResults')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if remaining === 0}
|
||||
<span class="full-note">{t('studio.refFull', { max })}</span>
|
||||
{/if}
|
||||
{#each rejects as rej, i (rej.name + i)}
|
||||
<span class="slot-error">{rej.message}</span>
|
||||
{/each}
|
||||
{#if error?.trim()}<span class="slot-error" role="alert">{error}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.refgrid-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.slot-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lbl em {
|
||||
color: var(--error);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.slot-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.refgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.ref-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.ref-cell.rejected {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.ref-thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ref-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.ref-thumb.error {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
cursor: default;
|
||||
color: var(--error);
|
||||
font-size: 10px;
|
||||
}
|
||||
.ref-no {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 4px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: var(--text);
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.ref-busy {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.ref-x {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ref-x:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
.ref-name {
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ref-name.err {
|
||||
color: var(--error);
|
||||
}
|
||||
.ref-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
font-size: 9.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mini-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ref-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 110px;
|
||||
padding: 8px 4px;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
color 150ms;
|
||||
}
|
||||
.ref-add:hover:not(:disabled) {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.ref-add.drag {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.ref-add.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.ref-add:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.add-main {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.add-sub {
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.from-results-link {
|
||||
align-self: flex-end;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.from-results-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.full-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.slot-error {
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.refgrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,8 @@
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
|
||||
import MediaSlot from '$lib/components/studio/MediaSlot.svelte';
|
||||
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
|
||||
import { imageFileProblem } from '$lib/comfy/imageFile';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { tick } from 'svelte';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
@@ -123,7 +125,14 @@
|
||||
let endpointTouched = $state(false);
|
||||
let endpointId = $state('');
|
||||
let workflowId = $state<number | null>(null);
|
||||
let values = $state<Record<string, string | number>>({});
|
||||
type FormValue = string | number | string[];
|
||||
let values = $state<Record<string, FormValue>>({});
|
||||
/** Per-slot rejected picks (format / size) — shown in place, never uploaded. */
|
||||
let slotRejects = $state<Record<string, Array<{ name: string; message: string }>>>({});
|
||||
/** Byte size of files uploaded here, by server path (for the slot meta line). */
|
||||
let fileSizes = $state<Record<string, number>>({});
|
||||
/** Reference index being replaced while its upload runs. */
|
||||
let replacingIndex = $state(-1);
|
||||
let showErrors = $state(false);
|
||||
let submitting = $state(false);
|
||||
let lightboxOpen = $state(false);
|
||||
@@ -163,13 +172,14 @@
|
||||
}
|
||||
if (wf.id === lastWfId) return;
|
||||
lastWfId = wf.id;
|
||||
const next: Record<string, string | number> = {};
|
||||
const next: Record<string, FormValue> = {};
|
||||
for (const inp of wf.input_schema ?? []) {
|
||||
if (inp.defaultValue !== undefined && inp.defaultValue !== '') {
|
||||
next[inp.nodeId] = inp.defaultValue;
|
||||
}
|
||||
}
|
||||
values = next;
|
||||
slotRejects = {};
|
||||
showErrors = false;
|
||||
});
|
||||
|
||||
@@ -186,13 +196,36 @@
|
||||
return `${inp.role ?? ''} ${inp.label}`.toLowerCase();
|
||||
}
|
||||
const isMedia = (inp: ComfyDynamicInput) =>
|
||||
inp.kind === 'image' || inp.kind === 'image_url' || inp.kind === 'video' || inp.kind === 'audio';
|
||||
inp.kind === 'image' ||
|
||||
inp.kind === 'image_url' ||
|
||||
inp.kind === 'video' ||
|
||||
inp.kind === 'audio' ||
|
||||
inp.kind === 'image_list';
|
||||
// Auto-detected slots carry their role from the wiring; tagged (Input:…)
|
||||
// workflows still name frames only in the title.
|
||||
const isFirstFrame = (inp: ComfyDynamicInput) =>
|
||||
role(inp) === 'first_frame' || (!inp.auto && kind === 'video' && /first|start|首帧/.test(hay(inp)));
|
||||
const isLastFrame = (inp: ComfyDynamicInput) =>
|
||||
role(inp) === 'last_frame' || (!inp.auto && kind === 'video' && /last|end|尾帧/.test(hay(inp)));
|
||||
|
||||
// ComfyUI Autogrow reference images (H3 ref_images): one ordered 1…max list.
|
||||
const refGroup = $derived(schema.find((i) => i.kind === 'image_list') ?? null);
|
||||
const refMax = $derived(refGroup?.max ?? 9);
|
||||
const refPaths = $derived.by(() => {
|
||||
const v = refGroup ? values[refGroup.nodeId] : undefined;
|
||||
if (Array.isArray(v)) return v;
|
||||
return typeof v === 'string' && v ? [v] : [];
|
||||
});
|
||||
const brokenReasons = $derived(
|
||||
(currentWorkflow?.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message),
|
||||
);
|
||||
|
||||
const promptInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'prompt') ?? null);
|
||||
const negInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'negative') ?? null);
|
||||
const seedInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'seed') ?? null);
|
||||
const widthInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'width') ?? null);
|
||||
const heightInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'height') ?? null);
|
||||
const resolutionInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'resolution') ?? null);
|
||||
const durationInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'duration') ?? null);
|
||||
const stepsInp = $derived(schema.find((i) => /步数|steps/.test(hay(i)) && !isMedia(i)) ?? null);
|
||||
const cfgInp = $derived(
|
||||
@@ -220,38 +253,80 @@
|
||||
mediaKind: 'image' | 'video' | 'audio';
|
||||
aspect: 'square' | 'wide';
|
||||
fromResults: boolean;
|
||||
/** Required-slot message when left empty (ref-upload-spec §3). */
|
||||
missing: string;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
const mediaSlots = $derived.by(() => {
|
||||
const media = schema.filter(isMedia);
|
||||
const media = schema.filter((i) => isMedia(i) && i.kind !== 'image_list');
|
||||
const plainImages = media.filter(
|
||||
(i) => i.kind === 'image' && role(i) !== 'mask' && !/mask|遮罩/.test(hay(i)) && !isFirstFrame(i) && !isLastFrame(i),
|
||||
);
|
||||
const editInput = kind === 'image' && plainImages.length === 1 ? plainImages[0] : null;
|
||||
const out: SlotDef[] = [];
|
||||
let refIdx = 0;
|
||||
for (const inp of media) {
|
||||
const h = hay(inp);
|
||||
const base = { inp, missing: '' };
|
||||
if (inp.kind === 'video') {
|
||||
out.push({ inp, label: t('studio.slotRefVideo'), mediaKind: 'video', aspect: 'square', fromResults: false });
|
||||
out.push({ ...base, label: t('studio.slotRefVideo'), mediaKind: 'video', aspect: 'square', fromResults: false });
|
||||
} else if (inp.kind === 'audio') {
|
||||
out.push({ inp, label: t('studio.slotAudio'), mediaKind: 'audio', aspect: 'square', fromResults: false });
|
||||
} else if (/mask|遮罩/.test(h)) {
|
||||
out.push({ inp, label: t('studio.slotMask'), mediaKind: 'image', aspect: 'square', fromResults: false });
|
||||
} else if (kind === 'video' && /first|start|首帧/.test(h)) {
|
||||
out.push({ inp, label: t('studio.slotFirst'), mediaKind: 'image', aspect: 'wide', fromResults: true });
|
||||
} else if (kind === 'video' && /last|end|尾帧/.test(h)) {
|
||||
out.push({ inp, label: t('studio.slotLast'), mediaKind: 'image', aspect: 'wide', fromResults: true });
|
||||
out.push({ ...base, label: t('studio.slotAudio'), mediaKind: 'audio', aspect: 'square', fromResults: false });
|
||||
} else if (role(inp) === 'mask' || /mask|遮罩/.test(h)) {
|
||||
out.push({ ...base, label: t('studio.slotMask'), mediaKind: 'image', aspect: 'square', fromResults: false });
|
||||
} else if (isFirstFrame(inp)) {
|
||||
out.push({
|
||||
...base,
|
||||
label: t('studio.slotFirst'),
|
||||
mediaKind: 'image',
|
||||
aspect: 'wide',
|
||||
fromResults: true,
|
||||
missing: t('studio.errNeedFirstFrame'),
|
||||
emptyText: t('studio.slotEmptyFirst'),
|
||||
});
|
||||
} else if (isLastFrame(inp)) {
|
||||
out.push({ ...base, label: t('studio.slotLast'), mediaKind: 'image', aspect: 'wide', fromResults: true });
|
||||
} else if (inp === editInput) {
|
||||
out.push({
|
||||
...base,
|
||||
label: t('studio.slotInput'),
|
||||
mediaKind: 'image',
|
||||
aspect: 'square',
|
||||
fromResults: false,
|
||||
missing: t('studio.errNeedInputImage'),
|
||||
emptyText: t('studio.slotEmptyInput'),
|
||||
});
|
||||
} else {
|
||||
refIdx += 1;
|
||||
const label = inp.auto
|
||||
? inp.label
|
||||
: refIdx === 1 && media.length === 1
|
||||
? t('studio.slotRef')
|
||||
: t('studio.slotRefN', { n: refIdx });
|
||||
out.push({
|
||||
inp,
|
||||
label: refIdx === 1 && media.length === 1 ? t('studio.slotRef') : t('studio.slotRefN', { n: refIdx }),
|
||||
...base,
|
||||
label,
|
||||
mediaKind: 'image',
|
||||
aspect: kind === 'video' ? 'wide' : 'square',
|
||||
fromResults: kind === 'video',
|
||||
missing: t('studio.errUpload', { label }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
/** Mode badge from what the workflow really wires (spec §1 table). */
|
||||
const modeLabel = $derived.by(() => {
|
||||
if (refGroup) return t('studio.modeRefToVideo');
|
||||
if (mediaSlots.some((s) => isFirstFrame(s.inp))) return t('studio.modeImageToVideo');
|
||||
if (kind === 'image' && mediaSlots.some((s) => s.mediaKind === 'image' && role(s.inp) !== 'mask'))
|
||||
return t('studio.modeImageEdit');
|
||||
if (mediaSlots.length) return mediaSlots.map((s) => s.label).join(' · ');
|
||||
return kind === 'image' ? t('studio.modeTextToImage') : t('studio.modeTextToVideo');
|
||||
});
|
||||
|
||||
const structuredIds = $derived(
|
||||
new Set(
|
||||
[
|
||||
@@ -260,6 +335,7 @@
|
||||
seedInp,
|
||||
widthInp,
|
||||
heightInp,
|
||||
resolutionInp,
|
||||
durationInp,
|
||||
stepsInp,
|
||||
cfgInp,
|
||||
@@ -314,8 +390,9 @@
|
||||
if (!v.trim()) errs[promptInp.nodeId] = t('studio.errPrompt');
|
||||
else if (v.length > 2000) errs[promptInp.nodeId] = t('studio.errPromptLong');
|
||||
}
|
||||
const first = mediaSlots.find((s) => /first|start|首帧/.test(hay(s.inp)));
|
||||
const last = mediaSlots.find((s) => /last|end|尾帧/.test(hay(s.inp)));
|
||||
if (brokenReasons.length) errs.workflow = t('studio.errWfBroken');
|
||||
const first = mediaSlots.find((s) => isFirstFrame(s.inp));
|
||||
const last = mediaSlots.find((s) => isLastFrame(s.inp));
|
||||
if (first && last) {
|
||||
const a = String(values[first.inp.nodeId] ?? '');
|
||||
const b = String(values[last.inp.nodeId] ?? '');
|
||||
@@ -328,14 +405,14 @@
|
||||
for (const s of mediaSlots) {
|
||||
if (!s.inp.required) continue;
|
||||
if (!String(values[s.inp.nodeId] ?? '').trim()) {
|
||||
const isPlainRef = kind === 'image' && !/mask|遮罩|first|start|首帧|last|end|尾帧/.test(hay(s.inp));
|
||||
errs[s.inp.nodeId] =
|
||||
mediaSlots.filter((x) => x.mediaKind === 'image' && !/mask|遮罩/.test(hay(x.inp))).length === 1 &&
|
||||
isPlainRef
|
||||
? t('studio.errRefImage')
|
||||
: t('studio.errUpload', { label: s.label });
|
||||
errs[s.inp.nodeId] = s.missing || t('studio.errUpload', { label: s.label });
|
||||
}
|
||||
}
|
||||
if (refGroup) {
|
||||
if (refPaths.length < (refGroup.min ?? 1)) errs[refGroup.nodeId] = t('studio.errNeedRef');
|
||||
else if (refPaths.length > refMax)
|
||||
errs[refGroup.nodeId] = t('studio.errRefMax', { max: refMax, n: refPaths.length, k: refPaths.length - refMax });
|
||||
}
|
||||
for (const inp of restInputs) {
|
||||
if (inp.required && !String(values[inp.nodeId] ?? '').trim()) {
|
||||
errs[inp.nodeId] = t('studio.errFill', { label: inp.label });
|
||||
@@ -343,7 +420,7 @@
|
||||
}
|
||||
for (const inp of schema) {
|
||||
const raw = values[inp.nodeId];
|
||||
if (raw === undefined || String(raw).trim() === '' || errs[inp.nodeId]) continue;
|
||||
if (raw === undefined || Array.isArray(raw) || String(raw).trim() === '' || errs[inp.nodeId]) continue;
|
||||
const msg = valueError(inp, raw);
|
||||
if (msg) errs[inp.nodeId] = msg;
|
||||
}
|
||||
@@ -367,7 +444,7 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
function setVal(nodeId: string, v: string | number) {
|
||||
function setVal(nodeId: string, v: FormValue) {
|
||||
values = { ...values, [nodeId]: v };
|
||||
}
|
||||
function numVal(nodeId: string, raw: string) {
|
||||
@@ -382,11 +459,72 @@
|
||||
// ------------------------------------------------------------------
|
||||
// Uploads & media slots
|
||||
// ------------------------------------------------------------------
|
||||
async function slotFile(nodeId: string, file: File) {
|
||||
function setRejects(nodeId: string, list: Array<{ name: string; message: string }>) {
|
||||
slotRejects = { ...slotRejects, [nodeId]: list };
|
||||
}
|
||||
|
||||
async function uploadImage(nodeId: string, file: File): Promise<string | null> {
|
||||
const path = await uploads.uploadSafe(nodeId, file);
|
||||
if (path) fileSizes = { ...fileSizes, [path]: file.size };
|
||||
return path;
|
||||
}
|
||||
|
||||
async function slotFile(nodeId: string, file: File, mediaKind: 'image' | 'video' | 'audio' = 'image') {
|
||||
if (mediaKind === 'image') {
|
||||
const problem = imageFileProblem(file);
|
||||
if (problem) {
|
||||
setRejects(nodeId, [{ name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setRejects(nodeId, []);
|
||||
const path = await uploadImage(nodeId, file);
|
||||
if (path) setVal(nodeId, path);
|
||||
}
|
||||
|
||||
/** Add picked/dropped files in order; a batch that would pass `max` is refused whole. */
|
||||
async function refAdd(files: File[]) {
|
||||
if (!refGroup) return;
|
||||
const key = refGroup.nodeId;
|
||||
const ok = files.filter((f) => !imageFileProblem(f));
|
||||
const rejected = files
|
||||
.filter((f) => imageFileProblem(f))
|
||||
.map((f) => ({ name: f.name, message: imageFileProblem(f)! }));
|
||||
setRejects(key, rejected);
|
||||
if (refPaths.length + ok.length > refMax) {
|
||||
toast.error(t('studio.errRefMax', { max: refMax, n: refPaths.length, k: ok.length }));
|
||||
return;
|
||||
}
|
||||
for (const f of ok) {
|
||||
const path = await uploadImage(key, f);
|
||||
if (!path) break;
|
||||
setVal(key, [...refPaths, path]);
|
||||
}
|
||||
}
|
||||
|
||||
async function refReplace(index: number, file: File) {
|
||||
if (!refGroup) return;
|
||||
const key = refGroup.nodeId;
|
||||
const problem = imageFileProblem(file);
|
||||
if (problem) {
|
||||
setRejects(key, [{ name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
setRejects(key, []);
|
||||
replacingIndex = index;
|
||||
try {
|
||||
const path = await uploadImage(key, file);
|
||||
if (path) setVal(key, refPaths.map((p, i) => (i === index ? path : p)));
|
||||
} finally {
|
||||
replacingIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function refRemove(index: number) {
|
||||
if (!refGroup) return;
|
||||
setVal(refGroup.nodeId, refPaths.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
const imageResults = $derived(
|
||||
allJobs
|
||||
.filter((j) => j.kind === 'image' && jobStatusInfo(j).tone === 'ok')
|
||||
@@ -398,13 +536,18 @@
|
||||
pickerFor = nodeId;
|
||||
}
|
||||
function pickImageResult(path: string) {
|
||||
if (pickerFor) setVal(pickerFor, path);
|
||||
if (pickerFor && refGroup && pickerFor === refGroup.nodeId) {
|
||||
if (refPaths.length >= refMax) toast.error(t('studio.refFull', { max: refMax }));
|
||||
else setVal(pickerFor, [...refPaths, path]);
|
||||
} else if (pickerFor) {
|
||||
setVal(pickerFor, path);
|
||||
}
|
||||
pickerFor = null;
|
||||
}
|
||||
|
||||
function sendToImg2Img(path: string) {
|
||||
let wf = currentWorkflow;
|
||||
let target = mediaSlots.find((s) => s.mediaKind === 'image')?.inp ?? null;
|
||||
let target = mediaSlots.find((s) => s.mediaKind === 'image' && role(s.inp) !== 'mask')?.inp ?? null;
|
||||
if (!target) {
|
||||
const candidate = ($workflowsQuery.data ?? []).find(
|
||||
(w) => w.kind === 'image' && w.is_enabled && (w.input_schema ?? []).some(isMedia),
|
||||
@@ -629,16 +772,24 @@
|
||||
const wf = ($workflowsQuery.data ?? []).find((w) => w.id === selectedEntry.job.workflow_id);
|
||||
if (!wf) return [];
|
||||
const vals = (selectedEntry.job.payload?.input_values ?? {}) as Record<string, unknown>;
|
||||
const out: Array<{ label: string; path: string }> = [];
|
||||
const out: Array<{ label: string; path: string; ref?: boolean }> = [];
|
||||
for (const inp of wf.input_schema ?? []) {
|
||||
const h = `${inp.role ?? ''} ${inp.label}`.toLowerCase();
|
||||
const v = vals[inp.nodeId];
|
||||
if (inp.kind === 'image_list') {
|
||||
const list = Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : [];
|
||||
list.forEach((p, i) => {
|
||||
if (typeof p === 'string') out.push({ label: t('studio.slotRefN', { n: i + 1 }), path: p, ref: true });
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (typeof v !== 'string' || !v) continue;
|
||||
if (/first|start|首帧/.test(h)) out.push({ label: t('studio.slotFirst'), path: v });
|
||||
else if (/last|end|尾帧/.test(h)) out.push({ label: t('studio.slotLast'), path: v });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const selectedRefCount = $derived(selectedFrames.filter((f) => f.ref).length);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// History actions
|
||||
@@ -650,14 +801,16 @@
|
||||
toast.error(t('studio.reuseMissingWf'));
|
||||
return;
|
||||
}
|
||||
const next: Record<string, string | number> = {};
|
||||
const next: Record<string, FormValue> = {};
|
||||
for (const inp of wf.input_schema ?? []) {
|
||||
if (inp.defaultValue !== undefined && inp.defaultValue !== '') next[inp.nodeId] = inp.defaultValue;
|
||||
}
|
||||
const past = (job.payload?.input_values ?? {}) as Record<string, unknown>;
|
||||
for (const [k, v] of Object.entries(past)) {
|
||||
if (typeof v === 'string' || typeof v === 'number') next[k] = v;
|
||||
else if (Array.isArray(v) && v.every((p) => typeof p === 'string')) next[k] = v as string[];
|
||||
}
|
||||
slotRejects = {};
|
||||
workflowId = wf.id;
|
||||
lastWfId = wf.id;
|
||||
values = next;
|
||||
@@ -764,13 +917,7 @@
|
||||
<div class="panel-head">
|
||||
<h2>{t('studio.genParams')}</h2>
|
||||
{#if currentWorkflow}
|
||||
<span class="panel-hint">
|
||||
{mediaSlots.length
|
||||
? mediaSlots.map((s) => s.label).join(' · ')
|
||||
: kind === 'image'
|
||||
? t('studio.modeTextToImage')
|
||||
: t('studio.modeTextToVideo')}
|
||||
</span>
|
||||
<span class="panel-hint">{modeLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if dryRun}
|
||||
@@ -839,11 +986,22 @@
|
||||
{:else if schema.some((i) => i.auto)}
|
||||
<p class="missing-note">{t('studio.wfAutoParamsNote', { n: schema.length })}</p>
|
||||
{/if}
|
||||
{#if brokenReasons.length}
|
||||
<div class="missing-note" role="alert">
|
||||
<span class="f-error">{t('studio.wfBrokenTitle')}</span>
|
||||
{#each brokenReasons as reason (reason)}
|
||||
<span class="f-error">· {reason}</span>
|
||||
{/each}
|
||||
<span class="f-error">{t('studio.wfBrokenHint')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if mediaSlots.length}
|
||||
{@const pairKey = `${mediaSlots.find((s) => isFirstFrame(s.inp))?.inp.nodeId ?? ''}|${mediaSlots.find((s) => isLastFrame(s.inp))?.inp.nodeId ?? ''}`}
|
||||
<div class="slots">
|
||||
{#each mediaSlots as s (s.inp.nodeId)}
|
||||
{@const reject = slotRejects[s.inp.nodeId]?.[0]}
|
||||
{#if s.inp.kind === 'video' || s.inp.kind === 'audio'}
|
||||
<div class="slot-span">
|
||||
<MediaSlot
|
||||
@@ -853,7 +1011,7 @@
|
||||
uploading={uploads.isUploading(s.inp.nodeId)}
|
||||
uploadName={uploads.uploading[s.inp.nodeId]}
|
||||
error={showErrors ? errors[s.inp.nodeId] : undefined}
|
||||
onFile={(f) => slotFile(s.inp.nodeId, f)}
|
||||
onFile={(f) => slotFile(s.inp.nodeId, f, s.mediaKind)}
|
||||
onClear={() => setVal(s.inp.nodeId, '')}
|
||||
/>
|
||||
</div>
|
||||
@@ -861,29 +1019,56 @@
|
||||
<div class="slot-cell" class:span2={s.aspect === 'wide' || mediaSlots.length === 1}>
|
||||
<MediaSlot
|
||||
label={s.label}
|
||||
hint={kind === 'video' && s.fromResults ? t('studio.slotFromResults') : undefined}
|
||||
required={s.inp.required}
|
||||
hint={s.fromResults ? t('studio.slotFromResults') : s.inp.required ? t('studio.slotHintOne') : undefined}
|
||||
value={String(values[s.inp.nodeId] ?? '')}
|
||||
kind="image"
|
||||
aspect={s.aspect}
|
||||
emptyText={s.emptyText}
|
||||
emptySub={t('studio.slotEmptySub')}
|
||||
size={fileSizes[String(values[s.inp.nodeId] ?? '')]}
|
||||
uploading={uploads.isUploading(s.inp.nodeId)}
|
||||
uploadName={uploads.uploading[s.inp.nodeId]}
|
||||
error={showErrors ? errors[s.inp.nodeId] : undefined}
|
||||
error={reject?.message ?? (showErrors ? errors[s.inp.nodeId] : undefined)}
|
||||
allowFromResults={s.fromResults}
|
||||
onFile={(f) => slotFile(s.inp.nodeId, f)}
|
||||
onFromResults={() => openPicker(s.inp.nodeId)}
|
||||
onClear={() => setVal(s.inp.nodeId, '')}
|
||||
onClear={() => {
|
||||
setRejects(s.inp.nodeId, []);
|
||||
setVal(s.inp.nodeId, '');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if showErrors && errors[`${mediaSlots.find((s) => /first|start|首帧/.test(hay(s.inp)))?.inp.nodeId ?? ''}|${mediaSlots.find((s) => /last|end|尾帧/.test(hay(s.inp)))?.inp.nodeId ?? ''}`]}
|
||||
<span class="f-error">
|
||||
{errors[`${mediaSlots.find((s) => /first|start|首帧/.test(hay(s.inp)))?.inp.nodeId ?? ''}|${mediaSlots.find((s) => /last|end|尾帧/.test(hay(s.inp)))?.inp.nodeId ?? ''}`]}
|
||||
</span>
|
||||
{#if showErrors && errors[pairKey]}
|
||||
<span class="f-error">{errors[pairKey]}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if refGroup}
|
||||
<div class="f-field">
|
||||
<RefImageGrid
|
||||
label={refGroup.label}
|
||||
paths={refPaths}
|
||||
max={refMax}
|
||||
sizes={fileSizes}
|
||||
uploadingName={uploads.uploading[refGroup.nodeId]}
|
||||
uploadingIndex={replacingIndex}
|
||||
rejects={slotRejects[refGroup.nodeId] ?? []}
|
||||
error={showErrors ? errors[refGroup.nodeId] : undefined}
|
||||
allowFromResults
|
||||
onAdd={refAdd}
|
||||
onReplace={refReplace}
|
||||
onRemove={refRemove}
|
||||
onDismissReject={(i) =>
|
||||
setRejects(refGroup.nodeId, (slotRejects[refGroup.nodeId] ?? []).filter((_, j) => j !== i))}
|
||||
onFromResults={() => openPicker(refGroup.nodeId)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if promptInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">
|
||||
@@ -1014,6 +1199,22 @@
|
||||
{@render fieldError(widthInp)}
|
||||
{@render fieldError(heightInp)}
|
||||
</div>
|
||||
{:else if resolutionInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('studio.resolutionLabel')}</span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
class:invalid={showErrors && errors[resolutionInp.nodeId]}
|
||||
type="number"
|
||||
min={resolutionInp.min}
|
||||
max={resolutionInp.max}
|
||||
step={resolutionInp.step ?? 32}
|
||||
value={String(values[resolutionInp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(resolutionInp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
<span class="panel-hint">{t('studio.resolutionHint')}</span>
|
||||
{@render fieldError(resolutionInp)}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if stepsInp || cfgInp || denoiseInp || batchInp || durationInp || fpsInp}
|
||||
@@ -1343,7 +1544,9 @@
|
||||
</div>
|
||||
{#if selectedFrames.length}
|
||||
<div class="frame-strip">
|
||||
<span class="strip-label">{t('studio.videoRefStrip')}</span>
|
||||
<span class="strip-label">
|
||||
{selectedRefCount ? t('studio.refStrip', { n: selectedRefCount }) : t('studio.videoRefStrip')}
|
||||
</span>
|
||||
{#each selectedFrames as fr (fr.path)}
|
||||
<div class="frame-item">
|
||||
<img src={assetUrl(fr.path)} alt={fr.label} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'タスク記録を削除',
|
||||
|
||||
@@ -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': '작업 기록 삭제',
|
||||
|
||||
@@ -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': '删除任务记录',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user