Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c00587a56 | ||
|
|
7c49250267 | ||
|
|
c5aa575ac3 | ||
|
|
6623ffa04c | ||
|
|
0ee8280ddb | ||
|
|
1fc8bdb8bb |
@@ -0,0 +1,128 @@
|
||||
"""Regenerate ``src/calliope/comfyui/ui_widget_orders.py`` (HEL-681).
|
||||
|
||||
Offline widget-order registry for the UI→API converter. Sources, in priority:
|
||||
|
||||
1. ``widgets_values_named`` instances across ``example_ComfyUI_workflows``
|
||||
(including subgraph inner nodes) — the frontend serializes named widgets
|
||||
in widget order, so the key order IS the serialization order;
|
||||
2. the repo's 10 ground-truth API exports (root ``*_API.json``) — scalar
|
||||
input names in export order (control_after_generate entries are absent
|
||||
there; the converter's walk skips them positionally).
|
||||
|
||||
Run from anywhere inside the repo:
|
||||
python scripts/build_widget_orders.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path(__file__).resolve().parents[1]
|
||||
REPO = BACKEND.parent
|
||||
EXAMPLES = REPO / "example_ComfyUI_workflows"
|
||||
OUT = BACKEND / "src" / "calliope" / "comfyui" / "ui_widget_orders.py"
|
||||
|
||||
BUTTONS = {"preview", "audioUI", "compare_view", "ToggleableColorPicker", "imagebrowser"}
|
||||
MAX_SIGNATURES = 3
|
||||
MAX_ORDER_LEN = 48
|
||||
|
||||
# Hand-curated fallbacks for node classes the corpus never serializes with
|
||||
# names (classic core/custom widgets whose order is stable and public).
|
||||
CURATED: dict[str, list[list[str]]] = {
|
||||
"PrimitiveNode": [["value"]],
|
||||
"Seed (rgthree)": [["seed"]],
|
||||
}
|
||||
|
||||
|
||||
def _iter_ui_nodes() -> list[dict]:
|
||||
nodes: list[dict] = []
|
||||
for path in sorted(EXAMPLES.rglob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
|
||||
continue
|
||||
nodes.extend(n for n in data["nodes"] if isinstance(n, dict))
|
||||
for sub in data.get("definitions", {}).get("subgraphs", []) or []:
|
||||
if isinstance(sub, dict):
|
||||
nodes.extend(n for n in sub.get("nodes", []) or [] if isinstance(n, dict))
|
||||
return nodes
|
||||
|
||||
|
||||
def _iter_api_workflows() -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for path in sorted(EXAMPLES.glob("*_API.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if isinstance(data, dict) and "nodes" not in data:
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
learned: dict[str, collections.Counter[tuple[str, ...]]] = collections.defaultdict(
|
||||
collections.Counter
|
||||
)
|
||||
for node in _iter_ui_nodes():
|
||||
named = node.get("widgets_values_named")
|
||||
if not isinstance(named, dict) or not named:
|
||||
continue
|
||||
keys = tuple(
|
||||
str(k) for k in named.keys() if k and k not in BUTTONS
|
||||
)
|
||||
if keys and len(keys) <= MAX_ORDER_LEN:
|
||||
learned[str(node.get("type") or "")][keys] += 1
|
||||
|
||||
for workflow in _iter_api_workflows():
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
names = [
|
||||
str(k)
|
||||
for k, v in (node.get("inputs") or {}).items()
|
||||
if not isinstance(v, list) and k and k not in BUTTONS
|
||||
]
|
||||
if len(names) > MAX_ORDER_LEN:
|
||||
continue
|
||||
# Display nodes (PreviewAny…) carry zero scalar inputs — that
|
||||
# empty order is real signal: their positional arrays are pure
|
||||
# button leftovers and map to {}.
|
||||
learned[str(node.get("class_type") or "")][tuple(names)] += 1
|
||||
|
||||
registry: dict[str, list[list[str]]] = {}
|
||||
for node_type, counter in sorted(learned.items()):
|
||||
if not node_type:
|
||||
continue
|
||||
orders: list[list[str]] = []
|
||||
for signature, _count in counter.most_common(MAX_SIGNATURES):
|
||||
orders.append(list(signature))
|
||||
if orders:
|
||||
registry[node_type] = orders
|
||||
for node_type, orders in CURATED.items():
|
||||
registry.setdefault(node_type, orders)
|
||||
|
||||
body = json.dumps(registry, ensure_ascii=False, indent=1, sort_keys=True)
|
||||
OUT.write_text(
|
||||
'"""GENERATED by scripts/build_widget_orders.py — do not edit by hand.\n'
|
||||
"\n"
|
||||
"Widget-order registry for the UI→API converter: class_type → candidate\n"
|
||||
"widget name orders, learned from the repo's named-widget UI files and\n"
|
||||
"ground-truth API exports (HEL-681).\n"
|
||||
'"""\n'
|
||||
"\n"
|
||||
"from __future__ import annotations\n"
|
||||
"\n"
|
||||
f"WIDGET_ORDERS: dict[str, list[list[str]]] = {body}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
total = sum(len(o) for orders in registry.values() for o in orders)
|
||||
print(f"wrote {OUT.name}: {len(registry)} node types, {total} orders total")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Generate the HEL-681 dry-run report over the repo's UI workflow library.
|
||||
|
||||
Walks ``example_ComfyUI_workflows`` (or ``--root``), runs every file through
|
||||
the production converter + lint + parser pipeline (exactly what
|
||||
``POST /api/workflows/convert`` does, minus HTTP), and writes a JSON detail
|
||||
file plus a human-readable Markdown summary. Read-only: nothing is imported,
|
||||
no DB is touched.
|
||||
|
||||
Usage:
|
||||
python scripts/ui_import_dryrun.py [--root DIR] [--out PREFIX]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(BACKEND / "src"))
|
||||
|
||||
from calliope.comfyui.lint import workflow_warnings # noqa: E402
|
||||
from calliope.comfyui.metadata import suggested_metadata # noqa: E402
|
||||
from calliope.comfyui.parser import classify_usage # noqa: E402
|
||||
from calliope.comfyui.ui_convert import convert_ui_workflow # noqa: E402
|
||||
|
||||
REPO = BACKEND.parent
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", default=str(REPO / "example_ComfyUI_workflows"))
|
||||
parser.add_argument("--out", default=str(REPO / "dryrun_report"))
|
||||
args = parser.parse_args()
|
||||
root = Path(args.root)
|
||||
out_prefix = Path(args.out)
|
||||
|
||||
entries = []
|
||||
for path in sorted(root.rglob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
entries.append(
|
||||
{
|
||||
"source_path": str(path.relative_to(root)),
|
||||
"status": "fail",
|
||||
"issues": [{"code": "unreadable", "message": str(exc)}],
|
||||
}
|
||||
)
|
||||
continue
|
||||
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
|
||||
continue # API-format / non-workflow files are out of scope
|
||||
rel = str(path.relative_to(root))
|
||||
result = convert_ui_workflow(data)
|
||||
entry: dict = {
|
||||
"source_path": rel,
|
||||
"status": result.status,
|
||||
"needs_confirmation": result.needs_confirmation,
|
||||
"issues": [
|
||||
{"node_id": i.node_id, "code": i.code, "level": i.level, "message": i.message}
|
||||
for i in result.issues
|
||||
],
|
||||
}
|
||||
if result.workflow is not None:
|
||||
entry["node_count"] = len(result.workflow)
|
||||
entry["suggested"] = suggested_metadata(result.workflow, rel, data)
|
||||
errors = [w for w in workflow_warnings(result.workflow) if w["level"] == "error"]
|
||||
entry["lint_errors"] = [e["code"] for e in errors]
|
||||
entry["importable"] = not result.needs_confirmation and not errors
|
||||
else:
|
||||
entry["importable"] = False
|
||||
entries.append(entry)
|
||||
|
||||
counts = collections.Counter(e["status"] for e in entries)
|
||||
code_counts = collections.Counter(
|
||||
i["code"] for e in entries for i in e["issues"]
|
||||
)
|
||||
category_counts = collections.Counter(
|
||||
(e.get("suggested") or {}).get("category") or "?" for e in entries
|
||||
)
|
||||
family_counts = collections.Counter(
|
||||
(e.get("suggested") or {}).get("model_family") or "?" for e in entries
|
||||
)
|
||||
importable = sum(1 for e in entries if e.get("importable"))
|
||||
needs_confirmation = sum(1 for e in entries if e["needs_confirmation"])
|
||||
lint_blocked = [e for e in entries if e.get("lint_errors")]
|
||||
|
||||
detail = {
|
||||
"root": str(root),
|
||||
"total": len(entries),
|
||||
"status_counts": dict(counts),
|
||||
"issue_code_counts": dict(code_counts),
|
||||
"importable": importable,
|
||||
"needs_confirmation": needs_confirmation,
|
||||
"lint_blocked": [e["source_path"] for e in lint_blocked],
|
||||
"files": entries,
|
||||
}
|
||||
out_prefix.parent.mkdir(parents=True, exist_ok=True)
|
||||
(out_prefix.with_suffix(".json")).write_text(
|
||||
json.dumps(detail, ensure_ascii=False, indent=1), encoding="utf-8"
|
||||
)
|
||||
|
||||
lines = [
|
||||
"# HEL-681 工作流 UI→API 转换 dry-run 报告",
|
||||
"",
|
||||
f"- 扫描目录:`{root}`(根目录 10 个 API 格式旧文件不在本管线范围)",
|
||||
f"- UI 格式文件总数:**{len(entries)}**",
|
||||
f"- 转换结果:ok **{counts.get('ok', 0)}** / warn **{counts.get('warn', 0)}** / fail **{counts.get('fail', 0)}**",
|
||||
f"- 其中「需人工确认」(控件无法可靠映射,禁止直接导入):**{needs_confirmation}**",
|
||||
f"- 通过转换+lint 后可直接导入:**{importable}**",
|
||||
f"- 转换成功但被现有 lint 拦截(导入时按阻止处理):**{len(lint_blocked)}**",
|
||||
"",
|
||||
"## 分类分布(自动提取)",
|
||||
"",
|
||||
"| 分类 | 数量 |",
|
||||
"|---|---|",
|
||||
]
|
||||
for cat, n in sorted(category_counts.items()):
|
||||
lines.append(f"| {cat} | {n} |")
|
||||
lines += ["", "## 模型家族分布(自动提取)", "", "| 家族 | 数量 |", "|---|---|"]
|
||||
for fam, n in sorted(family_counts.items(), key=lambda kv: -kv[1]):
|
||||
lines.append(f"| {fam} | {n} |")
|
||||
lines += ["", "## 问题代码统计", "", "| 代码 | 次数 | 含义 |", "|---|---|---|"]
|
||||
meaning = {
|
||||
"muted_input_dropped": "上游节点被静音,输入按未连接处理",
|
||||
"variable_upstream_dropped": "Set/Get 变量上游被旁路,取值按未连接处理",
|
||||
"get_without_set": "GetNode 没有同名 SetNode",
|
||||
"widget_unmapped": "控件值无法可靠映射到输入名(需人工确认)",
|
||||
"widget_order_mismatch": "控件数量与已知顺序不符(需人工确认)",
|
||||
"no_output_node": "图中无已知输出节点,全部活跃节点保留待确认",
|
||||
"cyclic_variable": "Set/Get 循环(FOR 循环流),该连线按未连接处理",
|
||||
"dangling_link": "连线指向不存在的节点",
|
||||
}
|
||||
for code, n in code_counts.most_common():
|
||||
lines.append(f"| `{code}` | {n} | {meaning.get(code, '')} |")
|
||||
lines += ["", "## 需人工确认文件清单", ""]
|
||||
for e in entries:
|
||||
if e["needs_confirmation"]:
|
||||
reasons = sorted({i["code"] for i in e["issues"]})[:3]
|
||||
lines.append(f"- `{e['source_path']}`({', '.join(reasons)})")
|
||||
lines += ["", "## lint 拦截文件清单", ""]
|
||||
for e in lint_blocked:
|
||||
lines.append(f"- `{e['source_path']}`({', '.join(e['lint_errors'])})")
|
||||
lines += [
|
||||
"",
|
||||
"## 逐文件结果",
|
||||
"",
|
||||
"| 文件 | 状态 | 节点数 | 分类 | 模型家族 | 用途 | 问题 |",
|
||||
"|---|---|---|---|---|---|---|",
|
||||
]
|
||||
for e in entries:
|
||||
sug = e.get("suggested") or {}
|
||||
issues = collections.Counter(i["code"] for i in e["issues"])
|
||||
issue_text = ", ".join(f"{c}×{n}" for c, n in issues.most_common(2)) or "-"
|
||||
usage = sug.get("usage") or ("待定" if sug.get("usage_ambiguous") else "-")
|
||||
lines.append(
|
||||
f"| `{e['source_path']}` | {e['status']} | {e.get('node_count', '-')} "
|
||||
f"| {sug.get('category') or '-'} | {sug.get('model_family') or '-'} "
|
||||
f"| {usage} | {issue_text} |"
|
||||
)
|
||||
(out_prefix.with_suffix(".md")).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"{len(entries)} files: {dict(counts)}, importable={importable}, nc={needs_confirmation}")
|
||||
print(f"report → {out_prefix}.md / {out_prefix}.json")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Suggested workflow metadata for imports (HEL-681 / S2 data layer).
|
||||
|
||||
Everything here derives from two honest sources: the repo directory the file
|
||||
came from (category) and the converted API graph itself (usage, media in/out,
|
||||
model family, note text). Per HEL-675's inventory, clean display names and
|
||||
tags stay human work — the suggestion is a starting point, never a guess that
|
||||
hides uncertainty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.parser import (
|
||||
classify_usage,
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
|
||||
# Repo top-level directory → (category key, display label). Directory names
|
||||
# are mapping sources only — they never surface to users verbatim.
|
||||
CATEGORY_DIRS: dict[str, tuple[str, str]] = {
|
||||
"0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大": ("basic", "基础处理"),
|
||||
"1-语音生成": ("audio_tts", "语音生成"),
|
||||
"2-音乐生成": ("music", "音乐生成"),
|
||||
"3-图像生成": ("image", "图像生成"),
|
||||
"4-视频生成": ("video", "视频生成"),
|
||||
"5-数字人": ("digital_human", "数字人"),
|
||||
"6-动作迁移": ("motion_transfer", "动作迁移"),
|
||||
}
|
||||
|
||||
# Evidence patterns → model family. Shared VAE/text-encoder files must NOT
|
||||
# count (HEL-675: Anima/Z-Image graphs reference qwen_image_vae and get
|
||||
# mislabeled Qwen — `qwen.?image(?!_vae)` is deliberate).
|
||||
_FAMILY_PATTERNS: tuple[tuple[str, str], ...] = (
|
||||
(r"minimax[_\-]?(music|_audio)|MiniMaxMusic", "MiniMax-Music"),
|
||||
(r"minimax.?h3|minimax_h3|h3[-_]realism|fl2v[aa]?", "MiniMax-H3"),
|
||||
(r"ltx[-_.]?2", "LTX-2.5"),
|
||||
(r"ltx", "LTX"),
|
||||
(r"krea", "Krea2"),
|
||||
(r"qwen.?image(?!_vae)|qwen_image_2|qwen2vl|qwen3vl|Qwen3?[-.]?ASR|qwen3?[-_.]?tts", "Qwen"),
|
||||
(r"z[._-]?image", "Z-Image"),
|
||||
(r"flux", "Flux"),
|
||||
(r"\bwan", "Wan"),
|
||||
(r"ace[_-]?step", "ACE-Step"),
|
||||
(r"index[_-]?tts", "IndexTTS2"),
|
||||
(r"anima[-_.]?(turbo|klein)", "Anima"),
|
||||
(r"sdpose", "SDPose"),
|
||||
(r"gemma", "Gemma"),
|
||||
(r"instantmesh|instant_mesh", "InstantMesh"),
|
||||
(r"hidream", "HiDream"),
|
||||
)
|
||||
_FAMILY_RE = [(re.compile(pat, re.IGNORECASE), fam) for pat, fam in _FAMILY_PATTERNS]
|
||||
|
||||
# Main-model loaders dominate the vote: a UNET/checkpoint reference says far
|
||||
# more about the workflow's family than a shared VAE or text encoder does.
|
||||
_PRIMARY_LOADER_RE = re.compile(r"UNETLoader|CheckpointLoader|unet|diffusion_model", re.IGNORECASE)
|
||||
|
||||
# Directory names that carry family evidence the files' own widget values
|
||||
# lack (HEL-675: 14 krea2 files reference no "krea" string at all).
|
||||
_FAMILY_DIRS = (
|
||||
(re.compile(r"krea2", re.IGNORECASE), "Krea2"),
|
||||
(re.compile(r"Minimax-h3|uncensored", re.IGNORECASE), "MiniMax-H3"),
|
||||
(re.compile(r"Z-Image", re.IGNORECASE), "Z-Image"),
|
||||
(re.compile(r"Qwen-Image", re.IGNORECASE), "Qwen"),
|
||||
(re.compile(r"^Anima$", re.IGNORECASE), "Anima"),
|
||||
(re.compile(r"Flux", re.IGNORECASE), "Flux"),
|
||||
)
|
||||
|
||||
_NOTE_TYPES = frozenset({"Note", "MarkdownNote"})
|
||||
|
||||
|
||||
def category_from_source_path(source_path: str | None) -> tuple[str | None, str | None]:
|
||||
"""(category key, label) derived from the file's repo-relative path."""
|
||||
if not source_path:
|
||||
return None, None
|
||||
head = source_path.replace("\\", "/").split("/", 1)[0]
|
||||
return CATEGORY_DIRS.get(head, (None, None))
|
||||
|
||||
|
||||
def model_family_from(workflow: dict[str, Any], source_path: str | None = None) -> str | None:
|
||||
"""The graph's dominant model family: main-model loader references weigh
|
||||
triple, other node evidence single, and the repo sub-directory adds a
|
||||
strong vote for families the widget values don't spell out."""
|
||||
votes: dict[str, int] = {}
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
class_type = str(node.get("class_type") or "")
|
||||
blob = " ".join(
|
||||
[class_type]
|
||||
+ [str(v) for v in (node.get("inputs") or {}).values() if isinstance(v, str)]
|
||||
)
|
||||
weight = 3 if _PRIMARY_LOADER_RE.search(blob) else 1
|
||||
for pattern, family in _FAMILY_RE:
|
||||
if pattern.search(blob):
|
||||
votes[family] = votes.get(family, 0) + weight
|
||||
break # one vote per node: strongest family that node shows
|
||||
if source_path:
|
||||
parts = source_path.replace("\\", "/").split("/")
|
||||
for part in parts[1:2]:
|
||||
for pattern, family in _FAMILY_DIRS:
|
||||
if pattern.search(part):
|
||||
votes[family] = votes.get(family, 0) + 2
|
||||
if not votes:
|
||||
return None
|
||||
return max(votes.items(), key=lambda kv: kv[1])[0]
|
||||
|
||||
|
||||
def note_description(ui_workflow: dict[str, Any], limit: int = 160) -> str | None:
|
||||
"""First non-trivial author note as description material (never final copy)."""
|
||||
for node in ui_workflow.get("nodes") or []:
|
||||
if not isinstance(node, dict) or str(node.get("type")) not in _NOTE_TYPES:
|
||||
continue
|
||||
values = node.get("widgets_values")
|
||||
text = values[0] if isinstance(values, list) and values else None
|
||||
if isinstance(values, dict):
|
||||
text = values.get("text") or next(iter(values.values()), None)
|
||||
if isinstance(text, str):
|
||||
text = text.strip()
|
||||
if len(text) >= 8:
|
||||
return text[:limit].strip()
|
||||
return None
|
||||
|
||||
|
||||
def suggested_metadata(
|
||||
converted: dict[str, Any],
|
||||
source_path: str | None = None,
|
||||
ui_workflow: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""The import suggestion bundle for one converted workflow."""
|
||||
judgment = classify_usage(converted)
|
||||
media_kinds = ("image", "image_list", "video", "video_list", "audio", "audio_list")
|
||||
media_in = sorted(
|
||||
{
|
||||
entry["kind"].replace("_list", "")
|
||||
for entry in parse_dynamic_inputs(converted)
|
||||
if entry.get("kind") in media_kinds
|
||||
}
|
||||
)
|
||||
media_out = sorted(
|
||||
{
|
||||
str(entry.get("kind"))
|
||||
for entry in parse_dynamic_outputs(converted)
|
||||
if entry.get("kind") in ("image", "video", "audio")
|
||||
}
|
||||
)
|
||||
category, category_label = category_from_source_path(source_path)
|
||||
return {
|
||||
"kind": "video" if "video" in media_out and "image" not in media_out else "image",
|
||||
"usage": judgment["usage"],
|
||||
"usage_ambiguous": judgment["ambiguous"],
|
||||
"category": category,
|
||||
"category_label": category_label,
|
||||
"model_family": model_family_from(converted, source_path),
|
||||
"media_in": media_in,
|
||||
"media_out": media_out,
|
||||
"description": note_description(ui_workflow or {}),
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
"""UI→API converter for ComfyUI workflow JSON (HEL-681 / S2 data layer).
|
||||
|
||||
ComfyUI saves two formats: the editor ("UI") format with ``nodes``/``links``
|
||||
arrays, and the API ("prompt") format ``{node_id: {class_type, inputs}}`` the
|
||||
product executes. This module converts UI→API offline and deterministically:
|
||||
|
||||
* links are rewritten through ``Reroute`` chains and bypassed (mode 4) nodes;
|
||||
* packed subgraph instances (frontend ≥1.x ``definitions.subgraphs``) expand
|
||||
into their inner nodes under qualified ids ``<packed>:<inner>`` — the same
|
||||
scheme ComfyUI itself exports;
|
||||
* widget values map through, in priority order: the node's serialized input
|
||||
list (entries carrying a ``widget`` marker — authoritative, this corpus's
|
||||
frontend version always writes them), the ``widgets_values_named`` dict,
|
||||
then a generated registry of widget orders learned from the corpus and the
|
||||
repo's ground-truth API exports (``ui_widget_orders``).
|
||||
|
||||
Anything that cannot be mapped with certainty is reported as an issue and the
|
||||
file is flagged ``needs_confirmation`` — never silently guessed: a wrong
|
||||
widget order produces a runnable-looking workflow with swapped seed/steps,
|
||||
which is worse than an honest「需人工确认」.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.ui_widget_orders import WIDGET_ORDERS
|
||||
|
||||
# Values of the frontend-only "control_after_generate" control that serializes
|
||||
# right after every seed-like INT widget (and is not a real server input).
|
||||
_CONTROL_MODES = frozenset({"fixed", "increment", "decrement", "randomize"})
|
||||
|
||||
# Widget names that are pure UI buttons/previews — never serialized in
|
||||
# widgets_values (named dicts skip them too) and never API inputs.
|
||||
_BUTTON_WIDGETS = frozenset(
|
||||
{"preview", "audioUI", "compare_view", "ToggleableColorPicker", "imagebrowser"}
|
||||
)
|
||||
|
||||
# Leftover positional values that are provably action buttons, not data:
|
||||
# known labels, self-describing ids, or emoji-prefixed actions. Anything
|
||||
# else left over keeps the file in the needs-confirmation queue.
|
||||
_BUTTON_LEFTOVER_LABELS = frozenset({"Edit", "lanpaint_star_button"})
|
||||
_BUTTON_LEFTOVER_EMOJI = ("🖼", "🎬", "🎵", "➕", "🔁", "🗑", "📂", "🔎", "✨")
|
||||
|
||||
|
||||
def _is_button_leftover(value: Any) -> bool:
|
||||
if value is None or value == "":
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
return (
|
||||
value in _BUTTON_LEFTOVER_LABELS
|
||||
or "button" in value.lower()
|
||||
or value.startswith(_BUTTON_LEFTOVER_EMOJI)
|
||||
)
|
||||
return False
|
||||
|
||||
# Virtual editor-only nodes. Links pass *through* reroute-style ones (see
|
||||
# _resolve_source); the rest are dropped from the graph entirely.
|
||||
_REROUTE_TYPES = frozenset({"Reroute", "Reroute (rgthree)"})
|
||||
# Frontend-virtual variable nodes: SetNode captures a value under a name,
|
||||
# GetNode reads it. Neither reaches the server — links through a Get resolve
|
||||
# to whatever its same-named Set captured (scope-local).
|
||||
_SET_TYPES = frozenset({"SetNode"})
|
||||
_GET_TYPES = frozenset({"GetNode"})
|
||||
_UI_ONLY_TYPES = frozenset(
|
||||
{
|
||||
"Note",
|
||||
"MarkdownNote",
|
||||
"Image Comparer (rgthree)",
|
||||
"GH_ImageVideoComparer",
|
||||
"Label (rgthree)",
|
||||
"Fast Groups Bypasser (rgthree)",
|
||||
"Fast Groups Muter (rgthree)",
|
||||
"Fast Muter (rgthree)",
|
||||
"FastGroupsBypassSwitch",
|
||||
"Mute / Bypass Repeater (rgthree)",
|
||||
}
|
||||
)
|
||||
|
||||
# Server-side OUTPUT nodes: the closure roots. Learned from the repo's
|
||||
# ground-truth API exports (their unreferenced classes) + the corpus's
|
||||
# real save/preview sinks (SaveVideo/SaveImageAdvanced/… custom nodes).
|
||||
_OUTPUT_CLASSES = frozenset(
|
||||
{
|
||||
"SaveImage",
|
||||
"PreviewImage",
|
||||
"SaveImageAdvanced",
|
||||
"SaveImageKJ",
|
||||
"SaveAnimatedWEBP",
|
||||
"SaveAnimatedPNG",
|
||||
"SaveAnimatedGIF",
|
||||
"SaveVideo",
|
||||
"SaveAudio",
|
||||
"SaveAudioMP3",
|
||||
"SaveAudioAdvanced",
|
||||
"PreviewAudio",
|
||||
"MaskPreview",
|
||||
"MaskPreview+",
|
||||
"ImageAndMaskPreview",
|
||||
"VHS_VideoCombine",
|
||||
"easy saveVideo",
|
||||
"easy multitrackProjectVideoCombine",
|
||||
"easy multiTrackInfoOutput",
|
||||
"easy multiTrackTaskOutput",
|
||||
"easy compareVideos",
|
||||
"easy compareImages",
|
||||
"easy showText",
|
||||
"easy imageWall",
|
||||
"CR Prompt Text",
|
||||
"PreviewAny",
|
||||
"easy clearGpuUsed",
|
||||
"easy clearCacheAll",
|
||||
"easy showSpentTime",
|
||||
}
|
||||
)
|
||||
|
||||
MODE_ACTIVE = 0
|
||||
MODE_MUTED = 2
|
||||
MODE_BYPASSED = 4
|
||||
|
||||
_SUBGRAPH_IN = -10 # pseudo-node id of a subgraph's boundary input
|
||||
_SUBGRAPH_OUT = -20 # pseudo-node id of a subgraph's boundary output
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertIssue:
|
||||
node_id: str
|
||||
code: str
|
||||
message: str
|
||||
level: str = "warning" # 'error' | 'warning'
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertResult:
|
||||
workflow: dict[str, Any] | None
|
||||
status: str # 'ok' | 'warn' | 'fail'
|
||||
issues: list[ConvertIssue] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def needs_confirmation(self) -> bool:
|
||||
"""Widget mapping uncertain — must not be imported as runnable."""
|
||||
return any(
|
||||
i.code in ("widget_unmapped", "widget_order_mismatch") for i in self.issues
|
||||
)
|
||||
|
||||
|
||||
def _norm_link(link: Any) -> tuple[int, int, int, int, int, str] | None:
|
||||
"""(id, origin_id, origin_slot, target_id, target_slot, type) from either form."""
|
||||
if isinstance(link, dict):
|
||||
try:
|
||||
return (
|
||||
int(link["id"]),
|
||||
int(link["origin_id"]),
|
||||
int(link["origin_slot"]),
|
||||
int(link["target_id"]),
|
||||
int(link["target_slot"]),
|
||||
str(link.get("type") or "*"),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if isinstance(link, (list, tuple)) and len(link) >= 5:
|
||||
try:
|
||||
return (
|
||||
int(link[0]),
|
||||
int(link[1]),
|
||||
int(link[2]),
|
||||
int(link[3]),
|
||||
int(link[4]),
|
||||
str(link[5]) if len(link) > 5 and link[5] else "*",
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _is_seed_like(name: str) -> bool:
|
||||
lowered = name.lower()
|
||||
return lowered in ("seed", "noise_seed", "value", "rand_seed", "random_seed") or (
|
||||
lowered.endswith("seed")
|
||||
)
|
||||
|
||||
|
||||
def _is_upload_entry(name: str, type_: str) -> bool:
|
||||
return type_.upper().endswith("UPLOAD") or name == "upload" or "UPLOAD" in type_.upper()
|
||||
|
||||
|
||||
class _Converter:
|
||||
"""One conversion run over a single UI-format document."""
|
||||
|
||||
def __init__(self, ui: dict[str, Any]) -> None:
|
||||
self.ui = ui
|
||||
self.issues: list[ConvertIssue] = []
|
||||
self.subgraphs: dict[str, dict[str, Any]] = {
|
||||
str(s.get("id")): s
|
||||
for s in self.ui.get("definitions", {}).get("subgraphs", []) or []
|
||||
if isinstance(s, dict)
|
||||
}
|
||||
# qualified id → UI node + owning scope
|
||||
self.nodes: dict[str, dict[str, Any]] = {}
|
||||
# scope key ('' for top level) → link table / node-id index / owner
|
||||
self.scope_links: dict[str, dict[int, tuple[int, int, int, int, str]]] = {}
|
||||
self.scope_ids: dict[str, dict[str, str]] = {}
|
||||
self.scope_owner: dict[str, str] = {}
|
||||
self._set_capture_cache: dict[str, dict[str, tuple[str, int]]] = {}
|
||||
self._set_names: dict[str, set[str]] = {}
|
||||
self._depth = 0
|
||||
self._cycle_warned = False
|
||||
self._set_building: set[str] = set()
|
||||
|
||||
# -------------------------------------------------------------- indexing
|
||||
def _index_scope(self, scope: str, links: list[Any], owner: str | None) -> None:
|
||||
table = self.scope_links.setdefault(scope, {})
|
||||
for link in links or []:
|
||||
norm = _norm_link(link)
|
||||
if norm is not None:
|
||||
table[norm[0]] = norm[1:]
|
||||
self.scope_ids.setdefault(scope, {})
|
||||
if owner is not None:
|
||||
self.scope_owner[scope] = owner
|
||||
|
||||
def _flatten(
|
||||
self, node: dict[str, Any], scope: str, prefix: str
|
||||
) -> None:
|
||||
qualified = f"{prefix}{node.get('id')}"
|
||||
self.nodes[qualified] = {"node": node, "scope": scope}
|
||||
self.scope_ids.setdefault(scope, {})[str(node.get("id"))] = qualified
|
||||
sub = self.subgraphs.get(str(node.get("type") or ""))
|
||||
if sub is None:
|
||||
return
|
||||
if not isinstance(sub.get("nodes"), list):
|
||||
self.issues.append(
|
||||
ConvertIssue(qualified, "subgraph_bad_definition", "子图缺少内部节点定义")
|
||||
)
|
||||
return
|
||||
sub_scope = f"{scope}|{node.get('type')}" if scope else f"{node.get('type')}"
|
||||
if sub_scope not in self.scope_links:
|
||||
self._index_scope(sub_scope, sub.get("links") or [], qualified)
|
||||
for inner in sub["nodes"]:
|
||||
if isinstance(inner, dict):
|
||||
self._flatten(inner, sub_scope, f"{qualified}:")
|
||||
|
||||
# --------------------------------------------------------------- lookups
|
||||
def _node(self, qualified: str) -> dict[str, Any] | None:
|
||||
entry = self.nodes.get(qualified)
|
||||
return entry["node"] if entry else None
|
||||
|
||||
def _scope(self, qualified: str) -> str:
|
||||
entry = self.nodes.get(qualified)
|
||||
return entry["scope"] if entry else ""
|
||||
|
||||
def _link(self, scope: str, link_id: Any) -> tuple[int, int, int, int, str] | None:
|
||||
if link_id is None:
|
||||
return None
|
||||
try:
|
||||
key = int(link_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return self.scope_links.get(scope, {}).get(key)
|
||||
|
||||
def _boundary_source(self, scope: str, link_id: Any) -> tuple[str, int] | None:
|
||||
"""(packed qualified id, boundary slot) when this link leaves a
|
||||
subgraph through its -10 input boundary."""
|
||||
link = self._link(scope, link_id)
|
||||
if link is None or link[0] != _SUBGRAPH_IN:
|
||||
return None
|
||||
packed_q = self.scope_owner.get(scope)
|
||||
if packed_q is None:
|
||||
return None
|
||||
return packed_q, link[1]
|
||||
|
||||
def _subgraph_of(self, qualified: str) -> dict[str, Any] | None:
|
||||
node = self._node(qualified)
|
||||
if node is None:
|
||||
return None
|
||||
return self.subgraphs.get(str(node.get("type") or ""))
|
||||
|
||||
@staticmethod
|
||||
def _virtual_name(node: dict[str, Any]) -> str:
|
||||
"""A Set/Get node's captured variable name (its first widget value)."""
|
||||
named = node.get("widgets_values_named")
|
||||
if isinstance(named, dict):
|
||||
for key in ("const_name", "name", "value"):
|
||||
if key in named:
|
||||
return str(named[key])
|
||||
values = node.get("widgets_values")
|
||||
if isinstance(values, list) and values and isinstance(values[0], str):
|
||||
return values[0]
|
||||
return ""
|
||||
|
||||
def _set_captures(self, scope: str) -> dict[str, tuple[str, int]]:
|
||||
"""{variable name → resolved source} for the Set nodes in one scope."""
|
||||
table = self._set_capture_cache.get(scope)
|
||||
if table is not None:
|
||||
return table
|
||||
table = {}
|
||||
# Seed before resolving: a Set feeding a same-named Get (FOR-loop
|
||||
# graphs) would otherwise recurse into this builder forever. A miss
|
||||
# while building is not a missing Set — never warn mid-construction.
|
||||
self._set_capture_cache[scope] = table
|
||||
self._set_building.add(scope)
|
||||
try:
|
||||
return self._build_set_captures(scope, table)
|
||||
finally:
|
||||
self._set_building.discard(scope)
|
||||
|
||||
def _build_set_captures(
|
||||
self, scope: str, table: dict[str, tuple[str, int]]
|
||||
) -> dict[str, tuple[str, int]]:
|
||||
names: set[str] = set()
|
||||
for qualified, entry in self.nodes.items():
|
||||
if entry["scope"] != scope:
|
||||
continue
|
||||
node = entry["node"]
|
||||
if str(node.get("type") or "") not in _SET_TYPES:
|
||||
continue
|
||||
name = self._virtual_name(node)
|
||||
if not name:
|
||||
continue
|
||||
names.add(name)
|
||||
ins = self._inputs_of(node)
|
||||
link = self._link(scope, ins[0].get("link")) if ins else None
|
||||
resolved = self._resolve_link(scope, link)
|
||||
if resolved is not None:
|
||||
table.setdefault(name, resolved)
|
||||
self._set_names[scope] = names
|
||||
self._set_capture_cache[scope] = table
|
||||
return table
|
||||
|
||||
@staticmethod
|
||||
def _inputs_of(node: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [i for i in node.get("inputs") or [] if isinstance(i, dict)]
|
||||
|
||||
@staticmethod
|
||||
def _outputs_of(node: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [o for o in node.get("outputs") or [] if isinstance(o, dict)]
|
||||
|
||||
# ------------------------------------------------------------- resolution
|
||||
def _resolve_link(
|
||||
self, scope: str, link: tuple[int, int, int, int, str] | None
|
||||
) -> tuple[str, int] | None:
|
||||
"""Resolve one link (in scope) to an API source (qualified id, slot)."""
|
||||
if link is None:
|
||||
return None
|
||||
if self._depth > 200: # Set/Get cycles (FOR-loop graphs) never unwind
|
||||
if not self._cycle_warned:
|
||||
self._cycle_warned = True
|
||||
self.issues.append(
|
||||
ConvertIssue(
|
||||
"",
|
||||
"cyclic_variable",
|
||||
"变量/Set-Get 连线存在循环(常见于 FOR 循环流),该连线按未连接处理",
|
||||
)
|
||||
)
|
||||
return None
|
||||
self._depth += 1
|
||||
try:
|
||||
return self._resolve_link_inner(scope, link)
|
||||
finally:
|
||||
self._depth -= 1
|
||||
|
||||
def _resolve_link_inner(
|
||||
self, scope: str, link: tuple[int, int, int, int, str]
|
||||
) -> tuple[str, int] | None:
|
||||
origin_id, origin_slot = link[0], link[1]
|
||||
if origin_id == _SUBGRAPH_IN:
|
||||
# Boundary: hand off to the packed instance's own input slot.
|
||||
packed_q = self.scope_owner.get(scope)
|
||||
if packed_q is None:
|
||||
return None
|
||||
node = self._node(packed_q)
|
||||
if node is None:
|
||||
return None
|
||||
entries = self._inputs_of(node)
|
||||
if origin_slot >= len(entries):
|
||||
return None
|
||||
entry = entries[origin_slot]
|
||||
if entry.get("link") is None:
|
||||
return None # unlinked boundary → inner widget value applies
|
||||
outer = self._scope(packed_q)
|
||||
return self._resolve_link(outer, self._link(outer, entry.get("link")))
|
||||
qualified = self.scope_ids.get(scope, {}).get(str(origin_id))
|
||||
if qualified is None:
|
||||
return None
|
||||
return self._resolve_source(qualified, origin_slot)
|
||||
|
||||
def _resolve_source(self, qualified: str, slot: int) -> tuple[str, int] | None:
|
||||
"""What an outgoing edge from (node, output slot) really connects to."""
|
||||
node = self._node(qualified)
|
||||
if node is None:
|
||||
return None
|
||||
node_type = str(node.get("type") or "")
|
||||
mode = node.get("mode") or 0
|
||||
scope = self._scope(qualified)
|
||||
|
||||
if node_type in _UI_ONLY_TYPES:
|
||||
return None
|
||||
|
||||
if node_type in _REROUTE_TYPES:
|
||||
ins = self._inputs_of(node)
|
||||
if not ins:
|
||||
return None
|
||||
return self._resolve_link(scope, self._link(scope, ins[0].get("link")))
|
||||
|
||||
if node_type in _SET_TYPES:
|
||||
ins = self._inputs_of(node)
|
||||
if not ins:
|
||||
return None
|
||||
return self._resolve_link(scope, self._link(scope, ins[0].get("link")))
|
||||
|
||||
if node_type in _GET_TYPES:
|
||||
name = self._virtual_name(node)
|
||||
captured = self._set_captures(scope).get(name)
|
||||
if captured is None and scope not in self._set_building:
|
||||
if name in self._set_names.get(scope, set()):
|
||||
self.issues.append(
|
||||
ConvertIssue(
|
||||
qualified,
|
||||
"variable_upstream_dropped",
|
||||
f"变量「{name}」的上游被旁路或未连接,取值按未连接处理",
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.issues.append(
|
||||
ConvertIssue(
|
||||
qualified,
|
||||
"get_without_set",
|
||||
f"变量节点「{name}」没有对应的 Set 节点",
|
||||
)
|
||||
)
|
||||
return captured
|
||||
|
||||
sub = self._subgraph_of(qualified)
|
||||
if sub is not None:
|
||||
outputs = [o for o in sub.get("outputs") or [] if isinstance(o, dict)]
|
||||
if slot >= len(outputs):
|
||||
return None
|
||||
sub_scope = f"{scope}|{node_type}" if scope else node_type
|
||||
for link in self.scope_links.get(sub_scope, {}).values():
|
||||
if link[2] == _SUBGRAPH_OUT and link[3] == slot:
|
||||
inner_q = self.scope_ids.get(sub_scope, {}).get(str(link[0]))
|
||||
return self._resolve_source(inner_q, link[1]) if inner_q else None
|
||||
return None # boundary output with no inner source
|
||||
|
||||
if mode == MODE_MUTED:
|
||||
return None
|
||||
if mode == MODE_BYPASSED:
|
||||
return self._bypass_through(qualified, slot)
|
||||
return (qualified, slot)
|
||||
|
||||
def _bypass_through(self, qualified: str, slot: int) -> tuple[str, int] | None:
|
||||
"""Pass-through of a bypassed node: same-name input first, then same-type."""
|
||||
node = self._node(qualified)
|
||||
if node is None:
|
||||
return None
|
||||
outputs = self._outputs_of(node)
|
||||
out = outputs[slot] if slot < len(outputs) else {}
|
||||
out_name = str(out.get("name") or "")
|
||||
out_type = str(out.get("type") or "*")
|
||||
linkable = [
|
||||
e for e in self._inputs_of(node) if not e.get("widget") and e.get("link") is not None
|
||||
]
|
||||
pick = None
|
||||
if out_name:
|
||||
pick = next((e for e in linkable if str(e.get("name") or "") == out_name), None)
|
||||
if pick is None:
|
||||
pick = next(
|
||||
(
|
||||
e
|
||||
for e in linkable
|
||||
if out_type == "*" or str(e.get("type") or "*") in (out_type, "*")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if pick is None:
|
||||
return None
|
||||
scope = self._scope(qualified)
|
||||
return self._resolve_link(scope, self._link(scope, pick.get("link")))
|
||||
|
||||
# --------------------------------------------------------- widget mapping
|
||||
def _widget_entries(self, node: dict[str, Any]) -> list[tuple[str, str]]:
|
||||
"""[(name, type)] of entries carrying the ``widget`` marker, in order."""
|
||||
out: list[tuple[str, str]] = []
|
||||
for entry in self._inputs_of(node):
|
||||
marker = entry.get("widget")
|
||||
if isinstance(marker, dict):
|
||||
name = str(marker.get("name") or entry.get("name") or "")
|
||||
if name:
|
||||
out.append((name, str(entry.get("type") or "")))
|
||||
return out
|
||||
|
||||
def _widgets_from_entries(
|
||||
self,
|
||||
node_id: str,
|
||||
entries: list[tuple[str, str]],
|
||||
values: Any,
|
||||
) -> tuple[dict[str, Any], list[ConvertIssue]]:
|
||||
"""Map widget values using the node's serialized input list."""
|
||||
inputs: dict[str, Any] = {}
|
||||
issues: list[ConvertIssue] = []
|
||||
if isinstance(values, dict):
|
||||
for name, type_ in entries:
|
||||
if name in _BUTTON_WIDGETS or self._is_upload(name, type_):
|
||||
continue
|
||||
if name in values:
|
||||
inputs[name] = values[name]
|
||||
else:
|
||||
issues.append(
|
||||
ConvertIssue(node_id, "widget_unmapped", f"控件「{name}」没有序列化值")
|
||||
)
|
||||
return inputs, issues
|
||||
if not isinstance(values, list):
|
||||
return inputs, issues
|
||||
if not values:
|
||||
return inputs, issues # nothing serialized — server defaults apply
|
||||
i = 0
|
||||
for name, type_ in entries:
|
||||
if name in _BUTTON_WIDGETS:
|
||||
continue # button widgets never serialize
|
||||
if i >= len(values):
|
||||
issues.append(
|
||||
ConvertIssue(node_id, "widget_order_mismatch", f"控件值数量不足({name})")
|
||||
)
|
||||
break
|
||||
value = values[i]
|
||||
i += 1
|
||||
if self._is_upload(name, type_):
|
||||
continue # consumes a slot, never an API input
|
||||
inputs[name] = value
|
||||
if (
|
||||
type_.upper() == "INT"
|
||||
and _is_seed_like(name)
|
||||
and i < len(values)
|
||||
and values[i] in _CONTROL_MODES
|
||||
):
|
||||
i += 1 # control_after_generate rides right after its seed
|
||||
leftover_ok = all(_is_button_leftover(v) for v in values[i:])
|
||||
if not issues and i != len(values) and not leftover_ok:
|
||||
issues.append(
|
||||
ConvertIssue(
|
||||
node_id,
|
||||
"widget_order_mismatch",
|
||||
f"有 {len(values)} 个控件值,输入清单只覆盖 {i} 个",
|
||||
)
|
||||
)
|
||||
return inputs, issues
|
||||
|
||||
@staticmethod
|
||||
def _is_upload(name: str, type_: str) -> bool:
|
||||
return _is_upload_entry(name, type_)
|
||||
|
||||
def _widgets_from_registry(
|
||||
self, node: dict[str, Any], values: list[Any]
|
||||
) -> tuple[dict[str, Any], list[ConvertIssue]]:
|
||||
node_type = str(node.get("type") or "")
|
||||
if not values:
|
||||
return {}, [] # nothing serialized — server defaults apply
|
||||
for order in WIDGET_ORDERS.get(node_type) or []:
|
||||
inputs: dict[str, Any] = {}
|
||||
i = 0
|
||||
for name in order:
|
||||
if name in _BUTTON_WIDGETS:
|
||||
continue
|
||||
if i >= len(values):
|
||||
break
|
||||
value = values[i]
|
||||
i += 1
|
||||
if _is_upload_entry(name, ""):
|
||||
continue
|
||||
inputs[name] = value
|
||||
if _is_seed_like(name) and i < len(values) and values[i] in _CONTROL_MODES:
|
||||
i += 1
|
||||
if i == len(values) or all(_is_button_leftover(v) for v in values[i:]):
|
||||
return inputs, [] # exact fit, or trailing button leftovers
|
||||
return {}, [
|
||||
ConvertIssue(
|
||||
node.get("id", ""),
|
||||
"widget_unmapped" if not WIDGET_ORDERS.get(node_type) else "widget_order_mismatch",
|
||||
f"「{node_type}」的 {len(values)} 个控件值无法可靠对应到输入名",
|
||||
)
|
||||
]
|
||||
|
||||
def _packed_widget_values(self, qualified: str) -> Any:
|
||||
"""Exposed widget values of a packed instance as {boundary name: value}."""
|
||||
node = self._node(qualified)
|
||||
sub = self._subgraph_of(qualified)
|
||||
if node is None or sub is None:
|
||||
return None
|
||||
boundary = [i for i in sub.get("inputs") or [] if isinstance(i, dict)]
|
||||
named = node.get("widgets_values_named")
|
||||
if isinstance(named, dict):
|
||||
return named
|
||||
values = node.get("widgets_values")
|
||||
if not isinstance(values, list):
|
||||
return None
|
||||
out: dict[str, Any] = {}
|
||||
i = 0
|
||||
for entry in boundary:
|
||||
name = str(entry.get("name") or "")
|
||||
type_ = str(entry.get("type") or "")
|
||||
if name and i < len(values):
|
||||
out[name] = values[i]
|
||||
i += 1
|
||||
if (
|
||||
type_.upper() == "INT"
|
||||
and _is_seed_like(name)
|
||||
and i < len(values)
|
||||
and values[i] in _CONTROL_MODES
|
||||
):
|
||||
i += 1
|
||||
return out
|
||||
|
||||
# -------------------------------------------------------------- serialize
|
||||
def _serialize_node(self, qualified: str) -> dict[str, Any] | None:
|
||||
node = self._node(qualified)
|
||||
if node is None:
|
||||
return None
|
||||
node_type = str(node.get("type") or "")
|
||||
if (
|
||||
not node_type
|
||||
or node_type in _UI_ONLY_TYPES
|
||||
or node_type in _REROUTE_TYPES
|
||||
or node_type in _SET_TYPES
|
||||
or node_type in _GET_TYPES
|
||||
):
|
||||
return None
|
||||
scope = self._scope(qualified)
|
||||
inputs: dict[str, Any] = {}
|
||||
|
||||
widget_values = node.get("widgets_values_named")
|
||||
if not isinstance(widget_values, dict):
|
||||
widget_values = node.get("widgets_values")
|
||||
if self._subgraph_of(qualified) is not None:
|
||||
widget_values = self._packed_widget_values(qualified)
|
||||
|
||||
widget_inputs: dict[str, Any] = {}
|
||||
entries = self._widget_entries(node)
|
||||
if widget_values is not None:
|
||||
if entries:
|
||||
widget_inputs, w_issues = self._widgets_from_entries(
|
||||
qualified, entries, widget_values
|
||||
)
|
||||
self.issues.extend(w_issues)
|
||||
elif isinstance(widget_values, dict):
|
||||
widget_inputs = {
|
||||
k: v for k, v in widget_values.items() if k not in _BUTTON_WIDGETS
|
||||
}
|
||||
elif isinstance(widget_values, list):
|
||||
widget_inputs, w_issues = self._widgets_from_registry(node, widget_values)
|
||||
self.issues.extend(w_issues)
|
||||
|
||||
for entry in self._inputs_of(node):
|
||||
name = str(entry.get("name") or "")
|
||||
if not name or name in inputs:
|
||||
continue
|
||||
if entry.get("link") is None:
|
||||
continue # unconnected optional input; widget values applied below
|
||||
# Widget entries fed from the subgraph boundary (-10): when the
|
||||
# packed instance left that boundary slot unlinked, the value is
|
||||
# the packed node's own exposed widget value, not the inner one.
|
||||
boundary = self._boundary_source(scope, entry.get("link"))
|
||||
if boundary is not None:
|
||||
packed_q, boundary_slot = boundary
|
||||
packed_node = self._node(packed_q)
|
||||
packed_entries = self._inputs_of(packed_node) if packed_node else []
|
||||
if boundary_slot < len(packed_entries) and packed_entries[
|
||||
boundary_slot
|
||||
].get("link") is None:
|
||||
values = self._packed_widget_values(packed_q)
|
||||
boundary_name = str(packed_entries[boundary_slot].get("name") or "")
|
||||
if isinstance(values, dict) and boundary_name in values:
|
||||
inputs[name] = values[boundary_name]
|
||||
continue
|
||||
# linked boundary falls through to normal resolution
|
||||
link = self._link(scope, entry.get("link"))
|
||||
if link is None:
|
||||
self.issues.append(
|
||||
ConvertIssue(qualified, "dangling_link", f"输入「{name}」的连线不存在")
|
||||
)
|
||||
continue
|
||||
resolved = self._resolve_link(scope, link)
|
||||
if resolved is not None:
|
||||
inputs[name] = [resolved[0], resolved[1]]
|
||||
elif entry.get("widget"):
|
||||
pass # muted upstream: the widget value below takes over
|
||||
else:
|
||||
self.issues.append(
|
||||
ConvertIssue(
|
||||
qualified,
|
||||
"muted_input_dropped",
|
||||
f"输入「{name}」上游被静音,已按未连接处理",
|
||||
)
|
||||
)
|
||||
|
||||
for name, value in widget_inputs.items():
|
||||
inputs.setdefault(name, value)
|
||||
|
||||
api_node: dict[str, Any] = {"class_type": node_type, "inputs": inputs}
|
||||
title = str(node.get("title") or "").strip()
|
||||
if title:
|
||||
api_node["_meta"] = {"title": title}
|
||||
return api_node
|
||||
|
||||
# ------------------------------------------------------------------- main
|
||||
def run(self) -> ConvertResult:
|
||||
nodes = self.ui.get("nodes")
|
||||
if not isinstance(nodes, list) or not nodes:
|
||||
return ConvertResult(
|
||||
None,
|
||||
"fail",
|
||||
[ConvertIssue("", "no_nodes", "文件不含 nodes 数组,不是 UI 格式工作流", "error")],
|
||||
)
|
||||
try:
|
||||
self._index_scope("", self.ui.get("links") or [], None)
|
||||
for node in nodes:
|
||||
if isinstance(node, dict):
|
||||
self._flatten(node, "", "")
|
||||
except RecursionError:
|
||||
return ConvertResult(
|
||||
None, "fail", [ConvertIssue("", "subgraph_recursion", "子图嵌套过深", "error")]
|
||||
)
|
||||
|
||||
# Closure from known output nodes (ComfyUI's own graphToPrompt roots).
|
||||
roots = [
|
||||
q
|
||||
for q, entry in self.nodes.items()
|
||||
if str(entry["node"].get("type") or "") in _OUTPUT_CLASSES
|
||||
and (entry["node"].get("mode") or 0) == MODE_ACTIVE
|
||||
]
|
||||
keep: dict[str, None] = {}
|
||||
stack = list(roots)
|
||||
while stack:
|
||||
qualified = stack.pop()
|
||||
if qualified in keep:
|
||||
continue
|
||||
entry = self.nodes.get(qualified)
|
||||
if entry is None:
|
||||
continue
|
||||
keep[qualified] = None
|
||||
node = entry["node"]
|
||||
scope = entry["scope"]
|
||||
for e in self._inputs_of(node):
|
||||
if e.get("link") is None:
|
||||
continue
|
||||
link = self._link(scope, e.get("link"))
|
||||
resolved = self._resolve_link(scope, link)
|
||||
if resolved is not None:
|
||||
stack.append(resolved[0])
|
||||
|
||||
if not keep:
|
||||
# No known output node: keep every active computed node and warn —
|
||||
# the import lint will report the missing output separately.
|
||||
for qualified, entry in self.nodes.items():
|
||||
node = entry["node"]
|
||||
node_type = str(node.get("type") or "")
|
||||
if (
|
||||
node_type in _UI_ONLY_TYPES
|
||||
or node_type in _REROUTE_TYPES
|
||||
or (node.get("mode") or 0) != MODE_ACTIVE
|
||||
or self._subgraph_of(qualified) is not None
|
||||
):
|
||||
continue
|
||||
keep[qualified] = None
|
||||
self.issues.append(
|
||||
ConvertIssue(
|
||||
"",
|
||||
"no_output_node",
|
||||
"图中没有已知输出节点(SaveImage / 视频合成等),"
|
||||
"全部活跃节点已保留,需人工确认",
|
||||
)
|
||||
)
|
||||
|
||||
workflow: dict[str, Any] = {}
|
||||
for qualified in keep:
|
||||
api_node = self._serialize_node(qualified)
|
||||
if api_node is not None and api_node["class_type"]:
|
||||
workflow[qualified] = api_node
|
||||
|
||||
if not workflow:
|
||||
return ConvertResult(
|
||||
None,
|
||||
"fail",
|
||||
self.issues or [ConvertIssue("", "empty", "转换后没有任何节点", "error")],
|
||||
)
|
||||
status = (
|
||||
"fail"
|
||||
if any(i.level == "error" for i in self.issues)
|
||||
else ("ok" if not self.issues else "warn")
|
||||
)
|
||||
return ConvertResult(workflow, status, self.issues)
|
||||
|
||||
|
||||
def convert_ui_workflow(ui: dict[str, Any]) -> ConvertResult:
|
||||
"""Convert one ComfyUI UI-format workflow dict into API format."""
|
||||
if not isinstance(ui, dict) or not isinstance(ui.get("nodes"), list):
|
||||
return ConvertResult(
|
||||
None,
|
||||
"fail",
|
||||
[ConvertIssue("", "not_ui_format", "不是 UI 格式(缺少 nodes 数组)", "error")],
|
||||
)
|
||||
return _Converter(ui).run()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -506,6 +506,27 @@ async def migrate_db(db_path: Path) -> None:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN output_map TEXT")
|
||||
if "output_map_source" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN output_map_source TEXT")
|
||||
# HEL-681 (S2): library metadata for 150+ scale workflow browsing. Purely
|
||||
# additive columns — old rows stay NULL/0 and every legacy path ignores
|
||||
# them; the lightweight list view (?view=meta) is their only reader.
|
||||
if "category" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN category TEXT")
|
||||
if "model_family" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN model_family TEXT")
|
||||
if "media_io" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN media_io TEXT")
|
||||
if "tags" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN tags TEXT")
|
||||
if "favorite" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0")
|
||||
if "last_used_at" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN last_used_at TIMESTAMP")
|
||||
if "source" not in cols:
|
||||
# where the row came from: 'ui_convert' (HEL-681 batch import) vs
|
||||
# NULL = manual/API import as before.
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN source TEXT")
|
||||
if "source_path" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN source_path TEXT")
|
||||
# Idempotent backfill: pending rows are re-judged on every boot (parser
|
||||
# upgrades can settle them); confirmed rows are never touched.
|
||||
_backfill_workflow_usage(conn)
|
||||
|
||||
@@ -243,6 +243,55 @@ class WorkflowUpdate(BaseModel):
|
||||
# resets the workflow to pending confirmation.
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
output_map_source: Literal["auto", "user"] | None = None
|
||||
# HEL-681 (S2): library metadata fields.
|
||||
favorite: bool | None = None
|
||||
tags: list[str] | None = None
|
||||
category: str | None = None
|
||||
model_family: str | None = None
|
||||
|
||||
|
||||
class WorkflowUIFileItem(BaseModel):
|
||||
"""One UI-format file for the HEL-681 convert/import pipeline."""
|
||||
|
||||
name: str | None = None
|
||||
# repo-relative path (category/model-family evidence); display name
|
||||
# defaults to the file stem.
|
||||
source_path: str | None = None
|
||||
workflow_json: dict[str, Any]
|
||||
# optional human confirmations from the import wizard (S3): a settled
|
||||
# usage and a corrected schema let warn files import enabled.
|
||||
usage: WorkflowUsageKind | None = None
|
||||
input_schema: list[dict[str, Any]] | None = None
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
description: str | None = None
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
class WorkflowConvertRequest(BaseModel):
|
||||
"""Dry-run: per-file ok/warn/fail + suggested metadata, nothing stored."""
|
||||
|
||||
files: list[WorkflowUIFileItem]
|
||||
# Include the converted API JSON per file (default on for small batches;
|
||||
# the 146-file report leaves it off to keep the payload sane).
|
||||
include_workflows: bool = False
|
||||
# L2 preflight against a real ComfyUI (opt-in; needs a connection).
|
||||
endpoint_id: str | None = None
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class WorkflowImportBatchRequest(BaseModel):
|
||||
"""Commit: convert UI files and insert, skipping conflicts. Idempotent."""
|
||||
|
||||
files: list[WorkflowUIFileItem]
|
||||
|
||||
|
||||
class WorkflowImportScanRequest(BaseModel):
|
||||
"""Scan a server-side directory of UI JSONs (repo example library)."""
|
||||
|
||||
root: str = "example_ComfyUI_workflows"
|
||||
# Set to preview what would import (same shape as /convert) — the safe
|
||||
# default for a first run against the 146-file library.
|
||||
dry_run: bool = True
|
||||
|
||||
|
||||
class WorkflowAnalyze(BaseModel):
|
||||
|
||||
@@ -49,6 +49,12 @@ class QueueManager:
|
||||
""",
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload), origin),
|
||||
)
|
||||
# HEL-681: every enqueued job counts as a use (最近使用).
|
||||
if workflow_id is not None:
|
||||
conn.execute(
|
||||
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(workflow_id,),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
return row_to_dict(row)
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, HTTPException
|
||||
|
||||
from calliope.comfyui.endpoints import client_for, endpoint_snapshot
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.metadata import suggested_metadata
|
||||
from calliope.comfyui.output_map import (
|
||||
auto_output_map,
|
||||
load_output_map,
|
||||
@@ -23,6 +24,7 @@ from calliope.comfyui.parser import (
|
||||
from calliope.comfyui.preflight import check_workflow_against
|
||||
from calliope.comfyui.profiles import detect_prompt_profile
|
||||
from calliope.comfyui.roles import usage_to_kind
|
||||
from calliope.comfyui.ui_convert import convert_ui_workflow
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, row_to_dict
|
||||
@@ -30,8 +32,12 @@ from calliope.events.bus import event_bus
|
||||
from calliope.models.schemas import (
|
||||
WorkflowAnalyze,
|
||||
WorkflowCheckRequest,
|
||||
WorkflowConvertRequest,
|
||||
WorkflowCreate,
|
||||
WorkflowImportBatchRequest,
|
||||
WorkflowImportScanRequest,
|
||||
WorkflowTestRequest,
|
||||
WorkflowUIFileItem,
|
||||
WorkflowUpdate,
|
||||
)
|
||||
from calliope.queue.manager import queue_manager
|
||||
@@ -44,6 +50,14 @@ from calliope.queue.workflow_test import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# HEL-681: metadata columns the lightweight list view exposes (everything
|
||||
# except the heavy workflow_json / input_schema / output_map payloads).
|
||||
_META_COLUMNS = (
|
||||
"id, name, kind, usage, is_enabled, category, model_family, media_io, tags,"
|
||||
" favorite, last_used_at, description, source, source_path, prompt_profile,"
|
||||
" created_at, endpoint_id, usage_source, output_map_source"
|
||||
)
|
||||
|
||||
|
||||
def _serialize_workflow(row: Any) -> dict[str, Any]:
|
||||
data = row_to_dict(row)
|
||||
@@ -61,9 +75,21 @@ def _serialize_workflow(row: Any) -> dict[str, Any]:
|
||||
data["needs_mapping"] = (
|
||||
mapping_problem(data["workflow_json"], raw_map, data.get("output_map_source")) is not None
|
||||
)
|
||||
# HEL-681: structured library metadata (raw JSON columns → values).
|
||||
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
|
||||
data["tags"] = _load_json_col(data.get("tags"), [])
|
||||
data["favorite"] = bool(data.get("favorite"))
|
||||
return data
|
||||
|
||||
|
||||
def _load_json_col(raw: Any, default: Any) -> Any:
|
||||
try:
|
||||
value = json.loads(raw) if raw else None
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
value = None
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
async def _connection_check(
|
||||
endpoint_id: str | None, workflow_json: dict[str, Any], refresh: bool = False
|
||||
) -> dict[str, Any]:
|
||||
@@ -216,9 +242,31 @@ def _last_test_summary(job: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_workflows() -> list[dict[str, Any]]:
|
||||
async def list_workflows(view: str | None = None) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
if view == "meta":
|
||||
# HEL-681: metadata-only list for 150+ scale browsing — no
|
||||
# workflow_json, no per-row re-parse. `needs_mapping` here is the
|
||||
# cheap pending check (usage/output_map not yet settled); rows
|
||||
# that never generated keep showing as pending exactly like the
|
||||
# full view reports them.
|
||||
rows = conn.execute(
|
||||
f"SELECT {_META_COLUMNS} FROM workflows ORDER BY favorite DESC,"
|
||||
" last_used_at DESC, id DESC"
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
data = row_to_dict(row)
|
||||
data["is_enabled"] = bool(data.get("is_enabled"))
|
||||
data["favorite"] = bool(data.get("favorite"))
|
||||
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
|
||||
data["tags"] = _load_json_col(data.get("tags"), [])
|
||||
data["needs_mapping"] = data.get("usage") is None or data.get(
|
||||
"output_map_source"
|
||||
) is None
|
||||
out.append(data)
|
||||
return out
|
||||
rows = conn.execute("SELECT * FROM workflows ORDER BY id DESC").fetchall()
|
||||
out = [_serialize_workflow(r) for r in rows]
|
||||
# 上次测试 state is read live from the newest test job per workflow —
|
||||
@@ -395,6 +443,12 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "is_enabled" in data and data["is_enabled"] is not None:
|
||||
data["is_enabled"] = 1 if data["is_enabled"] else 0
|
||||
# HEL-681: library metadata patch fields (favorite/tags/category/
|
||||
# model_family) — booleans to ints, lists to JSON text.
|
||||
if "favorite" in data and data["favorite"] is not None:
|
||||
data["favorite"] = 1 if data["favorite"] else 0
|
||||
if data.get("tags") is not None:
|
||||
data["tags"] = json.dumps(data["tags"])
|
||||
workflow_json = json.loads(existing["workflow_json"])
|
||||
# HEL-638: output_map payload. A non-empty list validates against the
|
||||
# stored JSON and becomes the user decision; an explicit empty list
|
||||
@@ -600,3 +654,280 @@ async def delete_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HEL-681 (S2): UI→API conversion dry-run + batch import + repo scan.
|
||||
# The convert endpoint never writes; import-batch converts again server-side
|
||||
# and skips anything uncertain (needs_confirmation / lint-blocked / conflict)
|
||||
# — repeated runs import nothing new, so the batch is idempotent by design.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _convert_one(item: WorkflowUIFileItem) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
"""(report, converted workflow) for one UI file — shared by every
|
||||
convert/import path so the analysis can never drift between them."""
|
||||
result = convert_ui_workflow(item.workflow_json)
|
||||
stem = (
|
||||
item.source_path.replace("\\", "/").rsplit("/", 1)[-1].removesuffix(".json")
|
||||
if item.source_path
|
||||
else None
|
||||
)
|
||||
name = item.name or stem or "未命名工作流"
|
||||
report: dict[str, Any] = {
|
||||
"name": name,
|
||||
"source_path": item.source_path,
|
||||
"status": result.status,
|
||||
"needs_confirmation": result.needs_confirmation,
|
||||
"issues": [
|
||||
{"node_id": i.node_id, "code": i.code, "level": i.level, "message": i.message}
|
||||
for i in result.issues
|
||||
],
|
||||
}
|
||||
if result.workflow is None:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = "转换失败:" + (
|
||||
result.issues[0].message if result.issues else "未知错误"
|
||||
)
|
||||
return report, None
|
||||
suggested = suggested_metadata(result.workflow, item.source_path, item.workflow_json)
|
||||
report["suggested"] = suggested
|
||||
# The import gate re-runs the product's own checks — a converted graph
|
||||
# that the lint would block (missing model link etc.) is reported, not
|
||||
# silently imported.
|
||||
broken = blocking_problem(result.workflow)
|
||||
if broken:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = broken
|
||||
report["status"] = "warn"
|
||||
return report, result.workflow
|
||||
if result.needs_confirmation:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = "存在无法可靠映射的控件,需人工确认后重导"
|
||||
return report, result.workflow
|
||||
report["importable"] = True
|
||||
return report, result.workflow
|
||||
|
||||
|
||||
@router.post("/convert")
|
||||
async def convert_workflows(payload: WorkflowConvertRequest) -> dict[str, Any]:
|
||||
"""Dry-run UI→API conversion for a batch of files: per-file ok/warn/fail,
|
||||
issues, suggested metadata and importability. Nothing is stored."""
|
||||
files = []
|
||||
object_info: dict[str, Any] | None = None
|
||||
client = None
|
||||
if payload.endpoint_id:
|
||||
endpoint = settings.resolve_comfyui_endpoint(payload.endpoint_id)
|
||||
if endpoint is not None:
|
||||
client = client_for(endpoint)
|
||||
try:
|
||||
object_info = await client.object_info(force_refresh=payload.refresh)
|
||||
except Exception:
|
||||
object_info = None
|
||||
try:
|
||||
for item in payload.files:
|
||||
report, converted = _convert_one(item)
|
||||
if object_info is not None and report.get("importable") and converted:
|
||||
findings = check_workflow_against(object_info, converted)
|
||||
errors = [f for f in findings if f.get("level") == "error"]
|
||||
report["preflight"] = {
|
||||
"endpoint_id": payload.endpoint_id,
|
||||
"ok": not errors,
|
||||
"findings": findings,
|
||||
}
|
||||
if errors:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = errors[0].get("message") or "preflight 未通过"
|
||||
if payload.include_workflows:
|
||||
report["workflow_json"] = converted
|
||||
files.append(report)
|
||||
finally:
|
||||
if client is not None:
|
||||
await client.close()
|
||||
counts = {key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")}
|
||||
return {
|
||||
"total": len(files),
|
||||
**counts,
|
||||
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
|
||||
"importable": sum(1 for f in files if f.get("importable")),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def _insert_converted(
|
||||
conn: Any, item: WorkflowUIFileItem, report: dict[str, Any], converted: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Insert one converted workflow row (import-batch's write path).
|
||||
|
||||
Mirrors create_workflow's enable gate: usage must be settled (auto or the
|
||||
wizard's confirmation) and unknown outputs need a mapping; anything less
|
||||
lands disabled exactly like a manual import would.
|
||||
"""
|
||||
suggested = report["suggested"]
|
||||
judgment = classify_usage(converted)
|
||||
usage = item.usage or judgment["usage"]
|
||||
usage_source = "user" if item.usage else "auto"
|
||||
kind = usage_to_kind(usage) or suggested["kind"]
|
||||
profile = detect_prompt_profile(converted)
|
||||
if item.output_map:
|
||||
problems = validate_output_map(converted, item.output_map)
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
output_map = json.dumps(item.output_map)
|
||||
output_map_source = "user"
|
||||
else:
|
||||
auto_map = auto_output_map(converted)
|
||||
output_map = json.dumps(auto_map) if auto_map else None
|
||||
output_map_source = "auto" if auto_map else None
|
||||
mapping_ok = not needs_user_output_map(converted) or bool(item.output_map)
|
||||
workflow_text = json.dumps(converted)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM workflows WHERE name = ? AND kind = ? AND workflow_json = ?",
|
||||
(report["name"], kind, workflow_text),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return {
|
||||
"result": "skipped",
|
||||
"skip_reason": "conflict",
|
||||
"existing_id": existing["id"],
|
||||
}
|
||||
is_enabled = 1 if (usage and mapping_ok) else 0
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema,
|
||||
description, prompt_profile, endpoint_id, is_enabled,
|
||||
usage, usage_source, output_map, output_map_source,
|
||||
category, model_family, media_io, tags, favorite,
|
||||
source, source_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'ui_convert', ?)
|
||||
""",
|
||||
(
|
||||
report["name"],
|
||||
kind,
|
||||
workflow_text,
|
||||
json.dumps(
|
||||
item.input_schema if item.input_schema else parse_dynamic_inputs(converted)
|
||||
),
|
||||
json.dumps(parse_dynamic_outputs(converted)),
|
||||
item.description if item.description is not None else suggested["description"],
|
||||
profile,
|
||||
is_enabled,
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
suggested["category"],
|
||||
suggested["model_family"],
|
||||
json.dumps({"in": suggested["media_in"], "out": suggested["media_out"]}),
|
||||
json.dumps(item.tags or []),
|
||||
item.source_path,
|
||||
),
|
||||
)
|
||||
return {"result": "imported", "id": cur.lastrowid}
|
||||
|
||||
|
||||
async def _run_import(items: list[WorkflowUIFileItem]) -> dict[str, Any]:
|
||||
reports: list[dict[str, Any]] = []
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
for item in items:
|
||||
report, converted = _convert_one(item)
|
||||
if not report.get("importable"):
|
||||
report["result"] = "skipped"
|
||||
reports.append(report)
|
||||
continue
|
||||
try:
|
||||
outcome = _insert_converted(conn, item, report, converted)
|
||||
except HTTPException as exc:
|
||||
report["result"] = "failed"
|
||||
report["skip_reason"] = str(exc.detail)
|
||||
reports.append(report)
|
||||
continue
|
||||
conn.commit()
|
||||
report.update(outcome)
|
||||
reports.append(report)
|
||||
finally:
|
||||
conn.close()
|
||||
imported = [r for r in reports if r.get("result") == "imported"]
|
||||
skipped = [
|
||||
{"name": r["name"], "source_path": r["source_path"], "reason": r.get("skip_reason")}
|
||||
for r in reports
|
||||
if r.get("result") != "imported"
|
||||
]
|
||||
return {
|
||||
"total": len(reports),
|
||||
"imported": len(imported),
|
||||
"skipped": len(skipped),
|
||||
"imported_ids": [r["id"] for r in imported],
|
||||
"skipped_files": skipped,
|
||||
"files": reports,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/import-batch")
|
||||
async def import_workflows_batch(payload: WorkflowImportBatchRequest) -> dict[str, Any]:
|
||||
"""Batch-import UI-format workflows: convert server-side, insert what is
|
||||
cleanly convertible, skip conflicts and anything uncertain. Re-running
|
||||
with the same files creates zero new rows."""
|
||||
return await _run_import(payload.files)
|
||||
|
||||
|
||||
@router.post("/import-scan")
|
||||
async def import_workflows_scan(payload: WorkflowImportScanRequest) -> dict[str, Any]:
|
||||
"""Scan a server-side directory for UI-format workflow JSONs and run the
|
||||
convert (dry_run, default) or import pipeline over every file found."""
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(payload.root)
|
||||
if not root.is_absolute():
|
||||
candidates = [
|
||||
Path.cwd() / root,
|
||||
Path(__file__).resolve().parents[4] / root,
|
||||
]
|
||||
root = next((c for c in candidates if c.is_dir()), root)
|
||||
if not root.is_dir():
|
||||
raise HTTPException(status_code=404, detail=f"目录不存在:{payload.root}")
|
||||
items: list[WorkflowUIFileItem] = []
|
||||
for path in sorted(root.rglob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
|
||||
continue # API-format or non-workflow files: not this pipeline's job
|
||||
items.append(
|
||||
WorkflowUIFileItem(
|
||||
source_path=str(path.relative_to(root)), workflow_json=data
|
||||
)
|
||||
)
|
||||
if payload.dry_run:
|
||||
files = [_convert_one(item)[0] for item in items]
|
||||
counts = {
|
||||
key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")
|
||||
}
|
||||
return {
|
||||
"total": len(files),
|
||||
**counts,
|
||||
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
|
||||
"importable": sum(1 for f in files if f.get("importable")),
|
||||
"files": files,
|
||||
}
|
||||
return await _run_import(items)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/touch")
|
||||
async def touch_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
"""Record a use (最近使用) — called by the generate pipeline on enqueue."""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
conn.execute(
|
||||
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(workflow_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"""HEL-681: UI→API converter unit tests + ground-truth cross-checks.
|
||||
|
||||
The synthetic graphs pin the structural semantics (reroute / bypass / mute /
|
||||
subgraph expansion / Set-Get variables / control_after_generate pairing);
|
||||
the corpus section runs the real 146-file library and the repo's API exports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.lint import workflow_warnings
|
||||
from calliope.comfyui.ui_convert import convert_ui_workflow
|
||||
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
|
||||
def _node(nid, ntype, *, mode=0, inputs=None, outputs=None, wv=None, wvn=None, title=None):
|
||||
node = {"id": nid, "type": ntype, "mode": mode, "inputs": inputs or [], "order": 0}
|
||||
if outputs is not None:
|
||||
node["outputs"] = outputs
|
||||
if wv is not None:
|
||||
node["widgets_values"] = wv
|
||||
if wvn is not None:
|
||||
node["widgets_values_named"] = wvn
|
||||
if title:
|
||||
node["title"] = title
|
||||
return node
|
||||
|
||||
|
||||
def _in_slot(name, type_, link=None, widget=None):
|
||||
entry = {"name": name, "type": type_, "link": link}
|
||||
if widget:
|
||||
entry["widget"] = {"name": widget}
|
||||
return entry
|
||||
|
||||
|
||||
def _out(name, type_, links=None):
|
||||
return {"name": name, "type": type_, "links": links or [], "slot_index": 0}
|
||||
|
||||
|
||||
def _base_graph():
|
||||
"""text2image: loader → prompts/latent → KSampler → VAE → SaveImage."""
|
||||
nodes = [
|
||||
_node(
|
||||
1,
|
||||
"CheckpointLoaderSimple",
|
||||
outputs=[_out("MODEL", "MODEL"), _out("CLIP", "CLIP"), _out("VAE", "VAE")],
|
||||
wv=["model.safetensors"],
|
||||
inputs=[_in_slot("ckpt_name", "COMBO", widget="ckpt_name")],
|
||||
),
|
||||
_node(
|
||||
2,
|
||||
"CLIPTextEncode",
|
||||
inputs=[
|
||||
_in_slot("clip", "CLIP", link=1),
|
||||
_in_slot("text", "STRING", widget="text"),
|
||||
],
|
||||
outputs=[_out("CONDITIONING", "CONDITIONING")],
|
||||
wv=["a cat"],
|
||||
),
|
||||
_node(
|
||||
3,
|
||||
"CLIPTextEncode",
|
||||
inputs=[
|
||||
_in_slot("clip", "CLIP", link=2),
|
||||
_in_slot("text", "STRING", widget="text"),
|
||||
],
|
||||
outputs=[_out("CONDITIONING", "CONDITIONING")],
|
||||
wv=["bad quality"],
|
||||
),
|
||||
_node(
|
||||
4,
|
||||
"EmptyLatentImage",
|
||||
inputs=[
|
||||
_in_slot("width", "INT", widget="width"),
|
||||
_in_slot("height", "INT", widget="height"),
|
||||
_in_slot("batch_size", "INT", widget="batch_size"),
|
||||
],
|
||||
outputs=[_out("LATENT", "LATENT")],
|
||||
wv=[512, 512, 1],
|
||||
),
|
||||
_node(
|
||||
5,
|
||||
"KSampler",
|
||||
inputs=[
|
||||
_in_slot("model", "MODEL", link=3),
|
||||
_in_slot("positive", "CONDITIONING", link=4),
|
||||
_in_slot("negative", "CONDITIONING", link=5),
|
||||
_in_slot("latent_image", "LATENT", link=6),
|
||||
_in_slot("seed", "INT", widget="seed"),
|
||||
_in_slot("steps", "INT", widget="steps"),
|
||||
_in_slot("cfg", "FLOAT", widget="cfg"),
|
||||
_in_slot("sampler_name", "COMBO", widget="sampler_name"),
|
||||
_in_slot("scheduler", "COMBO", widget="scheduler"),
|
||||
_in_slot("denoise", "FLOAT", widget="denoise"),
|
||||
],
|
||||
outputs=[_out("LATENT", "LATENT")],
|
||||
# seed 42 + control "fixed" rides together, then five widgets
|
||||
wv=[42, "fixed", 20, 7, "euler", "normal", 1.0],
|
||||
),
|
||||
_node(
|
||||
6,
|
||||
"VAEDecode",
|
||||
inputs=[
|
||||
_in_slot("samples", "LATENT", link=7),
|
||||
_in_slot("vae", "VAE", link=8),
|
||||
],
|
||||
outputs=[_out("IMAGE", "IMAGE")],
|
||||
),
|
||||
_node(
|
||||
7,
|
||||
"SaveImage",
|
||||
inputs=[
|
||||
_in_slot("images", "IMAGE", link=9),
|
||||
_in_slot("filename_prefix", "STRING", widget="filename_prefix"),
|
||||
],
|
||||
wv=["test"],
|
||||
),
|
||||
]
|
||||
links = [
|
||||
[1, 1, 1, 2, 0, "CLIP"],
|
||||
[2, 1, 1, 3, 0, "CLIP"],
|
||||
[3, 1, 0, 5, 0, "MODEL"],
|
||||
[4, 2, 0, 5, 1, "CONDITIONING"],
|
||||
[5, 3, 0, 5, 2, "CONDITIONING"],
|
||||
[6, 4, 0, 5, 3, "LATENT"],
|
||||
[7, 5, 0, 6, 0, "LATENT"],
|
||||
[8, 1, 2, 6, 1, "VAE"],
|
||||
[9, 6, 0, 7, 0, "IMAGE"],
|
||||
]
|
||||
return {"nodes": nodes, "links": links, "last_node_id": 7, "last_link_id": 9}
|
||||
|
||||
|
||||
def _convert(graph):
|
||||
return convert_ui_workflow(graph)
|
||||
|
||||
|
||||
def test_clean_conversion_maps_everything():
|
||||
result = _convert(_base_graph())
|
||||
assert result.status == "ok"
|
||||
assert result.needs_confirmation is False
|
||||
wf = result.workflow
|
||||
assert set(wf) == {"1", "2", "3", "4", "5", "6", "7"}
|
||||
ks = wf["5"]
|
||||
assert ks["class_type"] == "KSampler"
|
||||
assert ks["inputs"]["seed"] == 42 # control_after_generate consumed silently
|
||||
assert ks["inputs"]["steps"] == 20
|
||||
assert ks["inputs"]["denoise"] == 1.0
|
||||
assert ks["inputs"]["model"] == ["1", 0]
|
||||
assert ks["inputs"]["latent_image"] == ["4", 0]
|
||||
# lint on the converted graph must be error-free
|
||||
assert not [w for w in workflow_warnings(wf) if w["level"] == "error"]
|
||||
|
||||
|
||||
def test_upload_button_widgets_are_consumed_but_never_emitted():
|
||||
graph = _base_graph()
|
||||
load = _node(
|
||||
8,
|
||||
"LoadImage",
|
||||
inputs=[
|
||||
_in_slot("image", "COMBO", widget="image"),
|
||||
_in_slot("upload", "IMAGEUPLOAD", widget="upload"),
|
||||
],
|
||||
outputs=[_out("IMAGE", "IMAGE")],
|
||||
wv=["sample.png", "image"],
|
||||
)
|
||||
save = _node(
|
||||
9,
|
||||
"PreviewImage",
|
||||
inputs=[
|
||||
_in_slot("images", "IMAGE", link=20),
|
||||
_in_slot("filename_prefix", "STRING", widget="filename_prefix"),
|
||||
],
|
||||
wv=["preview"],
|
||||
)
|
||||
graph["nodes"] += [load, save]
|
||||
graph["links"].append([20, 8, 0, 9, 0, "IMAGE"])
|
||||
result = _convert(graph)
|
||||
assert result.workflow["8"]["inputs"] == {"image": "sample.png"}
|
||||
|
||||
|
||||
def test_reroute_pass_through():
|
||||
graph = _base_graph()
|
||||
reroute = _node(8, "Reroute", inputs=[_in_slot("(in)", "*", link=9)])
|
||||
graph["nodes"].append(reroute)
|
||||
# SaveImage now fed by the reroute instead of VAEDecode directly
|
||||
graph["nodes"][6]["inputs"][0]["link"] = 10
|
||||
graph["links"].append([10, 8, 0, 7, 0, "IMAGE"])
|
||||
result = _convert(graph)
|
||||
assert result.workflow["7"]["inputs"]["images"] == ["6", 0]
|
||||
assert "8" not in result.workflow
|
||||
|
||||
|
||||
def test_bypass_pass_through_and_mute_drop():
|
||||
graph = _base_graph()
|
||||
# bypass the KSampler: VAEDecode.samples must fall back to the latent
|
||||
graph["nodes"][4]["mode"] = 4
|
||||
result = _convert(graph)
|
||||
assert result.workflow["6"]["inputs"]["samples"] == ["4", 0]
|
||||
# mute the negative prompt: the link input is dropped with a warning
|
||||
graph = _base_graph()
|
||||
graph["nodes"][2]["mode"] = 2
|
||||
result = _convert(graph)
|
||||
assert "negative" not in result.workflow["5"]["inputs"]
|
||||
assert any(i.code == "muted_input_dropped" for i in result.issues)
|
||||
|
||||
|
||||
def test_widget_entry_with_muted_upstream_falls_back_to_value():
|
||||
graph = _base_graph()
|
||||
# feed seed from a muted PrimitiveInt via a converted widget input
|
||||
prim = _node(
|
||||
9,
|
||||
"PrimitiveInt",
|
||||
mode=2,
|
||||
inputs=[_in_slot("value", "INT", widget="value"), _in_slot("control", "COMBO", widget="control")],
|
||||
wv=[7],
|
||||
)
|
||||
graph["nodes"].append(prim)
|
||||
graph["nodes"][4]["inputs"][4]["link"] = 11 # seed ← primitive
|
||||
graph["links"].append([11, 9, 0, 5, 4, "INT"])
|
||||
result = _convert(graph)
|
||||
assert result.workflow["5"]["inputs"]["seed"] == 42
|
||||
|
||||
|
||||
def test_subgraph_expansion_and_boundary_wiring():
|
||||
graph = _base_graph()
|
||||
# pack the EmptyLatentImage into a subgraph exposing width/height widgets
|
||||
del graph["nodes"][3]
|
||||
graph["nodes"] = [n for n in graph["nodes"] if n["id"] != 4]
|
||||
sub_id = "aaaa-bbbb-cccc-dddd-eeee"
|
||||
packed = _node(
|
||||
20,
|
||||
sub_id,
|
||||
inputs=[
|
||||
_in_slot("width", "INT", widget="width"),
|
||||
_in_slot("height", "INT", widget="height"),
|
||||
],
|
||||
outputs=[_out("LATENT", "LATENT")],
|
||||
wv=[640, 384],
|
||||
)
|
||||
graph["nodes"].append(packed)
|
||||
# KSampler latent_image ← packed output
|
||||
graph["links"] = [l for l in graph["links"] if l[0] != 6]
|
||||
graph["links"].append([6, 20, 0, 5, 3, "LATENT"])
|
||||
graph["definitions"] = {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": sub_id,
|
||||
"name": "Latent",
|
||||
"inputs": [
|
||||
{"name": "width", "type": "INT"},
|
||||
{"name": "height", "type": "INT"},
|
||||
],
|
||||
"outputs": [{"name": "LATENT", "type": "LATENT"}],
|
||||
"inputNode": {"id": -10},
|
||||
"outputNode": {"id": -20},
|
||||
"nodes": [
|
||||
_node(
|
||||
5,
|
||||
"EmptyLatentImage",
|
||||
inputs=[
|
||||
_in_slot("width", "INT", widget="width", link=101),
|
||||
_in_slot("height", "INT", widget="height", link=102),
|
||||
_in_slot("batch_size", "INT", widget="batch_size"),
|
||||
],
|
||||
outputs=[_out("LATENT", "LATENT")],
|
||||
wv=[512, 512, 1],
|
||||
),
|
||||
],
|
||||
"links": [
|
||||
{"id": 101, "origin_id": -10, "origin_slot": 0, "target_id": 5, "target_slot": 0, "type": "INT"},
|
||||
{"id": 102, "origin_id": -10, "origin_slot": 1, "target_id": 5, "target_slot": 1, "type": "INT"},
|
||||
{"id": 103, "origin_id": 5, "origin_slot": 0, "target_id": -20, "target_slot": 0, "type": "LATENT"},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
result = _convert(graph)
|
||||
wf = result.workflow
|
||||
assert "20:5" in wf # qualified packed:inner id, ComfyUI's own scheme
|
||||
inner = wf["20:5"]
|
||||
assert inner["class_type"] == "EmptyLatentImage"
|
||||
# exposed widget values from the packed instance, not the inner defaults
|
||||
assert inner["inputs"]["width"] == 640
|
||||
assert inner["inputs"]["height"] == 384
|
||||
assert inner["inputs"]["batch_size"] == 1
|
||||
assert wf["5"]["inputs"]["latent_image"] == ["20:5", 0]
|
||||
# now wire the packed width slot from outside: boundary becomes a link
|
||||
res_node = _node(
|
||||
21,
|
||||
"ResolutionSelector",
|
||||
inputs=[_in_slot("aspect_ratio", "COMBO", widget="aspect_ratio")],
|
||||
outputs=[_out("width", "INT"), _out("height", "INT")],
|
||||
wv=["1:1"],
|
||||
)
|
||||
graph["nodes"].append(res_node)
|
||||
graph["nodes"][-2]["inputs"][0]["link"] = 12 # packed width ← selector
|
||||
graph["links"].append([12, 21, 0, 20, 0, "INT"])
|
||||
wf2 = _convert(graph).workflow
|
||||
assert wf2["20:5"]["inputs"]["width"] == ["21", 0]
|
||||
assert wf2["20:5"]["inputs"]["height"] == 384
|
||||
|
||||
|
||||
def test_set_get_variables_resolve_to_captured_source():
|
||||
graph = _base_graph()
|
||||
setter = _node(8, "SetNode", inputs=[_in_slot("*", "*", link=8)], wv=["the_vae"])
|
||||
getter = _node(9, "GetNode", wv=["the_vae"], outputs=[_out("VAE", "VAE")])
|
||||
graph["nodes"] += [setter, getter]
|
||||
# VAEDecode.vae now fed via the variable
|
||||
graph["nodes"][5]["inputs"][1]["link"] = 13
|
||||
graph["links"] = [l for l in graph["links"] if l[0] != 8]
|
||||
graph["links"] += [
|
||||
[8, 1, 2, 8, 0, "VAE"],
|
||||
[13, 9, 0, 6, 1, "VAE"],
|
||||
]
|
||||
result = _convert(graph)
|
||||
assert "8" not in result.workflow and "9" not in result.workflow
|
||||
assert result.workflow["6"]["inputs"]["vae"] == ["1", 2]
|
||||
|
||||
|
||||
def test_unknown_widget_order_lands_in_needs_confirmation():
|
||||
graph = _base_graph()
|
||||
# an old-frontend node: positional widgets, no widget markers, no registry
|
||||
graph["nodes"].append(
|
||||
_node(10, "MysteryCustomNode", inputs=[], outputs=[_out("IMAGE", "IMAGE")], wv=[1, 2, 3])
|
||||
)
|
||||
save = _node(
|
||||
11,
|
||||
"PreviewImage",
|
||||
inputs=[_in_slot("images", "IMAGE", link=30)],
|
||||
wv=["preview"],
|
||||
)
|
||||
graph["nodes"].append(save)
|
||||
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
|
||||
result = _convert(graph)
|
||||
assert result.needs_confirmation is True
|
||||
assert any(i.code == "widget_unmapped" for i in result.issues)
|
||||
# the node is still converted structurally — the report carries the doubt
|
||||
assert "10" in result.workflow
|
||||
|
||||
|
||||
def test_not_ui_format_fails_fast():
|
||||
result = convert_ui_workflow({"3": {"class_type": "SaveImage", "inputs": {}}})
|
||||
assert result.status == "fail"
|
||||
assert result.workflow is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real corpus: the 146 UI files + ground-truth API cross-checks (对拍).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
pytestmark_convert = pytest.mark.skipif(
|
||||
not EXAMPLES.is_dir(), reason="example_ComfyUI_workflows not checked out"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
|
||||
def test_corpus_146_files_all_convert():
|
||||
ui_files = [
|
||||
p
|
||||
for p in sorted(EXAMPLES.rglob("*.json"))
|
||||
if isinstance(json.loads(p.read_text(encoding="utf-8")), dict)
|
||||
and isinstance(json.loads(p.read_text(encoding="utf-8")).get("nodes"), list)
|
||||
]
|
||||
assert len(ui_files) == 146
|
||||
statuses = {"ok": 0, "warn": 0, "fail": 0}
|
||||
needs_confirmation = 0
|
||||
for path in ui_files:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
result = convert_ui_workflow(data)
|
||||
statuses[result.status] += 1
|
||||
needs_confirmation += result.needs_confirmation
|
||||
assert result.workflow, f"{path.name} produced no workflow"
|
||||
# structural sanity: no dangling links in any conversion
|
||||
nodes = result.workflow
|
||||
for nid, node in nodes.items():
|
||||
for name, val in node.get("inputs", {}).items():
|
||||
if isinstance(val, list):
|
||||
assert str(val[0]) in nodes, f"{path.name}: {nid}.{name} dangles"
|
||||
assert statuses["fail"] == 0
|
||||
assert statuses["ok"] + statuses["warn"] == 146
|
||||
# files whose widget order cannot be trusted are flagged, never hidden
|
||||
assert 0 < needs_confirmation < 10
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
|
||||
def test_cross_check_against_ground_truth_exports():
|
||||
"""The H3 t2v UI export converts to the same node classes the author's
|
||||
own API export carries; shared classes agree on scalar input names."""
|
||||
ui = json.loads(
|
||||
(EXAMPLES / "4-视频生成/Minimax-h3/minimax_h3_t2v_文生视频.json").read_text(encoding="utf-8")
|
||||
)
|
||||
api = json.loads(
|
||||
(EXAMPLES / "minimax_h3-Turbo_Text2Video_20260813_API.json").read_text(encoding="utf-8")
|
||||
)
|
||||
result = convert_ui_workflow(ui)
|
||||
assert result.status in ("ok", "warn")
|
||||
converted = result.workflow
|
||||
api_classes = {n["class_type"] for n in api.values()}
|
||||
# Every scalar name the conversion emits for a shared class must be an
|
||||
# input name the author's own export knows — wiring may differ between
|
||||
# versions (a muted branch falls back to its widget value), but the NAME
|
||||
# mapping must never invent or swap inputs.
|
||||
for cls in api_classes & {n["class_type"] for n in converted.values()}:
|
||||
api_names = {
|
||||
key
|
||||
for n in api.values()
|
||||
if n["class_type"] == cls
|
||||
for key in n.get("inputs", {})
|
||||
}
|
||||
conv_names = {
|
||||
key
|
||||
for n in converted.values()
|
||||
if n["class_type"] == cls
|
||||
for key, v in n.get("inputs", {}).items()
|
||||
if not isinstance(v, list)
|
||||
}
|
||||
assert conv_names <= api_names, f"class {cls} invents inputs: {conv_names - api_names}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
|
||||
def test_packed_krea2_conversion_shape_matches_export_scheme():
|
||||
"""The krea2 turbo file (packed subgraph) converts ok, expands inner nodes
|
||||
under `<packed>:<inner>` ids and maps the sampler widgets exactly — the
|
||||
same scheme the author's own Krea2 API export uses (ids like "171:153")."""
|
||||
ui = json.loads(
|
||||
(EXAMPLES / "3-图像生成/krea2/krea2_turbo_文生图.json").read_text(encoding="utf-8")
|
||||
)
|
||||
api = json.loads(
|
||||
(EXAMPLES / "Krea2_t2i_20260818_API.json").read_text(encoding="utf-8")
|
||||
)
|
||||
result = convert_ui_workflow(ui)
|
||||
assert result.status == "ok"
|
||||
assert any(":" in nid for nid in result.workflow) # expanded subgraph nodes
|
||||
assert any("171:" in nid for nid in api) # ground truth uses the same scheme
|
||||
ks = [
|
||||
n
|
||||
for n in result.workflow.values()
|
||||
if n["class_type"] == "KSampler" and not isinstance(n["inputs"].get("seed"), list)
|
||||
]
|
||||
assert ks, "inner KSampler with widget-mapped seed expected"
|
||||
assert {"seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"} <= set(
|
||||
ks[0]["inputs"]
|
||||
)
|
||||
@@ -0,0 +1,421 @@
|
||||
"""HEL-681 (S2): batch import endpoints, meta list view, migration safety.
|
||||
|
||||
Everything runs on a temp DB via the shared client fixture — the real
|
||||
146-file library is never touched, matching the card's「不导入生产数据」boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.db import migrate_db
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
|
||||
def _ui_graph():
|
||||
"""Minimal clean t2i UI graph (same shape as test_ui_convert's)."""
|
||||
return {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "ckpt_name", "type": "COMBO", "widget": {"name": "ckpt_name"}, "link": None}],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [3], "slot_index": 0},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [1, 2], "slot_index": 1},
|
||||
{"name": "VAE", "type": "VAE", "links": [8], "slot_index": 2},
|
||||
],
|
||||
"widgets_values": ["qwen_image_2.safetensors"],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 1},
|
||||
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None},
|
||||
],
|
||||
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [4], "slot_index": 0}],
|
||||
"widgets_values": ["a cat"],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 2},
|
||||
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None},
|
||||
],
|
||||
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5], "slot_index": 0}],
|
||||
"widgets_values": ["bad"],
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "EmptyLatentImage",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "width", "type": "INT", "widget": {"name": "width"}, "link": None},
|
||||
{"name": "height", "type": "INT", "widget": {"name": "height"}, "link": None},
|
||||
{"name": "batch_size", "type": "INT", "widget": {"name": "batch_size"}, "link": None},
|
||||
],
|
||||
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [6], "slot_index": 0}],
|
||||
"widgets_values": [512, 512, 1],
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "KSampler",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 3},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 4},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 5},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 6},
|
||||
{"name": "seed", "type": "INT", "widget": {"name": "seed"}, "link": None},
|
||||
{"name": "steps", "type": "INT", "widget": {"name": "steps"}, "link": None},
|
||||
{"name": "cfg", "type": "FLOAT", "widget": {"name": "cfg"}, "link": None},
|
||||
{"name": "sampler_name", "type": "COMBO", "widget": {"name": "sampler_name"}, "link": None},
|
||||
{"name": "scheduler", "type": "COMBO", "widget": {"name": "scheduler"}, "link": None},
|
||||
{"name": "denoise", "type": "FLOAT", "widget": {"name": "denoise"}, "link": None},
|
||||
],
|
||||
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0}],
|
||||
"widgets_values": [42, "fixed", 20, 7, "euler", "normal", 1.0],
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "VAEDecode",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 7},
|
||||
{"name": "vae", "type": "VAE", "link": 8},
|
||||
],
|
||||
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0}],
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SaveImage",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 9},
|
||||
{"name": "filename_prefix", "type": "STRING", "widget": {"name": "filename_prefix"}, "link": None},
|
||||
],
|
||||
"widgets_values": ["test"],
|
||||
},
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 1, 2, 0, "CLIP"],
|
||||
[2, 1, 1, 3, 0, "CLIP"],
|
||||
[3, 1, 0, 5, 0, "MODEL"],
|
||||
[4, 2, 0, 5, 1, "CONDITIONING"],
|
||||
[5, 3, 0, 5, 2, "CONDITIONING"],
|
||||
[6, 4, 0, 5, 3, "LATENT"],
|
||||
[7, 5, 0, 6, 0, "LATENT"],
|
||||
[8, 1, 2, 6, 1, "VAE"],
|
||||
[9, 6, 0, 7, 0, "IMAGE"],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _api_workflow():
|
||||
return json.loads(
|
||||
(EXAMPLES / "video_minimax_h3_r2v_2ref_API.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def test_convert_dry_run_reports_and_stores_nothing(client):
|
||||
payload = {
|
||||
"files": [
|
||||
{
|
||||
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
|
||||
"workflow_json": _ui_graph(),
|
||||
}
|
||||
],
|
||||
"include_workflows": True,
|
||||
}
|
||||
resp = client.post("/api/workflows/convert", json=payload)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 1 and body["ok"] == 1 and body["importable"] == 1
|
||||
entry = body["files"][0]
|
||||
assert entry["name"] == "test_t2i"
|
||||
assert entry["workflow_json"]["5"]["inputs"]["seed"] == 42
|
||||
assert entry["suggested"]["usage"] == "text2image"
|
||||
assert entry["suggested"]["category"] == "image"
|
||||
assert entry["suggested"]["model_family"] == "Qwen"
|
||||
assert entry["suggested"]["media_out"] == ["image"]
|
||||
# dry-run must not write anything
|
||||
assert client.get("/api/workflows").json() == []
|
||||
|
||||
|
||||
def test_convert_needs_confirmation_is_flagged_not_runnable(client):
|
||||
graph = _ui_graph()
|
||||
graph["nodes"].append(
|
||||
{
|
||||
"id": 10,
|
||||
"type": "MysteryCustomNode",
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [30], "slot_index": 0}],
|
||||
"widgets_values": [1, 2, 3],
|
||||
}
|
||||
)
|
||||
graph["nodes"].append(
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PreviewImage",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "images", "type": "IMAGE", "link": 30}],
|
||||
"widgets_values": ["p"],
|
||||
}
|
||||
)
|
||||
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
|
||||
resp = client.post(
|
||||
"/api/workflows/convert",
|
||||
json={"files": [{"source_path": "x/mystery.json", "workflow_json": graph}]},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["needs_confirmation"] == 1
|
||||
assert body["importable"] == 0
|
||||
assert "需人工确认" in body["files"][0]["skip_reason"]
|
||||
|
||||
|
||||
def test_import_batch_then_rerun_is_idempotent(client):
|
||||
files = [
|
||||
{
|
||||
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
|
||||
"workflow_json": _ui_graph(),
|
||||
}
|
||||
]
|
||||
first = client.post("/api/workflows/import-batch", json={"files": files})
|
||||
assert first.status_code == 200
|
||||
body = first.json()
|
||||
assert body["imported"] == 1 and body["skipped"] == 0
|
||||
row_id = body["imported_ids"][0]
|
||||
|
||||
# second run with identical file: conflict skip, no duplicates
|
||||
second = client.post("/api/workflows/import-batch", json={"files": files})
|
||||
body2 = second.json()
|
||||
assert body2["imported"] == 0 and body2["skipped"] == 1
|
||||
assert body2["skipped_files"][0]["reason"] == "conflict"
|
||||
listing = client.get("/api/workflows").json()
|
||||
assert len(listing) == 1
|
||||
|
||||
detail = client.get(f"/api/workflows/{row_id}").json()
|
||||
assert detail["usage"] == "text2image"
|
||||
assert detail["is_enabled"] is True
|
||||
assert detail["category"] == "image"
|
||||
assert detail["model_family"] == "Qwen"
|
||||
assert detail["media_io"] == {"in": [], "out": ["image"]}
|
||||
assert detail["source"] == "ui_convert"
|
||||
assert detail["source_path"] == "3-图像生成/Qwen-Image-2.1/test_t2i.json"
|
||||
|
||||
|
||||
def test_import_batch_skips_needs_confirmation_files(client):
|
||||
graph = _ui_graph()
|
||||
graph["nodes"].append(
|
||||
{
|
||||
"id": 10,
|
||||
"type": "MysteryCustomNode",
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [30], "slot_index": 0}],
|
||||
"widgets_values": [1, 2, 3],
|
||||
}
|
||||
)
|
||||
graph["nodes"].append(
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PreviewImage",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "images", "type": "IMAGE", "link": 30}],
|
||||
"widgets_values": ["p"],
|
||||
}
|
||||
)
|
||||
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
|
||||
resp = client.post(
|
||||
"/api/workflows/import-batch",
|
||||
json={"files": [{"name": "mystery", "workflow_json": graph}]},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["imported"] == 0
|
||||
assert "需人工确认" in body["skipped_files"][0]["reason"]
|
||||
assert client.get("/api/workflows").json() == []
|
||||
|
||||
|
||||
def test_warn_sample_imports_with_human_confirmation(client):
|
||||
"""A warn file (muted branch → input dropped) still imports when the
|
||||
wizard supplies the settled usage — the manual-correction path."""
|
||||
graph = _ui_graph()
|
||||
graph["nodes"][2]["mode"] = 2 # mute the negative prompt branch
|
||||
dry = client.post(
|
||||
"/api/workflows/convert",
|
||||
json={"files": [{"name": "warned", "workflow_json": graph}]},
|
||||
).json()
|
||||
assert dry["warn"] == 1 and dry["importable"] == 1
|
||||
resp = client.post(
|
||||
"/api/workflows/import-batch",
|
||||
json={"files": [{"name": "warned", "workflow_json": graph, "usage": "text2image"}]},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["imported"] == 1
|
||||
detail = client.get(f"/api/workflows/{body['imported_ids'][0]}").json()
|
||||
assert detail["is_enabled"] is True
|
||||
assert "negative" not in detail["workflow_json"]["5"]["inputs"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
|
||||
def test_import_scan_dry_run_over_repo_library(client):
|
||||
resp = client.post(
|
||||
"/api/workflows/import-scan",
|
||||
json={"root": str(EXAMPLES), "dry_run": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 146
|
||||
assert body["fail"] == 0
|
||||
assert body["ok"] + body["warn"] == 146
|
||||
assert body["needs_confirmation"] > 0 # the 3 uncensored files stay honest
|
||||
assert body["importable"] < body["total"]
|
||||
# a dry scan must leave the DB untouched
|
||||
assert client.get("/api/workflows").json() == []
|
||||
|
||||
|
||||
def test_import_scan_commit_from_tmp_directory(client, tmp_path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "demo.json").write_text(json.dumps(_ui_graph()), encoding="utf-8")
|
||||
(tmp_path / "plain_api.json").write_text(json.dumps(_api_workflow()), encoding="utf-8")
|
||||
(tmp_path / "not_a_workflow.json").write_text(json.dumps({"hello": 1}), encoding="utf-8")
|
||||
resp = client.post(
|
||||
"/api/workflows/import-scan",
|
||||
json={"root": str(tmp_path), "dry_run": False},
|
||||
)
|
||||
body = resp.json()
|
||||
# only the one UI file went through this pipeline; API-format and junk skipped
|
||||
assert body["imported"] == 1
|
||||
listing = client.get("/api/workflows").json()
|
||||
assert len(listing) == 1
|
||||
assert listing[0]["source_path"] == "sub/demo.json"
|
||||
# rerun: idempotent
|
||||
again = client.post(
|
||||
"/api/workflows/import-scan",
|
||||
json={"root": str(tmp_path), "dry_run": False},
|
||||
).json()
|
||||
assert again["imported"] == 0
|
||||
assert len(client.get("/api/workflows").json()) == 1
|
||||
|
||||
|
||||
def test_meta_view_has_metadata_but_no_full_json(client):
|
||||
client.post(
|
||||
"/api/workflows/import-batch",
|
||||
json={
|
||||
"files": [
|
||||
{
|
||||
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
|
||||
"workflow_json": _ui_graph(),
|
||||
"tags": ["极速"],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
meta = client.get("/api/workflows", params={"view": "meta"}).json()
|
||||
assert len(meta) == 1
|
||||
row = meta[0]
|
||||
assert "workflow_json" not in row
|
||||
assert "input_schema" not in row
|
||||
assert "output_schema" not in row
|
||||
assert row["category"] == "image"
|
||||
assert row["model_family"] == "Qwen"
|
||||
assert row["media_io"] == {"in": [], "out": ["image"]}
|
||||
assert row["tags"] == ["极速"]
|
||||
assert row["favorite"] is False
|
||||
assert row["needs_mapping"] is False
|
||||
|
||||
|
||||
def test_legacy_list_shape_and_old_records_unchanged(client):
|
||||
"""Old imports (API format via POST /api/workflows) keep working; the
|
||||
legacy list still carries workflow_json and the new columns default."""
|
||||
created = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "旧导入", "workflow_json": _api_workflow()},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
legacy = client.get("/api/workflows").json()
|
||||
assert len(legacy) == 1
|
||||
row = legacy[0]
|
||||
assert isinstance(row["workflow_json"], dict)
|
||||
assert "usage" in row and "output_map" in row
|
||||
assert row["favorite"] is False
|
||||
assert row["tags"] == []
|
||||
assert row["media_io"] == {"in": [], "out": []}
|
||||
meta = client.get("/api/workflows", params={"view": "meta"}).json()
|
||||
assert meta[0]["needs_mapping"] is False or meta[0]["needs_mapping"] is True
|
||||
assert meta[0]["source"] is None
|
||||
|
||||
|
||||
def test_favorite_tags_patch_and_touch(client):
|
||||
wid = client.post(
|
||||
"/api/workflows/import-batch",
|
||||
json={"files": [{"name": "fav", "workflow_json": _ui_graph()}]},
|
||||
).json()["imported_ids"][0]
|
||||
resp = client.patch(f"/api/workflows/{wid}", json={"favorite": True, "tags": ["高质量"]})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["favorite"] is True
|
||||
assert resp.json()["tags"] == ["高质量"]
|
||||
touch = client.post(f"/api/workflows/{wid}/touch")
|
||||
assert touch.status_code == 200
|
||||
meta = client.get("/api/workflows", params={"view": "meta"}).json()
|
||||
assert meta[0]["last_used_at"] is not None
|
||||
assert meta[0]["favorite"] is True
|
||||
|
||||
|
||||
def test_migration_adds_columns_on_old_database(tmp_path):
|
||||
"""A pre-S2 database (workflows table without the metadata columns)
|
||||
migrates in place; existing rows survive with defaults."""
|
||||
db_path = tmp_path / "old.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE workflows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('image', 'video')),
|
||||
workflow_json TEXT NOT NULL,
|
||||
description TEXT,
|
||||
prompt_profile TEXT NOT NULL DEFAULT 'prose',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
usage TEXT,
|
||||
usage_source TEXT,
|
||||
output_map TEXT,
|
||||
output_map_source TEXT
|
||||
);
|
||||
INSERT INTO workflows (name, kind, workflow_json)
|
||||
VALUES ('legacy', 'video', '{"1": {"class_type": "SaveImage", "inputs": {}}}');
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(migrate_db(db_path))
|
||||
conn = sqlite3.connect(db_path)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(workflows)").fetchall()}
|
||||
assert {
|
||||
"category",
|
||||
"model_family",
|
||||
"media_io",
|
||||
"tags",
|
||||
"favorite",
|
||||
"last_used_at",
|
||||
"source",
|
||||
"source_path",
|
||||
} <= cols
|
||||
row = conn.execute("SELECT name, favorite, tags, category FROM workflows").fetchone()
|
||||
assert row == ("legacy", 0, None, None)
|
||||
conn.close()
|
||||
@@ -140,6 +140,11 @@ import type {
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
WorkflowMeta,
|
||||
WorkflowUIFileInput,
|
||||
WorkflowConvertFileReport,
|
||||
WorkflowConvertReport,
|
||||
WorkflowImportResult,
|
||||
} from "./comfy/types";
|
||||
export type {
|
||||
Job,
|
||||
@@ -154,6 +159,11 @@ export type {
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
WorkflowMeta,
|
||||
WorkflowUIFileInput,
|
||||
WorkflowConvertFileReport,
|
||||
WorkflowConvertReport,
|
||||
WorkflowImportResult,
|
||||
};
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -462,6 +472,50 @@ export const comfy = {
|
||||
|
||||
export const workflows = {
|
||||
list: () => api<Workflow[]>("/api/workflows"),
|
||||
/** HEL-682: metadata-only list — 150+ rows without any workflow_json. */
|
||||
listMeta: () => api<WorkflowMeta[]>("/api/workflows?view=meta"),
|
||||
/** HEL-682: dry-run UI→API conversion for a batch of files (nothing stored). */
|
||||
convert: (
|
||||
files: WorkflowUIFileInput[],
|
||||
opts?: { endpoint_id?: string | null; refresh?: boolean },
|
||||
) =>
|
||||
api<WorkflowConvertReport>("/api/workflows/convert", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
files,
|
||||
...(opts?.endpoint_id ? { endpoint_id: opts.endpoint_id } : {}),
|
||||
...(opts?.refresh ? { refresh: opts.refresh } : {}),
|
||||
}),
|
||||
}),
|
||||
/** HEL-682: commit a batch import (converts server-side, conflicts skipped). */
|
||||
importBatch: (files: WorkflowUIFileInput[]) =>
|
||||
api<WorkflowImportResult>("/api/workflows/import-batch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ files }),
|
||||
}),
|
||||
/** HEL-682: scan a server-side directory of UI-format JSONs — dry-run report. */
|
||||
importScanDry: (root: string) =>
|
||||
api<WorkflowConvertReport>("/api/workflows/import-scan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ root, dry_run: true }),
|
||||
}),
|
||||
/** HEL-682: scan a server-side directory of UI-format JSONs — commit the import. */
|
||||
importScanCommit: (root: string) =>
|
||||
api<WorkflowImportResult>("/api/workflows/import-scan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ root, dry_run: false }),
|
||||
}),
|
||||
/** HEL-682: record a use (最近使用). */
|
||||
touch: (id: number) =>
|
||||
api<{ ok: boolean }>(`/api/workflows/${id}/touch`, { method: "POST" }),
|
||||
/** HEL-682: delete byte-identical duplicates, keeping the lowest id. */
|
||||
dedupe: () =>
|
||||
api<{
|
||||
ok: boolean;
|
||||
removed: number;
|
||||
kept_ids: number[];
|
||||
removed_ids: number[];
|
||||
}>("/api/workflows/dedupe", { method: "POST" }),
|
||||
analyze: (
|
||||
workflow_json: Record<string, unknown>,
|
||||
endpoint_id?: string | null,
|
||||
@@ -537,6 +591,10 @@ export const workflows = {
|
||||
input_schema: ComfyDynamicInput[];
|
||||
output_map: WorkflowOutputMapEntry[];
|
||||
output_map_source: "auto" | "user";
|
||||
favorite: boolean;
|
||||
tags: string[];
|
||||
category: string;
|
||||
model_family: string;
|
||||
}>,
|
||||
) =>
|
||||
api<Workflow>(`/api/workflows/${id}`, {
|
||||
|
||||
@@ -175,6 +175,120 @@ export interface Workflow {
|
||||
last_test?: WorkflowLastTest | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HEL-682: metadata-only row (GET /api/workflows?view=meta) — the whole
|
||||
* point is browsing 150+ workflows without downloading workflow_json.
|
||||
*/
|
||||
export interface WorkflowMediaIO {
|
||||
in: string[];
|
||||
out: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowMeta {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: 'image' | 'video';
|
||||
usage: WorkflowUsage | null;
|
||||
usage_source: 'auto' | 'user' | null;
|
||||
is_enabled: boolean;
|
||||
category: string | null;
|
||||
model_family: string | null;
|
||||
media_io: WorkflowMediaIO;
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
last_used_at: string | null;
|
||||
description: string | null;
|
||||
source: string | null;
|
||||
source_path: string | null;
|
||||
prompt_profile: string;
|
||||
created_at: string;
|
||||
endpoint_id: string | null;
|
||||
output_map_source: 'auto' | 'user' | null;
|
||||
/** Cheap pending check: usage or output mapping not settled yet. */
|
||||
needs_mapping: boolean;
|
||||
}
|
||||
|
||||
/** One UI-format file submitted to the batch convert/import pipeline. */
|
||||
export interface WorkflowUIFileInput {
|
||||
name?: string;
|
||||
source_path?: string;
|
||||
workflow_json: Record<string, unknown>;
|
||||
usage?: WorkflowUsage;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowConvertIssue {
|
||||
node_id: string;
|
||||
code: string;
|
||||
level: 'error' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WorkflowConvertSuggested {
|
||||
kind: 'image' | 'video';
|
||||
usage: WorkflowUsage | null;
|
||||
usage_ambiguous: boolean;
|
||||
category: string | null;
|
||||
category_label: string | null;
|
||||
model_family: string | null;
|
||||
media_in: string[];
|
||||
media_out: string[];
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowPreflightFinding {
|
||||
level: string;
|
||||
code: string;
|
||||
node_id?: string;
|
||||
node_title?: string;
|
||||
message: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowConvertFileReport {
|
||||
name: string;
|
||||
source_path: string | null;
|
||||
status: 'ok' | 'warn' | 'fail';
|
||||
needs_confirmation: boolean;
|
||||
issues: WorkflowConvertIssue[];
|
||||
importable: boolean;
|
||||
skip_reason?: string | null;
|
||||
suggested?: WorkflowConvertSuggested;
|
||||
preflight?: {
|
||||
endpoint_id: string | null;
|
||||
ok: boolean;
|
||||
findings: WorkflowPreflightFinding[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Dry-run report (POST /convert, POST /import-scan with dry_run). */
|
||||
export interface WorkflowConvertReport {
|
||||
total: number;
|
||||
ok: number;
|
||||
warn: number;
|
||||
fail: number;
|
||||
needs_confirmation: number;
|
||||
importable: number;
|
||||
files: WorkflowConvertFileReport[];
|
||||
}
|
||||
|
||||
export interface WorkflowImportFileReport extends WorkflowConvertFileReport {
|
||||
result?: 'imported' | 'skipped' | 'failed';
|
||||
id?: number;
|
||||
existing_id?: number;
|
||||
}
|
||||
|
||||
/** Commit result (POST /import-batch, POST /import-scan without dry_run). */
|
||||
export interface WorkflowImportResult {
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
imported_ids: number[];
|
||||
skipped_files: { name: string; source_path: string | null; reason: string | null }[];
|
||||
files: WorkflowImportFileReport[];
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: number;
|
||||
project_id: number;
|
||||
|
||||
@@ -167,28 +167,34 @@
|
||||
<div class="stage-media" in:xfade out:xfade>
|
||||
{#if output && video}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
src={assetUrl(output)}
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
></video>
|
||||
<div class="print a-photo">
|
||||
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
src={assetUrl(output)}
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
></video>
|
||||
</div>
|
||||
{:else if output}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt={prompt || t('desk.tabResult')}
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImg(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
<div class="print a-photo">
|
||||
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt={prompt || t('desk.tabResult')}
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImg(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
</div>
|
||||
{:else if fileGone}
|
||||
<div class="stage-empty gone">
|
||||
<span>{t('desk.missingTitle')}</span>
|
||||
@@ -362,14 +368,24 @@
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-5);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.stage-media > img,
|
||||
.stage-media > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
/* 相纸白边 (HEL-680):检视舞台上的结果按相纸呈现,媒体自适应内含 */
|
||||
.print {
|
||||
position: relative;
|
||||
display: grid;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
--paper-w: 5px;
|
||||
}
|
||||
.print > img,
|
||||
.print > video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
.stage-empty {
|
||||
display: grid;
|
||||
|
||||
@@ -183,7 +183,8 @@
|
||||
|
||||
<div class="out">
|
||||
{#if output}
|
||||
<div class="thumb" class:portrait>
|
||||
<div class="thumb a-photo" class:portrait>
|
||||
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
|
||||
{#if isVideoOutput(job, output)}
|
||||
<video
|
||||
src={assetUrl(output)}
|
||||
@@ -238,15 +239,42 @@
|
||||
background-color var(--d-state) ease,
|
||||
box-shadow var(--d-state) ease;
|
||||
}
|
||||
/* 选中框:金色描边从上一条记录滑到新选中的记录(feed 驱动 WAAPI) */
|
||||
/* 选中框:取景框角线从上一条记录滑到新选中的记录(feed 驱动 WAAPI,
|
||||
HEL-680 四角短线取代整框描边) */
|
||||
.sel-mark {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
z-index: 3;
|
||||
display: none;
|
||||
border: 1px solid var(--accent-line);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0);
|
||||
background-position:
|
||||
left top,
|
||||
left top,
|
||||
right top,
|
||||
right top,
|
||||
left bottom,
|
||||
left bottom,
|
||||
right bottom,
|
||||
right bottom;
|
||||
background-size:
|
||||
14px 2px,
|
||||
2px 14px,
|
||||
14px 2px,
|
||||
2px 14px,
|
||||
14px 2px,
|
||||
2px 14px,
|
||||
14px 2px,
|
||||
2px 14px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.take.selected {
|
||||
border-color: transparent;
|
||||
@@ -458,6 +486,12 @@
|
||||
background: var(--bg-4);
|
||||
box-shadow: inset 0 0 0 1px var(--on-media-line);
|
||||
}
|
||||
/* 相纸白边 (HEL-680):有产出的记录缩略图按相纸呈现,压过占位态样式 */
|
||||
.thumb.a-photo {
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.thumb.portrait {
|
||||
width: 96px;
|
||||
aspect-ratio: 3 / 4;
|
||||
|
||||
@@ -52,7 +52,12 @@
|
||||
'var(--avatar-4)',
|
||||
'var(--avatar-5)',
|
||||
];
|
||||
const placeholder = $derived(PLACEHOLDER_GRADIENTS[project.id % PLACEHOLDER_GRADIENTS.length]);
|
||||
const placeholderIdx = $derived(project.id % PLACEHOLDER_GRADIENTS.length);
|
||||
const placeholder = $derived(PLACEHOLDER_GRADIENTS[placeholderIdx]);
|
||||
// HEL-680 灰阶封面占位:1–2 亮阶配深图标,3–5 暗阶配浅图标
|
||||
const placeholderIconColor = $derived(
|
||||
placeholderIdx <= 1 ? 'var(--accent-ink)' : 'var(--text)',
|
||||
);
|
||||
|
||||
const coverUrl = $derived(assetUrl(project.cover_path));
|
||||
|
||||
@@ -174,7 +179,12 @@
|
||||
onerror={() => (coverFailed = true)}
|
||||
/>
|
||||
{:else}
|
||||
<span class="cover-ph" style:background={placeholder} aria-hidden="true">
|
||||
<span
|
||||
class="cover-ph"
|
||||
style:background={placeholder}
|
||||
style:color={placeholderIconColor}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="film" size={22} />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -402,6 +402,7 @@
|
||||
flex-shrink: 0;
|
||||
margin-bottom: var(--sp-5);
|
||||
border-radius: var(--r-md);
|
||||
border-bottom: 2px solid var(--accent-lo);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-grad);
|
||||
|
||||
@@ -51,6 +51,7 @@ export const icons = {
|
||||
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
|
||||
// Status
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
alert:
|
||||
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
||||
|
||||
@@ -132,9 +132,7 @@ let {
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(139, 92, 246, 0.08), transparent 55%),
|
||||
#050508;
|
||||
background: var(--stage-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -79,14 +79,14 @@
|
||||
let criticNotes = $state<string[]>([]);
|
||||
|
||||
const preview = createMutation({
|
||||
mutationFn: async (vars?: { force?: boolean }) => {
|
||||
mutationFn: async (vars: { force?: boolean } | void) => {
|
||||
if (!clip && !scene) throw new Error(t('promptPreview.noClipSelected'));
|
||||
return jobsApi.previewPrompt(projectId, {
|
||||
clip_id: clip?.id,
|
||||
scene_id: clip ? undefined : scene?.id,
|
||||
workflow_id: workflow?.id,
|
||||
input_values: compactInputValues(inputValues),
|
||||
force: Boolean(vars?.force),
|
||||
force: Boolean((vars as { force?: boolean } | undefined)?.force),
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
|
||||
@@ -203,7 +203,7 @@ title={t('filmstrip.clipTitle', {
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
z-index: 1;
|
||||
background: #3f3f46;
|
||||
background: var(--bg-4);
|
||||
}
|
||||
|
||||
.clip.status-done .bar {
|
||||
|
||||
@@ -0,0 +1,908 @@
|
||||
<script lang="ts">
|
||||
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import {
|
||||
apiErrorMessage,
|
||||
workflows,
|
||||
type ComfyEndpoint,
|
||||
type WorkflowConvertFileReport,
|
||||
type WorkflowConvertReport,
|
||||
type WorkflowImportResult,
|
||||
type WorkflowUIFileInput,
|
||||
type WorkflowUsage,
|
||||
} from '$lib/api';
|
||||
import { WORKFLOW_USAGES } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
|
||||
/**
|
||||
* 批量导入向导 (HEL-682 / S4):扫描服务器目录或选择多个本地文件 →
|
||||
* dry-run 逐文件预检报告(ok / warn / fail / 需人工确认,含建议元数据)→
|
||||
* 确认后提交导入。warn 项可补充用途后以启用状态入库;fail 与需人工确认项
|
||||
* 不会被导入,也不得被标作可运行。重复导入按内容去重,全部跳过。
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
endpoints: ComfyEndpoint[];
|
||||
onimported?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), endpoints, onimported }: Props = $props();
|
||||
|
||||
type Step = 'source' | 'report' | 'result';
|
||||
|
||||
let step = $state<Step>('source');
|
||||
let mode = $state<'scan' | 'files'>('scan');
|
||||
let scanRoot = $state('example_ComfyUI_workflows');
|
||||
let preflightId = $state('');
|
||||
let inputFiles = $state<WorkflowUIFileInput[]>([]);
|
||||
let nonUiSkipped = $state<string[]>([]);
|
||||
let reading = $state(false);
|
||||
let running = $state(false);
|
||||
let importing = $state(false);
|
||||
let error = $state('');
|
||||
let report = $state<WorkflowConvertReport | null>(null);
|
||||
let result = $state<WorkflowImportResult | null>(null);
|
||||
let usageApplied = $state(0);
|
||||
/** Per-file usage chosen in the report step, keyed by name\0source_path. */
|
||||
let usageBy = $state<Record<string, WorkflowUsage>>({});
|
||||
/** Files the user un-checked (file-picker mode only). */
|
||||
let excludedKeys = $state<Set<string>>(new Set());
|
||||
let rSearch = $state('');
|
||||
let rFilter = $state<'all' | 'warn' | 'blocked'>('all');
|
||||
let expandedIssues = $state<Set<string>>(new Set());
|
||||
let visible = $state(60);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
function rk(f: { name: string; source_path: string | null }): string {
|
||||
return `${f.name}\u0000${f.source_path ?? ''}`;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
step = 'source';
|
||||
inputFiles = [];
|
||||
nonUiSkipped = [];
|
||||
report = null;
|
||||
result = null;
|
||||
usageApplied = 0;
|
||||
usageBy = {};
|
||||
excludedKeys = new Set();
|
||||
rSearch = '';
|
||||
rFilter = 'all';
|
||||
expandedIssues = new Set();
|
||||
visible = 60;
|
||||
error = '';
|
||||
}
|
||||
|
||||
function usageLabel(u: WorkflowUsage | null | undefined): string {
|
||||
return u ? t(`usage.${u}`) : t('usage.pending');
|
||||
}
|
||||
|
||||
function mediaIcon(kind: string): 'image' | 'film' | 'music' {
|
||||
if (kind === 'video') return 'film';
|
||||
if (kind === 'audio') return 'music';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function mediaTitle(kind: string): string {
|
||||
if (kind === 'video') return t('wl.mediaVideo');
|
||||
if (kind === 'audio') return t('wl.mediaAudio');
|
||||
return t('wl.mediaImage');
|
||||
}
|
||||
|
||||
async function readPickedFiles(list: FileList | null) {
|
||||
if (!list?.length) return;
|
||||
reading = true;
|
||||
error = '';
|
||||
try {
|
||||
const items: WorkflowUIFileInput[] = [];
|
||||
const skipped: string[] = [];
|
||||
for (const file of Array.from(list)) {
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
if (!(data && typeof data === 'object' && Array.isArray(data.nodes))) {
|
||||
skipped.push(file.name);
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
name: file.name.replace(/\.json$/i, ''),
|
||||
source_path: file.name,
|
||||
workflow_json: data,
|
||||
});
|
||||
} catch {
|
||||
skipped.push(file.name);
|
||||
}
|
||||
}
|
||||
inputFiles = items;
|
||||
nonUiSkipped = skipped;
|
||||
} finally {
|
||||
reading = false;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function runDryRun() {
|
||||
running = true;
|
||||
error = '';
|
||||
try {
|
||||
let res: WorkflowConvertReport;
|
||||
if (mode === 'scan') {
|
||||
res = await workflows.importScanDry(scanRoot.trim() || 'example_ComfyUI_workflows');
|
||||
inputFiles = [];
|
||||
nonUiSkipped = [];
|
||||
} else {
|
||||
if (inputFiles.length === 0) {
|
||||
error = t('wiz.noFiles');
|
||||
return;
|
||||
}
|
||||
res = await workflows.convert(
|
||||
inputFiles,
|
||||
preflightId ? { endpoint_id: preflightId } : undefined,
|
||||
);
|
||||
}
|
||||
report = res;
|
||||
usageBy = {};
|
||||
excludedKeys = new Set(res.files.filter((f) => !f.importable).map((f) => rk(f)));
|
||||
rSearch = '';
|
||||
rFilter = 'all';
|
||||
expandedIssues = new Set();
|
||||
visible = 60;
|
||||
step = 'report';
|
||||
} catch (err) {
|
||||
error = apiErrorMessage(err);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCount = $derived(
|
||||
report
|
||||
? mode === 'files'
|
||||
? report.files.filter((f) => f.importable && !excludedKeys.has(rk(f))).length
|
||||
: report.importable
|
||||
: 0,
|
||||
);
|
||||
const blockedCount = $derived(
|
||||
report ? report.files.filter((f) => !f.importable).length : 0,
|
||||
);
|
||||
|
||||
const filteredFiles = $derived.by(() => {
|
||||
if (!report) return [];
|
||||
const q = rSearch.trim().toLowerCase();
|
||||
return report.files.filter((f) => {
|
||||
if (rFilter === 'warn' && f.status !== 'warn') return false;
|
||||
if (rFilter === 'blocked' && f.importable) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
f.name.toLowerCase().includes(q) ||
|
||||
(f.source_path ?? '').toLowerCase().includes(q) ||
|
||||
(f.suggested?.model_family ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function needsUsagePick(f: WorkflowConvertFileReport): boolean {
|
||||
return Boolean(f.importable && (!f.suggested?.usage || f.suggested.usage_ambiguous));
|
||||
}
|
||||
|
||||
function setUsage(f: WorkflowConvertFileReport, usage: string) {
|
||||
const key = rk(f);
|
||||
if (usage) usageBy = { ...usageBy, [key]: usage as WorkflowUsage };
|
||||
else {
|
||||
const next = { ...usageBy };
|
||||
delete next[key];
|
||||
usageBy = next;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleIssue(f: WorkflowConvertFileReport) {
|
||||
const key = rk(f);
|
||||
const next = new Set(expandedIssues);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
expandedIssues = next;
|
||||
}
|
||||
|
||||
function skipText(reason: string | null | undefined): string {
|
||||
if (reason === 'conflict') return t('wiz.conflict');
|
||||
return reason ?? '';
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
if (!report) return;
|
||||
importing = true;
|
||||
error = '';
|
||||
try {
|
||||
let res: WorkflowImportResult;
|
||||
if (mode === 'files') {
|
||||
const files = inputFiles
|
||||
.map((item, i) => ({ item, f: report!.files[i] }))
|
||||
.filter(({ f }) => f?.importable && !excludedKeys.has(rk(f)))
|
||||
.map(({ item, f }) => {
|
||||
const usage = usageBy[rk(f)];
|
||||
return usage ? { ...item, usage } : item;
|
||||
});
|
||||
res = await workflows.importBatch(files);
|
||||
} else {
|
||||
res = await workflows.importScanCommit(scanRoot.trim() || 'example_ComfyUI_workflows');
|
||||
}
|
||||
result = res;
|
||||
// Directory commits import everything found server-side; usage
|
||||
// choices from the report step are applied to the fresh rows here.
|
||||
if (mode === 'scan') {
|
||||
let applied = 0;
|
||||
for (const f of res.files) {
|
||||
const usage = usageBy[rk(f)];
|
||||
if (f.result === 'imported' && f.id != null && usage) {
|
||||
try {
|
||||
await workflows.update(f.id, {
|
||||
usage,
|
||||
usage_source: 'user',
|
||||
is_enabled: true,
|
||||
});
|
||||
applied++;
|
||||
} catch {
|
||||
try {
|
||||
await workflows.update(f.id, { usage, usage_source: 'user' });
|
||||
applied++;
|
||||
} catch {
|
||||
/* keep the imported row, skip the override */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
usageApplied = applied;
|
||||
}
|
||||
step = 'result';
|
||||
onimported?.();
|
||||
if (res.imported > 0) toast.success(t('wiz.importedToast', { n: res.imported }));
|
||||
} catch (err) {
|
||||
error = apiErrorMessage(err);
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:open title={t('wiz.title')} width={780} onclose={reset}>
|
||||
<ol class="wz-steps" aria-label={t('wiz.title')}>
|
||||
<li class:active={step === 'source'} class:done={step !== 'source'}>
|
||||
<span class="wz-step-n">1</span>{t('wiz.stepSource')}
|
||||
</li>
|
||||
<li class:active={step === 'report'} class:done={step === 'result'}>
|
||||
<span class="wz-step-n">2</span>{t('wiz.stepReport')}
|
||||
</li>
|
||||
<li class:active={step === 'result'}>
|
||||
<span class="wz-step-n">3</span>{t('wiz.stepResult')}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
{#if error}
|
||||
<div class="wz-error" role="alert">
|
||||
<Icon name="alert" size={14} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if step === 'source'}
|
||||
<div class="seg wz-mode" role="radiogroup" aria-label={t('wiz.sourceLabel')}>
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:selected={mode === 'scan'}
|
||||
aria-pressed={mode === 'scan'}
|
||||
onclick={() => (mode = 'scan')}
|
||||
>
|
||||
<Icon name="folder" size={14} />
|
||||
{t('wiz.modeScan')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:selected={mode === 'files'}
|
||||
aria-pressed={mode === 'files'}
|
||||
onclick={() => (mode = 'files')}
|
||||
>
|
||||
<Icon name="upload" size={14} />
|
||||
{t('wiz.modeFiles')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if mode === 'scan'}
|
||||
<label class="wz-field">
|
||||
<span class="wz-label">{t('wiz.rootLabel')}</span>
|
||||
<input
|
||||
class="a-input wz-root"
|
||||
value={scanRoot}
|
||||
spellcheck="false"
|
||||
oninput={(e) => (scanRoot = e.currentTarget.value)}
|
||||
placeholder="example_ComfyUI_workflows"
|
||||
/>
|
||||
<span class="wz-hint">{t('wiz.rootHint')}</span>
|
||||
</label>
|
||||
{:else}
|
||||
<div class="wz-field">
|
||||
<span class="wz-label">{t('wiz.pickFiles')}</span>
|
||||
<button type="button" class="a-btn secondary" onclick={() => fileInput?.click()}>
|
||||
<Icon name="upload" size={14} />
|
||||
{t('wiz.pickFilesBtn')}
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
multiple
|
||||
class="wz-file-hidden"
|
||||
onchange={(e) => void readPickedFiles(e.currentTarget.files)}
|
||||
/>
|
||||
<span class="wz-hint">{t('wiz.pickFilesHint')}</span>
|
||||
{#if reading}
|
||||
<p class="wz-read">{t('common.loading')}…</p>
|
||||
{/if}
|
||||
{#if inputFiles.length}
|
||||
<p class="wz-read ok">
|
||||
<Icon name="check" size={13} />
|
||||
{t('wiz.parsed', { n: inputFiles.length })}
|
||||
</p>
|
||||
{/if}
|
||||
{#if nonUiSkipped.length}
|
||||
<p class="wz-read bad">
|
||||
<Icon name="alert" size={13} />
|
||||
{t('wiz.notUi', { n: nonUiSkipped.length })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'files' && endpoints.length > 0}
|
||||
<label class="wz-field">
|
||||
<span class="wz-label">{t('wiz.preflight')}</span>
|
||||
<select class="a-select wz-preflight" value={preflightId} onchange={(e) => (preflightId = e.currentTarget.value)}>
|
||||
<option value="">{t('wiz.preflightNone')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="wz-hint">{t('wiz.preflightHint')}</span>
|
||||
</label>
|
||||
{/if}
|
||||
{:else if step === 'report' && report}
|
||||
<div class="wz-summary">
|
||||
<span class="wz-chip">{t('wiz.total', { n: report.total })}</span>
|
||||
<span class="wz-chip ok">{t('wiz.ok', { n: report.ok })}</span>
|
||||
<span class="wz-chip warn">{t('wiz.warn', { n: report.warn })}</span>
|
||||
<span class="wz-chip fail">{t('wiz.fail', { n: report.fail })}</span>
|
||||
<span class="wz-chip confirm">{t('wiz.confirmCount', { n: report.needs_confirmation })}</span>
|
||||
<span class="wz-chip importable">{t('wiz.importable', { n: report.importable })}</span>
|
||||
</div>
|
||||
<p class="wz-note">{t('wiz.reportHint')}</p>
|
||||
|
||||
<div class="wz-toolbar">
|
||||
<div class="filters" role="group" aria-label={t('wiz.filterLabel')}>
|
||||
<button type="button" class="a-chip" class:sel={rFilter === 'all'} aria-pressed={rFilter === 'all'} onclick={() => (rFilter = 'all')}>
|
||||
{t('wl.filterAll')}
|
||||
</button>
|
||||
<button type="button" class="a-chip" class:sel={rFilter === 'warn'} aria-pressed={rFilter === 'warn'} onclick={() => (rFilter = 'warn')}>
|
||||
{t('wiz.filterWarn')}
|
||||
</button>
|
||||
<button type="button" class="a-chip" class:sel={rFilter === 'blocked'} aria-pressed={rFilter === 'blocked'} onclick={() => (rFilter = 'blocked')}>
|
||||
{t('wiz.filterBlocked')}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
class="a-input wz-search"
|
||||
value={rSearch}
|
||||
oninput={(e) => (rSearch = e.currentTarget.value)}
|
||||
placeholder={t('wiz.searchPh')}
|
||||
aria-label={t('wiz.searchPh')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="wz-list">
|
||||
{#each filteredFiles.slice(0, visible) as f (rk(f))}
|
||||
<div class="wz-row" class:blocked={!f.importable}>
|
||||
{#if mode === 'files'}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="wz-check"
|
||||
aria-label={t('wiz.includeAria', { name: f.name })}
|
||||
disabled={!f.importable}
|
||||
checked={f.importable && !excludedKeys.has(rk(f))}
|
||||
onchange={(e) => {
|
||||
const next = new Set(excludedKeys);
|
||||
if (e.currentTarget.checked) next.delete(rk(f));
|
||||
else next.add(rk(f));
|
||||
excludedKeys = next;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<span class="wz-dot {f.status}" title={f.status}></span>
|
||||
<div class="wz-main">
|
||||
<div class="wz-line">
|
||||
<span class="wz-name" title={f.suggested?.description ?? f.name}>{f.name}</span>
|
||||
{#if f.suggested?.model_family}
|
||||
<span class="tag tag-usage">{f.suggested.model_family}</span>
|
||||
{/if}
|
||||
{#if f.suggested}
|
||||
<span class="tag tag-usage">{usageLabel(f.suggested.usage)}</span>
|
||||
<span class="wz-media" aria-hidden="true">
|
||||
{#each f.suggested.media_in as m (m)}
|
||||
<span class="wz-m" title={mediaTitle(m)}><Icon name={mediaIcon(m)} size={12} /></span>
|
||||
{/each}
|
||||
{#if f.suggested.media_out.length}
|
||||
<span class="wz-arrow">→</span>
|
||||
{#each f.suggested.media_out as m (m)}
|
||||
<span class="wz-m" title={mediaTitle(m)}><Icon name={mediaIcon(m)} size={12} /></span>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if f.status === 'warn'}
|
||||
<span class="tag tag-warn">{t('wiz.warnTag')}</span>
|
||||
{/if}
|
||||
{#if f.needs_confirmation}
|
||||
<span class="tag tag-error">{t('wiz.confirmTag')}</span>
|
||||
{/if}
|
||||
{#if f.preflight && !f.preflight.ok}
|
||||
<span class="tag tag-error" title={f.preflight.findings.map((x) => x.message).join('\n')}>
|
||||
{t('wiz.preflightTag')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if f.source_path}
|
||||
<div class="wz-path mono">{f.source_path}</div>
|
||||
{/if}
|
||||
{#if !f.importable && f.skip_reason}
|
||||
<div class="wz-skip">{t('wiz.skipReason', { reason: skipText(f.skip_reason) })}</div>
|
||||
{/if}
|
||||
{#if needsUsagePick(f)}
|
||||
<label class="wz-usage">
|
||||
<span>{t('wiz.usagePick')}</span>
|
||||
<select
|
||||
class="a-select wz-usage-select"
|
||||
value={usageBy[rk(f)] ?? ''}
|
||||
onchange={(e) => setUsage(f, e.currentTarget.value)}
|
||||
>
|
||||
<option value="">{t('wiz.usageNone')}</option>
|
||||
{#each WORKFLOW_USAGES as u (u)}
|
||||
<option value={u}>{usageLabel(u)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if f.issues.length}
|
||||
<button type="button" class="wz-issues-toggle" onclick={() => toggleIssue(f)} aria-expanded={expandedIssues.has(rk(f))}>
|
||||
<Icon name="alert" size={12} />
|
||||
{t('wiz.issues', { n: f.issues.length })}
|
||||
</button>
|
||||
{#if expandedIssues.has(rk(f))}
|
||||
<ul class="wz-issues">
|
||||
{#each f.issues as iss, i (`${rk(f)}#${i}`)}
|
||||
<li class="lvl-{iss.level}">
|
||||
<span class="mono">{iss.node_id}</span>
|
||||
{iss.message}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if filteredFiles.length > visible}
|
||||
<button type="button" class="a-btn ghost wz-more" onclick={() => (visible += 100)}>
|
||||
{t('wl.moreCount', { n: Math.min(100, filteredFiles.length - visible), m: filteredFiles.length })}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if step === 'result' && result}
|
||||
<div class="wz-summary">
|
||||
<span class="wz-chip ok">{t('wiz.imported', { n: result.imported })}</span>
|
||||
<span class="wz-chip">{t('wiz.skipped', { n: result.skipped })}</span>
|
||||
{#if result.files.some((f) => f.result === 'failed')}
|
||||
<span class="wz-chip fail">{t('wiz.fail', { n: result.files.filter((f) => f.result === 'failed').length })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="wz-note">{t('wiz.resultNote')}</p>
|
||||
{#if usageApplied > 0}
|
||||
<p class="wz-note ok">{t('wiz.usageApplied', { n: usageApplied })}</p>
|
||||
{/if}
|
||||
<div class="wz-list">
|
||||
{#each result.files as f (rk(f))}
|
||||
<div class="wz-row">
|
||||
<span class="wz-dot {f.result === 'imported' ? 'ok' : f.result === 'failed' ? 'fail' : 'warn'}"></span>
|
||||
<div class="wz-main">
|
||||
<div class="wz-line">
|
||||
<span class="wz-name">{f.name}</span>
|
||||
{#if f.result === 'imported'}
|
||||
<span class="tag tag-ok">
|
||||
<Icon name="check" size={12} />
|
||||
{t('wiz.importedTag', { id: f.id ?? '?' })}
|
||||
</span>
|
||||
{:else if f.skip_reason === 'conflict' || f.result === 'skipped'}
|
||||
<span class="tag tag-usage">{skipText(f.skip_reason)}</span>
|
||||
{:else}
|
||||
<span class="tag tag-error">{skipText(f.skip_reason)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if f.source_path}
|
||||
<div class="wz-path mono">{f.source_path}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet footer()}
|
||||
{#if step === 'source'}
|
||||
<Button variant="secondary" onclick={() => (open = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={running} disabled={reading} onclick={() => void runDryRun()}>
|
||||
{running ? t('wiz.running') : t('wiz.runDry')}
|
||||
</Button>
|
||||
{:else if step === 'report'}
|
||||
<span class="wz-footer-note">
|
||||
{t('wiz.selectedN', { n: selectedCount })}
|
||||
{#if blockedCount}· {t('wiz.excludedM', { m: blockedCount })}{/if}
|
||||
</span>
|
||||
<Button variant="secondary" disabled={importing} onclick={() => (step = 'source')}>{t('wiz.back')}</Button>
|
||||
<Button variant="primary" loading={importing} disabled={selectedCount === 0} onclick={() => void runImport()}>
|
||||
{importing ? t('wiz.importing') : t('wiz.import')}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="secondary" onclick={() => { reset(); step = 'source'; }}>{t('wiz.again')}</Button>
|
||||
<Button variant="primary" onclick={() => (open = false)}>{t('wiz.done')}</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Drawer>
|
||||
|
||||
<style>
|
||||
.wz-steps {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.wz-steps li {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wz-steps li.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wz-steps li.done {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wz-step-n {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid currentColor;
|
||||
font-size: 10.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wz-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 7px;
|
||||
background: var(--error-dim);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wz-error :global(.icon) {
|
||||
color: var(--error);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.wz-mode {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.seg-item {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
padding: 8px 6px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.seg-item.selected {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wz-field {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.wz-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.wz-root,
|
||||
.wz-preflight,
|
||||
.wz-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wz-hint {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wz-file-hidden {
|
||||
display: none;
|
||||
}
|
||||
.wz-read {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wz-read.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
.wz-read.bad {
|
||||
color: var(--warn);
|
||||
}
|
||||
.wz-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.wz-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.wz-chip.ok,
|
||||
.wz-chip.importable {
|
||||
color: var(--success);
|
||||
background: var(--success-dim);
|
||||
}
|
||||
.wz-chip.warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-dim, var(--bg-4));
|
||||
}
|
||||
.wz-chip.fail,
|
||||
.wz-chip.confirm {
|
||||
color: var(--error);
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.wz-note {
|
||||
margin: 0 0 12px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wz-note.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
.wz-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wz-toolbar .filters {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.wz-search {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
.wz-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wz-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.wz-row:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.wz-row.blocked .wz-name {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wz-check {
|
||||
margin-top: 3px;
|
||||
accent-color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wz-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-top: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wz-dot.ok {
|
||||
background: var(--success);
|
||||
}
|
||||
.wz-dot.warn {
|
||||
background: var(--warn);
|
||||
}
|
||||
.wz-dot.fail {
|
||||
background: var(--error);
|
||||
}
|
||||
.wz-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wz-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wz-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tag-usage {
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.tag-warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-dim, var(--bg-4));
|
||||
}
|
||||
.tag-error {
|
||||
color: var(--error);
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.tag-ok {
|
||||
color: var(--success);
|
||||
background: var(--success-dim);
|
||||
}
|
||||
.wz-media {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wz-m {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.wz-arrow {
|
||||
font-size: 10px;
|
||||
}
|
||||
.wz-path {
|
||||
margin-top: 2px;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
word-break: break-all;
|
||||
}
|
||||
.wz-skip {
|
||||
margin-top: 4px;
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wz-usage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wz-usage-select {
|
||||
width: 180px;
|
||||
}
|
||||
.wz-issues-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 5px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
color: var(--warn);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wz-issues-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.wz-issues {
|
||||
margin: 6px 0 0;
|
||||
padding: 8px 10px;
|
||||
list-style: none;
|
||||
border-radius: 7px;
|
||||
background: var(--surface-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wz-issues li {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wz-issues li .mono {
|
||||
color: var(--text-3);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.wz-issues li.lvl-error {
|
||||
color: var(--error);
|
||||
}
|
||||
.wz-more {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.wz-footer-note {
|
||||
margin-right: auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { apiErrorMessage, workflows, type WorkflowMeta } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
|
||||
/**
|
||||
* 工作流标注 (HEL-682 / S4):单条编辑显示名/说明/标签/分类/模型家族;
|
||||
* 批量模式对选中项做查找替换重命名、统一说明、追加标签与分类标注。
|
||||
* 只有填写了的字段会被提交,空字段一律不动。
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
/** Rows to update — length 1 = single edit (prefilled), >1 = batch. */
|
||||
targets: WorkflowMeta[];
|
||||
/** Known category keys / family names for the datalists. */
|
||||
categories: string[];
|
||||
families: string[];
|
||||
onchanged?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), targets, categories, families, onchanged }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
let name = $state('');
|
||||
let nameFind = $state('');
|
||||
let nameReplace = $state('');
|
||||
let description = $state('');
|
||||
let tagsText = $state('');
|
||||
let category = $state('');
|
||||
let modelFamily = $state('');
|
||||
let applying = $state(false);
|
||||
|
||||
const isBatch = $derived(targets.length > 1);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
const first = targets[0];
|
||||
name = targets.length === 1 ? (first?.name ?? '') : '';
|
||||
nameFind = '';
|
||||
nameReplace = '';
|
||||
description = targets.length === 1 ? (first?.description ?? '') : '';
|
||||
tagsText = targets.length === 1 ? (first?.tags?.join(', ') ?? '') : '';
|
||||
category = targets.length === 1 ? (first?.category ?? '') : '';
|
||||
modelFamily = targets.length === 1 ? (first?.model_family ?? '') : '';
|
||||
}
|
||||
});
|
||||
|
||||
function parseTags(text: string): string[] {
|
||||
return text
|
||||
.split(/[,,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function payloadFor(row: WorkflowMeta): Record<string, unknown> | null {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (isBatch) {
|
||||
if (nameFind && row.name.includes(nameFind)) {
|
||||
payload.name = row.name.split(nameFind).join(nameReplace);
|
||||
}
|
||||
if (description.trim()) payload.description = description.trim();
|
||||
if (tagsText.trim()) {
|
||||
const add = parseTags(tagsText);
|
||||
const merged = [...row.tags, ...add.filter((x) => !row.tags.includes(x))];
|
||||
if (merged.length !== row.tags.length) payload.tags = merged;
|
||||
}
|
||||
if (category.trim()) payload.category = category.trim();
|
||||
if (modelFamily.trim()) payload.model_family = modelFamily.trim();
|
||||
} else {
|
||||
if (name.trim() && name.trim() !== row.name) payload.name = name.trim();
|
||||
if (description !== (row.description ?? '')) payload.description = description.trim();
|
||||
const nextTags = parseTags(tagsText);
|
||||
if (JSON.stringify(nextTags) !== JSON.stringify(row.tags ?? [])) payload.tags = nextTags;
|
||||
if (category.trim() !== (row.category ?? '')) payload.category = category.trim();
|
||||
if (modelFamily.trim() !== (row.model_family ?? '')) payload.model_family = modelFamily.trim();
|
||||
}
|
||||
return Object.keys(payload).length ? payload : null;
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
applying = true;
|
||||
try {
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
for (const row of targets) {
|
||||
const payload = payloadFor(row);
|
||||
if (!payload) {
|
||||
done++; // nothing to change counts as OK (single mode no-op)
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await workflows.update(row.id, payload);
|
||||
done++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
if (failed === 0) {
|
||||
toast.success(isBatch ? t('md.applied', { n: done }) : t('md.appliedOne'));
|
||||
} else {
|
||||
toast.error(t('md.partial', { n: done, m: failed }));
|
||||
}
|
||||
open = false;
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
onchanged?.();
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
applying = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title={isBatch ? t('md.batchTitle', { n: targets.length }) : t('md.title')} size="md">
|
||||
<div class="md-form">
|
||||
{#if isBatch}
|
||||
<p class="md-hint">
|
||||
<Icon name="info" size={14} />
|
||||
{t('md.batchHint')}
|
||||
</p>
|
||||
<div class="md-grid2">
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.nameFind')}</span>
|
||||
<input class="a-input" value={nameFind} oninput={(e) => (nameFind = e.currentTarget.value)} placeholder={t('md.nameFindPh')} />
|
||||
</label>
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.nameReplace')}</span>
|
||||
<input class="a-input" value={nameReplace} oninput={(e) => (nameReplace = e.currentTarget.value)} placeholder={t('md.nameReplacePh')} />
|
||||
</label>
|
||||
</div>
|
||||
<p class="md-sub">{t('md.nameHint')}</p>
|
||||
{:else}
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.name')}</span>
|
||||
<input class="a-input" value={name} oninput={(e) => (name = e.currentTarget.value)} />
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.description')}</span>
|
||||
<textarea
|
||||
class="a-textarea md-desc"
|
||||
rows="2"
|
||||
value={description}
|
||||
oninput={(e) => (description = e.currentTarget.value)}
|
||||
placeholder={t('md.descriptionPh')}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.tags')}</span>
|
||||
<input
|
||||
class="a-input"
|
||||
value={tagsText}
|
||||
oninput={(e) => (tagsText = e.currentTarget.value)}
|
||||
placeholder={t('md.tagsPh')}
|
||||
/>
|
||||
<span class="md-sub">{t('md.tagsHint')}</span>
|
||||
</label>
|
||||
|
||||
<div class="md-grid2">
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.category')}</span>
|
||||
<input
|
||||
class="a-input"
|
||||
value={category}
|
||||
list="md-cat-list"
|
||||
oninput={(e) => (category = e.currentTarget.value)}
|
||||
/>
|
||||
<datalist id="md-cat-list">
|
||||
{#each categories as c (c)}
|
||||
<option value={c}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</label>
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.modelFamily')}</span>
|
||||
<input
|
||||
class="a-input"
|
||||
value={modelFamily}
|
||||
list="md-family-list"
|
||||
oninput={(e) => (modelFamily = e.currentTarget.value)}
|
||||
/>
|
||||
<datalist id="md-family-list">
|
||||
{#each families as f (f)}
|
||||
<option value={f}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<Button variant="secondary" disabled={applying} onclick={() => (open = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={applying} onclick={() => void apply()}>{t('md.apply')}</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.md-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.md-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 9px 12px;
|
||||
border-radius: 7px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.md-hint :global(.icon) {
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.md-grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.md-field {
|
||||
display: block;
|
||||
}
|
||||
.md-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.md-desc {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
.md-sub {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.md-grid2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1902,7 +1902,7 @@ export const de: Dict = {
|
||||
'home.stageProgress': 'Fünf-Phasen-Fortschritt',
|
||||
'tasks.sourceWorkbench': 'Kreativ-Fluss',
|
||||
'jobst.idSuffix': ' · ComfyUI-Prompt-ID {id}',
|
||||
'jobst.done': 'Fertig',
|
||||
'jobst.done': 'Fixiert',
|
||||
'jobst.doneDetail': 'ComfyUI ist fertig, Ergebnisse gespeichert',
|
||||
'jobst.dryRun': 'Platzhalter',
|
||||
'jobst.dryRunDetail': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
|
||||
@@ -2097,4 +2097,136 @@ export const de: Dict = {
|
||||
'wfd.nodesPickSuggest': 'Wähle unten die Verwendung und schließe die Ausgangszuordnung ab, bevor du speicherst.',
|
||||
'wfd.showRaw': 'Rohes JSON anzeigen',
|
||||
'wfd.hideRaw': 'Rohes JSON ausblenden',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'UI-Format-Workflows stapelweise importieren; suchen, filtern, annotieren — volles JSON nur bei Bedarf',
|
||||
'wl.batchImport': 'Stapelimport',
|
||||
'wl.dedupe': 'Duplikate entfernen',
|
||||
'wl.dedupeTitle': 'Duplizierte Workflows entfernen',
|
||||
'wl.dedupeConfirm': 'Einträge mit identischem Namen UND Inhalt werden gelöscht (der früheste Import bleibt). Gleicher Name mit anderem Inhalt bleibt unangetastet. Fortfahren?',
|
||||
'wl.dedupeDone': '{n} doppelte Workflows entfernt',
|
||||
'wl.dedupeNone': 'Keine doppelten Workflows gefunden',
|
||||
'wl.favCount': '{n} Favoriten',
|
||||
'wl.searchPh': 'Name, Beschreibung, Tags, Modell suchen… (/ drücken)',
|
||||
'wl.filterLabel': 'Filter',
|
||||
'wl.filterAll': 'Alle',
|
||||
'wl.filterFav': 'Favoriten',
|
||||
'wl.filterPending': 'Offen',
|
||||
'wl.filterDisabled': 'Deaktiviert',
|
||||
'wl.favAria': 'Favorisieren',
|
||||
'wl.unfavAria': 'Favorit entfernen',
|
||||
'wl.categoryLabel': 'Kategorie',
|
||||
'wl.categoryAll': 'Alle Kategorien',
|
||||
'wl.familyLabel': 'Modellfamilie',
|
||||
'wl.familyAll': 'Alle Familien',
|
||||
'wl.mediaLabel': 'Medium',
|
||||
'wl.mediaAll': 'Alle Medien',
|
||||
'wl.mediaImage': 'Bild',
|
||||
'wl.mediaVideo': 'Video',
|
||||
'wl.mediaAudio': 'Audio',
|
||||
'wl.mediaIn': 'Eingabe',
|
||||
'wl.mediaOut': 'Ausgabe',
|
||||
'wl.sortLabel': 'Sortierung',
|
||||
'wl.sortRecent': 'Favoriten / zuletzt',
|
||||
'wl.sortName': 'Name',
|
||||
'wl.resetFilters': 'Filter zurücksetzen',
|
||||
'wl.noMatch': 'Keine Workflows entsprechen den Filtern',
|
||||
'wl.resultCount': '{n} von {m}',
|
||||
'wl.selected': '{n} ausgewählt',
|
||||
'wl.batchLabel': 'Stapelaktionen',
|
||||
'wl.batchAnnotate': 'Annotieren',
|
||||
'wl.batchEnable': 'Aktivieren',
|
||||
'wl.batchDisable': 'Deaktivieren',
|
||||
'wl.clearSel': 'Auswahl aufheben',
|
||||
'wl.batchDone': '{n} Workflows aktualisiert',
|
||||
'wl.batchPartial': '{n} erfolgreich, {m} fehlgeschlagen (zuerst Verwendung und Ausgabe klären)',
|
||||
'wl.selectAll': 'Alle aufgelisteten Workflows auswählen',
|
||||
'wl.rowCheckAria': '{name} auswählen',
|
||||
'wl.headMeta': 'Metadaten',
|
||||
'wl.headActions': 'Aktionen',
|
||||
'wl.enableAria': '{name} aktivieren oder deaktivieren',
|
||||
'wl.enableBlocked': 'Verwendung und Ausgabeknoten vor Aktivierung festlegen',
|
||||
'wl.editMeta': 'Metadaten bearbeiten',
|
||||
'wl.lastUsed': 'Zuletzt verwendet',
|
||||
'wl.moreCount': '{n} mehr anzeigen ({m} gesamt)',
|
||||
'wfCat.basic': 'Grundverarbeitung',
|
||||
'wfCat.audioTts': 'Sprachgenerierung',
|
||||
'wfCat.music': 'Musikgenerierung',
|
||||
'wfCat.image': 'Bildgenerierung',
|
||||
'wfCat.video': 'Videogenerierung',
|
||||
'wfCat.digitalHuman': 'Digitaler Mensch',
|
||||
'wfCat.motionTransfer': 'Bewegungsübertragung',
|
||||
'md.title': 'Workflow-Metadaten',
|
||||
'md.batchTitle': 'Stapel-Annotieren ({n})',
|
||||
'md.batchHint': 'Nur ausgefüllte Felder werden angewendet; Namen per Suchen→Ersetzen, Tags werden ergänzt.',
|
||||
'md.name': 'Anzeigename',
|
||||
'md.nameFind': 'Suchen',
|
||||
'md.nameFindPh': 'Zu ersetzender Text…',
|
||||
'md.nameReplace': 'Ersetzen mit',
|
||||
'md.nameReplacePh': 'Ersatztext…',
|
||||
'md.nameHint': 'Leeres „Suchen“ lässt Namen unverändert.',
|
||||
'md.description': 'Beschreibung',
|
||||
'md.descriptionPh': 'Eine Zeile, was dieser Workflow tut…',
|
||||
'md.tags': 'Tags',
|
||||
'md.tagsPh': 'z. B. schnell, hohe Qualität',
|
||||
'md.tagsHint': 'Kommagetrennt; im Stapelmodus werden sie ergänzt.',
|
||||
'md.category': 'Kategorie',
|
||||
'md.modelFamily': 'Modellfamilie',
|
||||
'md.apply': 'Anwenden',
|
||||
'md.applied': '{n} Workflows aktualisiert',
|
||||
'md.appliedOne': 'Aktualisiert',
|
||||
'md.partial': '{n} erfolgreich, {m} fehlgeschlagen',
|
||||
'wiz.title': 'Workflows stapelweise importieren',
|
||||
'wiz.stepSource': 'Quelle',
|
||||
'wiz.stepReport': 'Prüfbericht',
|
||||
'wiz.stepResult': 'Ergebnis',
|
||||
'wiz.sourceLabel': 'Importquelle',
|
||||
'wiz.modeScan': 'Serververzeichnis scannen',
|
||||
'wiz.modeFiles': 'Lokale Dateien wählen',
|
||||
'wiz.rootLabel': 'Serververzeichnis',
|
||||
'wiz.rootHint': 'Ein Verzeichnispfad auf dem Server (z. B. example_ComfyUI_workflows des Repos); UI-Format-JSONs werden rekursiv gescannt, API-Format und andere übersprungen.',
|
||||
'wiz.preflight': 'Preflight gegen echtes ComfyUI (optional)',
|
||||
'wiz.preflightNone': 'Überspringen (Offline-Konvertierung)',
|
||||
'wiz.preflightHint': 'Mit gewählter Verbindung werden konvertierte Graphen Knoten für Knoten geprüft.',
|
||||
'wiz.pickFiles': 'Lokale Dateien',
|
||||
'wiz.pickFilesBtn': 'JSON-Dateien wählen (mehrere)',
|
||||
'wiz.pickFilesHint': 'ComfyUI-Workflows im UI-Format wählen; Mehrfachauswahl möglich.',
|
||||
'wiz.parsed': '{n} Dateien gelesen',
|
||||
'wiz.notUi': '{n} Dateien sind kein UI-Format (kein nodes-Array) und wurden übersprungen',
|
||||
'wiz.noFiles': 'Zuerst Dateien zum Import auswählen',
|
||||
'wiz.runDry': 'Probelauf starten (nichts wird gespeichert)',
|
||||
'wiz.running': 'Prüfe…',
|
||||
'wiz.total': '{n} Dateien',
|
||||
'wiz.ok': 'OK {n}',
|
||||
'wiz.warn': 'Warnung {n}',
|
||||
'wiz.fail': 'Fehler {n}',
|
||||
'wiz.confirmCount': 'Prüfung nötig {n}',
|
||||
'wiz.importable': 'Importierbar {n}',
|
||||
'wiz.reportHint': 'Der Probelauf speichert nichts. Warnungen werden aktiviert importiert, sobald eine Verwendung angegeben ist; prüfungspflichtige und fehlgeschlagene Dateien werden nie importiert.',
|
||||
'wiz.filterLabel': 'Berichtsfilter',
|
||||
'wiz.filterWarn': 'Nur Warnungen',
|
||||
'wiz.filterBlocked': 'Nicht importierbar',
|
||||
'wiz.searchPh': 'Dateinamen suchen…',
|
||||
'wiz.includeAria': '{name} importieren',
|
||||
'wiz.skipReason': 'Nicht importierbar: {reason}',
|
||||
'wiz.issues': '{n} Konvertierungshinweise',
|
||||
'wiz.usagePick': 'Verwendung:',
|
||||
'wiz.usageNone': 'Vorerst überspringen (nach Import offen)',
|
||||
'wiz.selectedN': '{n} Dateien werden importiert',
|
||||
'wiz.excludedM': '{m} nicht importierbar',
|
||||
'wiz.import': 'Importieren',
|
||||
'wiz.importing': 'Importiere…',
|
||||
'wiz.back': 'Zurück',
|
||||
'wiz.imported': '{n} importiert',
|
||||
'wiz.skipped': '{n} übersprungen',
|
||||
'wiz.conflict': 'Existiert bereits (Duplikat übersprungen)',
|
||||
'wiz.usageApplied': 'Verwendung für {n} neue Importe gesetzt',
|
||||
'wiz.done': 'Fertig',
|
||||
'wiz.again': 'Weitere Charge importieren',
|
||||
'wiz.resultNote': 'Dateien mit identischem Inhalt werden automatisch übersprungen — Wiederholung erzeugt keine Duplikate.',
|
||||
'wiz.importedToast': '{n} Workflows importiert',
|
||||
'wiz.warnTag': 'Warnung',
|
||||
'wiz.confirmTag': 'Prüfung nötig',
|
||||
'wiz.preflightTag': 'Preflight fehlgeschlagen',
|
||||
'wiz.importedTag': 'Importiert #{id}',
|
||||
};
|
||||
|
||||
@@ -1901,7 +1901,7 @@ export const en = {
|
||||
'home.stageProgress': 'Five-stage progress',
|
||||
'tasks.sourceWorkbench': 'Creation flow',
|
||||
'jobst.idSuffix': ' · ComfyUI prompt ID {id}',
|
||||
'jobst.done': 'Done',
|
||||
'jobst.done': 'Fixed',
|
||||
'jobst.doneDetail': 'ComfyUI finished and the results are saved',
|
||||
'jobst.dryRun': 'Placeholder',
|
||||
'jobst.dryRunDetail': 'Placeholder only: Dry-run is on, ComfyUI was not called',
|
||||
@@ -2096,6 +2096,138 @@ export const en = {
|
||||
'wfd.nodesPickSuggest': 'Pick the usage and finish the output mapping below before saving.',
|
||||
'wfd.showRaw': 'View raw JSON',
|
||||
'wfd.hideRaw': 'Hide raw JSON',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'Batch-import UI-format workflows; search, filter and annotate — full JSON loads only per action',
|
||||
'wl.batchImport': 'Batch import',
|
||||
'wl.dedupe': 'Dedupe',
|
||||
'wl.dedupeTitle': 'Remove duplicate workflows',
|
||||
'wl.dedupeConfirm': 'This deletes entries whose name AND content are identical (the earliest import is kept). Same name with different content is your variant and stays untouched. Continue?',
|
||||
'wl.dedupeDone': 'Removed {n} duplicate workflows',
|
||||
'wl.dedupeNone': 'No duplicate workflows found',
|
||||
'wl.favCount': '{n} favorites',
|
||||
'wl.searchPh': 'Search name, description, tags, model… (press /)',
|
||||
'wl.filterLabel': 'Filters',
|
||||
'wl.filterAll': 'All',
|
||||
'wl.filterFav': 'Favorites',
|
||||
'wl.filterPending': 'Pending',
|
||||
'wl.filterDisabled': 'Disabled',
|
||||
'wl.favAria': 'Favorite',
|
||||
'wl.unfavAria': 'Unfavorite',
|
||||
'wl.categoryLabel': 'Category',
|
||||
'wl.categoryAll': 'All categories',
|
||||
'wl.familyLabel': 'Model family',
|
||||
'wl.familyAll': 'All families',
|
||||
'wl.mediaLabel': 'Media',
|
||||
'wl.mediaAll': 'All media',
|
||||
'wl.mediaImage': 'Image',
|
||||
'wl.mediaVideo': 'Video',
|
||||
'wl.mediaAudio': 'Audio',
|
||||
'wl.mediaIn': 'Input',
|
||||
'wl.mediaOut': 'Output',
|
||||
'wl.sortLabel': 'Sort',
|
||||
'wl.sortRecent': 'Favorite / recent',
|
||||
'wl.sortName': 'Name',
|
||||
'wl.resetFilters': 'Clear filters',
|
||||
'wl.noMatch': 'No workflows match the current filters',
|
||||
'wl.resultCount': '{n} of {m} match',
|
||||
'wl.selected': '{n} selected',
|
||||
'wl.batchLabel': 'Batch actions',
|
||||
'wl.batchAnnotate': 'Annotate',
|
||||
'wl.batchEnable': 'Enable',
|
||||
'wl.batchDisable': 'Disable',
|
||||
'wl.clearSel': 'Clear selection',
|
||||
'wl.batchDone': 'Updated {n} workflows',
|
||||
'wl.batchPartial': '{n} succeeded, {m} failed (usage and output mapping must be settled first)',
|
||||
'wl.selectAll': 'Select all listed workflows',
|
||||
'wl.rowCheckAria': 'Select {name}',
|
||||
'wl.headMeta': 'Metadata',
|
||||
'wl.headActions': 'Actions',
|
||||
'wl.enableAria': 'Enable or disable {name}',
|
||||
'wl.enableBlocked': 'Settle usage and output nodes before enabling',
|
||||
'wl.editMeta': 'Edit metadata',
|
||||
'wl.lastUsed': 'Last used',
|
||||
'wl.moreCount': 'Show {n} more ({m} total)',
|
||||
'wfCat.basic': 'Basic processing',
|
||||
'wfCat.audioTts': 'Speech generation',
|
||||
'wfCat.music': 'Music generation',
|
||||
'wfCat.image': 'Image generation',
|
||||
'wfCat.video': 'Video generation',
|
||||
'wfCat.digitalHuman': 'Digital human',
|
||||
'wfCat.motionTransfer': 'Motion transfer',
|
||||
'md.title': 'Workflow metadata',
|
||||
'md.batchTitle': 'Batch annotate ({n})',
|
||||
'md.batchHint': 'Only filled fields are applied; names are renamed via find→replace and tags are appended.',
|
||||
'md.name': 'Display name',
|
||||
'md.nameFind': 'Find',
|
||||
'md.nameFindPh': 'Text to replace…',
|
||||
'md.nameReplace': 'Replace with',
|
||||
'md.nameReplacePh': 'Replacement text…',
|
||||
'md.nameHint': 'Leave “Find” empty to keep names unchanged.',
|
||||
'md.description': 'Description',
|
||||
'md.descriptionPh': 'One line about what this workflow does…',
|
||||
'md.tags': 'Tags',
|
||||
'md.tagsPh': 'e.g. fast, high quality',
|
||||
'md.tagsHint': 'Comma separated; appended in batch mode.',
|
||||
'md.category': 'Category',
|
||||
'md.modelFamily': 'Model family',
|
||||
'md.apply': 'Apply',
|
||||
'md.applied': 'Updated {n} workflows',
|
||||
'md.appliedOne': 'Updated',
|
||||
'md.partial': '{n} succeeded, {m} failed',
|
||||
'wiz.title': 'Batch import workflows',
|
||||
'wiz.stepSource': 'Source',
|
||||
'wiz.stepReport': 'Dry-run report',
|
||||
'wiz.stepResult': 'Result',
|
||||
'wiz.sourceLabel': 'Import source',
|
||||
'wiz.modeScan': 'Scan server directory',
|
||||
'wiz.modeFiles': 'Pick local files',
|
||||
'wiz.rootLabel': 'Server directory',
|
||||
'wiz.rootHint': 'A directory path on the server (e.g. the repo’s example_ComfyUI_workflows); UI-format ComfyUI JSONs inside are scanned recursively — API-format and other JSONs are skipped.',
|
||||
'wiz.preflight': 'Preflight against a live ComfyUI (optional)',
|
||||
'wiz.preflightNone': 'Skip (offline conversion)',
|
||||
'wiz.preflightHint': 'With a connection selected, converted graphs are checked node-by-node against that ComfyUI.',
|
||||
'wiz.pickFiles': 'Local files',
|
||||
'wiz.pickFilesBtn': 'Choose JSON files (multiple)',
|
||||
'wiz.pickFilesHint': 'Pick ComfyUI UI-format workflow JSONs; multiple selection supported.',
|
||||
'wiz.parsed': '{n} files read',
|
||||
'wiz.notUi': '{n} files are not UI-format (no nodes array) and were skipped',
|
||||
'wiz.noFiles': 'Choose files to import first',
|
||||
'wiz.runDry': 'Run dry-run (nothing stored)',
|
||||
'wiz.running': 'Checking…',
|
||||
'wiz.total': '{n} files',
|
||||
'wiz.ok': 'OK {n}',
|
||||
'wiz.warn': 'Warn {n}',
|
||||
'wiz.fail': 'Fail {n}',
|
||||
'wiz.confirmCount': 'Need review {n}',
|
||||
'wiz.importable': 'Importable {n}',
|
||||
'wiz.reportHint': 'The dry-run stores nothing. Warn items import enabled once a usage is supplied; needs-review and failed items are never imported nor marked runnable.',
|
||||
'wiz.filterLabel': 'Report filters',
|
||||
'wiz.filterWarn': 'Warnings only',
|
||||
'wiz.filterBlocked': 'Not importable',
|
||||
'wiz.searchPh': 'Search file name…',
|
||||
'wiz.includeAria': 'Import {name}',
|
||||
'wiz.skipReason': 'Not importable: {reason}',
|
||||
'wiz.issues': '{n} conversion notes',
|
||||
'wiz.usagePick': 'Usage:',
|
||||
'wiz.usageNone': 'Skip for now (pending after import)',
|
||||
'wiz.selectedN': '{n} files will be imported',
|
||||
'wiz.excludedM': '{m} not importable',
|
||||
'wiz.import': 'Import',
|
||||
'wiz.importing': 'Importing…',
|
||||
'wiz.back': 'Back',
|
||||
'wiz.imported': 'Imported {n}',
|
||||
'wiz.skipped': 'Skipped {n}',
|
||||
'wiz.conflict': 'Already exists (duplicate skipped)',
|
||||
'wiz.usageApplied': 'Usage supplied for {n} new imports',
|
||||
'wiz.done': 'Done',
|
||||
'wiz.again': 'Import another batch',
|
||||
'wiz.resultNote': 'Files with identical content are skipped automatically — re-running never creates duplicates.',
|
||||
'wiz.importedToast': 'Imported {n} workflows',
|
||||
'wiz.warnTag': 'Warn',
|
||||
'wiz.confirmTag': 'Needs review',
|
||||
'wiz.preflightTag': 'Preflight failed',
|
||||
'wiz.importedTag': 'Imported #{id}',
|
||||
};
|
||||
|
||||
export type Dict = typeof en;
|
||||
|
||||
@@ -1902,7 +1902,7 @@ export const es: Dict = {
|
||||
'home.stageProgress': 'Progreso de cinco etapas',
|
||||
'tasks.sourceWorkbench': 'Flujo de creación',
|
||||
'jobst.idSuffix': ' · ID de prompt de ComfyUI {id}',
|
||||
'jobst.done': 'Listo',
|
||||
'jobst.done': 'Fijado',
|
||||
'jobst.doneDetail': 'ComfyUI terminó y los resultados se guardaron',
|
||||
'jobst.dryRun': 'Marcador',
|
||||
'jobst.dryRunDetail': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
|
||||
@@ -2097,4 +2097,136 @@ export const es: Dict = {
|
||||
'wfd.nodesPickSuggest': 'Elige el uso y completa el mapeo de salida abajo antes de guardar.',
|
||||
'wfd.showRaw': 'Ver JSON original',
|
||||
'wfd.hideRaw': 'Ocultar JSON original',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'Importa por lotes flujos en formato UI; busca, filtra y anota — el JSON completo solo se carga al actuar',
|
||||
'wl.batchImport': 'Importar por lotes',
|
||||
'wl.dedupe': 'Deduplicar',
|
||||
'wl.dedupeTitle': 'Eliminar flujos duplicados',
|
||||
'wl.dedupeConfirm': 'Se eliminan las entradas con nombre Y contenido idénticos (se conserva la primera importada). Mismo nombre con contenido distinto no se toca. ¿Continuar?',
|
||||
'wl.dedupeDone': 'Se eliminaron {n} flujos duplicados',
|
||||
'wl.dedupeNone': 'No se encontraron flujos duplicados',
|
||||
'wl.favCount': '{n} favoritos',
|
||||
'wl.searchPh': 'Buscar nombre, descripción, etiquetas, modelo… (pulsa /)',
|
||||
'wl.filterLabel': 'Filtros',
|
||||
'wl.filterAll': 'Todos',
|
||||
'wl.filterFav': 'Favoritos',
|
||||
'wl.filterPending': 'Pendientes',
|
||||
'wl.filterDisabled': 'Desactivados',
|
||||
'wl.favAria': 'Marcar como favorito',
|
||||
'wl.unfavAria': 'Quitar de favoritos',
|
||||
'wl.categoryLabel': 'Categoría',
|
||||
'wl.categoryAll': 'Todas las categorías',
|
||||
'wl.familyLabel': 'Familia de modelo',
|
||||
'wl.familyAll': 'Todas las familias',
|
||||
'wl.mediaLabel': 'Medio',
|
||||
'wl.mediaAll': 'Todos los medios',
|
||||
'wl.mediaImage': 'Imagen',
|
||||
'wl.mediaVideo': 'Vídeo',
|
||||
'wl.mediaAudio': 'Audio',
|
||||
'wl.mediaIn': 'Entrada',
|
||||
'wl.mediaOut': 'Salida',
|
||||
'wl.sortLabel': 'Orden',
|
||||
'wl.sortRecent': 'Favoritos / recientes',
|
||||
'wl.sortName': 'Nombre',
|
||||
'wl.resetFilters': 'Limpiar filtros',
|
||||
'wl.noMatch': 'Ningún flujo coincide con los filtros',
|
||||
'wl.resultCount': '{n} de {m}',
|
||||
'wl.selected': '{n} seleccionados',
|
||||
'wl.batchLabel': 'Acciones por lotes',
|
||||
'wl.batchAnnotate': 'Anotar',
|
||||
'wl.batchEnable': 'Activar',
|
||||
'wl.batchDisable': 'Desactivar',
|
||||
'wl.clearSel': 'Limpiar selección',
|
||||
'wl.batchDone': '{n} flujos actualizados',
|
||||
'wl.batchPartial': '{n} con éxito, {m} fallidos (confirma antes uso y salida)',
|
||||
'wl.selectAll': 'Seleccionar todos los flujos listados',
|
||||
'wl.rowCheckAria': 'Seleccionar {name}',
|
||||
'wl.headMeta': 'Metadatos',
|
||||
'wl.headActions': 'Acciones',
|
||||
'wl.enableAria': 'Activar o desactivar {name}',
|
||||
'wl.enableBlocked': 'Confirma uso y nodos de salida antes de activar',
|
||||
'wl.editMeta': 'Editar metadatos',
|
||||
'wl.lastUsed': 'Último uso',
|
||||
'wl.moreCount': 'Mostrar {n} más ({m} en total)',
|
||||
'wfCat.basic': 'Procesamiento básico',
|
||||
'wfCat.audioTts': 'Generación de voz',
|
||||
'wfCat.music': 'Generación de música',
|
||||
'wfCat.image': 'Generación de imágenes',
|
||||
'wfCat.video': 'Generación de vídeo',
|
||||
'wfCat.digitalHuman': 'Humano digital',
|
||||
'wfCat.motionTransfer': 'Transferencia de movimiento',
|
||||
'md.title': 'Metadatos del flujo',
|
||||
'md.batchTitle': 'Anotar por lotes ({n})',
|
||||
'md.batchHint': 'Solo se aplican los campos rellenados; los nombres se renombran con buscar→reemplazar y las etiquetas se añaden.',
|
||||
'md.name': 'Nombre visible',
|
||||
'md.nameFind': 'Buscar',
|
||||
'md.nameFindPh': 'Texto a reemplazar…',
|
||||
'md.nameReplace': 'Reemplazar por',
|
||||
'md.nameReplacePh': 'Texto de reemplazo…',
|
||||
'md.nameHint': 'Deja «Buscar» vacío para no cambiar nombres.',
|
||||
'md.description': 'Descripción',
|
||||
'md.descriptionPh': 'Una línea sobre qué hace este flujo…',
|
||||
'md.tags': 'Etiquetas',
|
||||
'md.tagsPh': 'p. ej. rápido, alta calidad',
|
||||
'md.tagsHint': 'Separadas por comas; en lote se añaden.',
|
||||
'md.category': 'Categoría',
|
||||
'md.modelFamily': 'Familia de modelo',
|
||||
'md.apply': 'Aplicar',
|
||||
'md.applied': '{n} flujos actualizados',
|
||||
'md.appliedOne': 'Actualizado',
|
||||
'md.partial': '{n} con éxito, {m} fallidos',
|
||||
'wiz.title': 'Importar flujos por lotes',
|
||||
'wiz.stepSource': 'Origen',
|
||||
'wiz.stepReport': 'Informe de prueba',
|
||||
'wiz.stepResult': 'Resultado',
|
||||
'wiz.sourceLabel': 'Origen de importación',
|
||||
'wiz.modeScan': 'Escanear carpeta del servidor',
|
||||
'wiz.modeFiles': 'Elegir archivos locales',
|
||||
'wiz.rootLabel': 'Carpeta del servidor',
|
||||
'wiz.rootHint': 'Ruta de carpeta en el servidor (p. ej. example_ComfyUI_workflows del repo); se escanean recursivamente los JSON en formato UI, el formato API y otros JSON se omiten.',
|
||||
'wiz.preflight': 'Preflight contra un ComfyUI real (opcional)',
|
||||
'wiz.preflightNone': 'Omitir (conversión sin conexión)',
|
||||
'wiz.preflightHint': 'Con una conexión elegida, los grafos convertidos se verifican nodo a nodo.',
|
||||
'wiz.pickFiles': 'Archivos locales',
|
||||
'wiz.pickFilesBtn': 'Elegir archivos JSON (varios)',
|
||||
'wiz.pickFilesHint': 'Elige JSON de flujos ComfyUI en formato UI; se admite selección múltiple.',
|
||||
'wiz.parsed': '{n} archivos leídos',
|
||||
'wiz.notUi': '{n} archivos no son formato UI (sin array nodes) y se omitieron',
|
||||
'wiz.noFiles': 'Elige primero los archivos a importar',
|
||||
'wiz.runDry': 'Ejecutar prueba (no se guarda nada)',
|
||||
'wiz.running': 'Comprobando…',
|
||||
'wiz.total': '{n} archivos',
|
||||
'wiz.ok': 'OK {n}',
|
||||
'wiz.warn': 'Avisos {n}',
|
||||
'wiz.fail': 'Fallos {n}',
|
||||
'wiz.confirmCount': 'Requieren revisión {n}',
|
||||
'wiz.importable': 'Importables {n}',
|
||||
'wiz.reportHint': 'La prueba no guarda nada. Los avisos se importan activados al indicar un uso; los que requieren revisión o fallaron nunca se importan.',
|
||||
'wiz.filterLabel': 'Filtros del informe',
|
||||
'wiz.filterWarn': 'Solo avisos',
|
||||
'wiz.filterBlocked': 'No importables',
|
||||
'wiz.searchPh': 'Buscar nombre de archivo…',
|
||||
'wiz.includeAria': 'Importar {name}',
|
||||
'wiz.skipReason': 'No importable: {reason}',
|
||||
'wiz.issues': '{n} notas de conversión',
|
||||
'wiz.usagePick': 'Uso:',
|
||||
'wiz.usageNone': 'Omitir por ahora (pendiente tras importar)',
|
||||
'wiz.selectedN': 'Se importarán {n} archivos',
|
||||
'wiz.excludedM': '{m} no importables',
|
||||
'wiz.import': 'Importar',
|
||||
'wiz.importing': 'Importando…',
|
||||
'wiz.back': 'Atrás',
|
||||
'wiz.imported': '{n} importados',
|
||||
'wiz.skipped': '{n} omitidos',
|
||||
'wiz.conflict': 'Ya existe (duplicado omitido)',
|
||||
'wiz.usageApplied': 'Uso indicado para {n} importaciones nuevas',
|
||||
'wiz.done': 'Hecho',
|
||||
'wiz.again': 'Importar otro lote',
|
||||
'wiz.resultNote': 'Los archivos con contenido idéntico se omiten automáticamente; repetir no crea duplicados.',
|
||||
'wiz.importedToast': '{n} flujos importados',
|
||||
'wiz.warnTag': 'Aviso',
|
||||
'wiz.confirmTag': 'Requiere revisión',
|
||||
'wiz.preflightTag': 'Preflight fallido',
|
||||
'wiz.importedTag': 'Importado #{id}',
|
||||
};
|
||||
|
||||
@@ -1902,7 +1902,7 @@ export const fr: Dict = {
|
||||
'home.stageProgress': 'Progression en cinq étapes',
|
||||
'tasks.sourceWorkbench': 'Flux de création',
|
||||
'jobst.idSuffix': ' · ID de prompt ComfyUI {id}',
|
||||
'jobst.done': 'Terminé',
|
||||
'jobst.done': 'Fixé',
|
||||
'jobst.doneDetail': 'ComfyUI a terminé, résultats enregistrés',
|
||||
'jobst.dryRun': 'Substitut',
|
||||
'jobst.dryRunDetail': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
|
||||
@@ -2097,4 +2097,136 @@ export const fr: Dict = {
|
||||
'wfd.nodesPickSuggest': "Choisissez l'usage et terminez le mappage des sorties ci-dessous avant d'enregistrer.",
|
||||
'wfd.showRaw': 'Voir le JSON brut',
|
||||
'wfd.hideRaw': 'Masquer le JSON brut',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'Importez par lots des flux au format UI ; cherchez, filtrez, annotez — le JSON complet ne se charge qu’à l’action',
|
||||
'wl.batchImport': 'Import par lot',
|
||||
'wl.dedupe': 'Dédupliquer',
|
||||
'wl.dedupeTitle': 'Supprimer les flux dupliqués',
|
||||
'wl.dedupeConfirm': 'Les entrées au nom ET contenu identiques seront supprimées (le premier import est conservé). Même nom mais contenu différent = votre variante, intact. Continuer ?',
|
||||
'wl.dedupeDone': '{n} flux dupliqués supprimés',
|
||||
'wl.dedupeNone': 'Aucun flux dupliqué trouvé',
|
||||
'wl.favCount': '{n} favoris',
|
||||
'wl.searchPh': 'Rechercher nom, description, tags, modèle… (touche /)',
|
||||
'wl.filterLabel': 'Filtres',
|
||||
'wl.filterAll': 'Tous',
|
||||
'wl.filterFav': 'Favoris',
|
||||
'wl.filterPending': 'À confirmer',
|
||||
'wl.filterDisabled': 'Désactivés',
|
||||
'wl.favAria': 'Mettre en favori',
|
||||
'wl.unfavAria': 'Retirer des favoris',
|
||||
'wl.categoryLabel': 'Catégorie',
|
||||
'wl.categoryAll': 'Toutes les catégories',
|
||||
'wl.familyLabel': 'Famille de modèle',
|
||||
'wl.familyAll': 'Toutes les familles',
|
||||
'wl.mediaLabel': 'Média',
|
||||
'wl.mediaAll': 'Tous les médias',
|
||||
'wl.mediaImage': 'Image',
|
||||
'wl.mediaVideo': 'Vidéo',
|
||||
'wl.mediaAudio': 'Audio',
|
||||
'wl.mediaIn': 'Entrée',
|
||||
'wl.mediaOut': 'Sortie',
|
||||
'wl.sortLabel': 'Tri',
|
||||
'wl.sortRecent': 'Favoris / récents',
|
||||
'wl.sortName': 'Nom',
|
||||
'wl.resetFilters': 'Effacer les filtres',
|
||||
'wl.noMatch': 'Aucun flux ne correspond aux filtres',
|
||||
'wl.resultCount': '{n} sur {m}',
|
||||
'wl.selected': '{n} sélectionnés',
|
||||
'wl.batchLabel': 'Actions groupées',
|
||||
'wl.batchAnnotate': 'Annoter',
|
||||
'wl.batchEnable': 'Activer',
|
||||
'wl.batchDisable': 'Désactiver',
|
||||
'wl.clearSel': 'Effacer la sélection',
|
||||
'wl.batchDone': '{n} flux mis à jour',
|
||||
'wl.batchPartial': '{n} réussis, {m} échoués (usage et sortie à confirmer d’abord)',
|
||||
'wl.selectAll': 'Sélectionner tous les flux listés',
|
||||
'wl.rowCheckAria': 'Sélectionner {name}',
|
||||
'wl.headMeta': 'Métadonnées',
|
||||
'wl.headActions': 'Actions',
|
||||
'wl.enableAria': 'Activer ou désactiver {name}',
|
||||
'wl.enableBlocked': 'Confirmez l’usage et les nœuds de sortie avant activation',
|
||||
'wl.editMeta': 'Modifier les métadonnées',
|
||||
'wl.lastUsed': 'Dernière utilisation',
|
||||
'wl.moreCount': 'Afficher {n} de plus ({m} au total)',
|
||||
'wfCat.basic': 'Traitement de base',
|
||||
'wfCat.audioTts': 'Génération de voix',
|
||||
'wfCat.music': 'Génération de musique',
|
||||
'wfCat.image': 'Génération d’images',
|
||||
'wfCat.video': 'Génération de vidéo',
|
||||
'wfCat.digitalHuman': 'Humain numérique',
|
||||
'wfCat.motionTransfer': 'Transfert de mouvement',
|
||||
'md.title': 'Métadonnées du flux',
|
||||
'md.batchTitle': 'Annotation groupée ({n})',
|
||||
'md.batchHint': 'Seuls les champs remplis sont appliqués ; renommage par rechercher→remplacer, tags ajoutés.',
|
||||
'md.name': 'Nom affiché',
|
||||
'md.nameFind': 'Rechercher',
|
||||
'md.nameFindPh': 'Texte à remplacer…',
|
||||
'md.nameReplace': 'Remplacer par',
|
||||
'md.nameReplacePh': 'Texte de remplacement…',
|
||||
'md.nameHint': 'Laissez « Rechercher » vide pour ne pas renommer.',
|
||||
'md.description': 'Description',
|
||||
'md.descriptionPh': 'Une ligne sur ce que fait ce flux…',
|
||||
'md.tags': 'Tags',
|
||||
'md.tagsPh': 'ex. rapide, haute qualité',
|
||||
'md.tagsHint': 'Séparés par des virgules ; ajoutés en mode groupé.',
|
||||
'md.category': 'Catégorie',
|
||||
'md.modelFamily': 'Famille de modèle',
|
||||
'md.apply': 'Appliquer',
|
||||
'md.applied': '{n} flux mis à jour',
|
||||
'md.appliedOne': 'Mis à jour',
|
||||
'md.partial': '{n} réussis, {m} échoués',
|
||||
'wiz.title': 'Import de flux par lot',
|
||||
'wiz.stepSource': 'Source',
|
||||
'wiz.stepReport': 'Rapport de test',
|
||||
'wiz.stepResult': 'Résultat',
|
||||
'wiz.sourceLabel': 'Source d’import',
|
||||
'wiz.modeScan': 'Scanner un dossier serveur',
|
||||
'wiz.modeFiles': 'Choisir des fichiers locaux',
|
||||
'wiz.rootLabel': 'Dossier serveur',
|
||||
'wiz.rootHint': 'Chemin d’un dossier sur le serveur (ex. example_ComfyUI_workflows du dépôt) ; les JSON au format UI sont scannés récursivement, le format API et les autres sont ignorés.',
|
||||
'wiz.preflight': 'Pré-vérification sur un ComfyUI réel (facultatif)',
|
||||
'wiz.preflightNone': 'Passer (conversion hors ligne)',
|
||||
'wiz.preflightHint': 'Avec une connexion choisie, les graphes convertis sont vérifiés nœud par nœud.',
|
||||
'wiz.pickFiles': 'Fichiers locaux',
|
||||
'wiz.pickFilesBtn': 'Choisir des fichiers JSON (plusieurs)',
|
||||
'wiz.pickFilesHint': 'Choisissez des flux ComfyUI au format UI ; sélection multiple possible.',
|
||||
'wiz.parsed': '{n} fichiers lus',
|
||||
'wiz.notUi': '{n} fichiers ne sont pas au format UI (pas de tableau nodes), ignorés',
|
||||
'wiz.noFiles': 'Choisissez d’abord des fichiers à importer',
|
||||
'wiz.runDry': 'Lancer le test (rien n’est enregistré)',
|
||||
'wiz.running': 'Vérification…',
|
||||
'wiz.total': '{n} fichiers',
|
||||
'wiz.ok': 'OK {n}',
|
||||
'wiz.warn': 'Avertissements {n}',
|
||||
'wiz.fail': 'Échecs {n}',
|
||||
'wiz.confirmCount': 'À vérifier {n}',
|
||||
'wiz.importable': 'Importables {n}',
|
||||
'wiz.reportHint': 'Le test n’écrit rien. Les avertissements s’importent activés dès qu’un usage est fourni ; les fichiers à vérifier ou en échec ne sont jamais importés.',
|
||||
'wiz.filterLabel': 'Filtres du rapport',
|
||||
'wiz.filterWarn': 'Avertissements seuls',
|
||||
'wiz.filterBlocked': 'Non importables',
|
||||
'wiz.searchPh': 'Rechercher un nom de fichier…',
|
||||
'wiz.includeAria': 'Importer {name}',
|
||||
'wiz.skipReason': 'Non importable : {reason}',
|
||||
'wiz.issues': '{n} notes de conversion',
|
||||
'wiz.usagePick': 'Usage :',
|
||||
'wiz.usageNone': 'Passer pour l’instant (à confirmer après import)',
|
||||
'wiz.selectedN': '{n} fichiers seront importés',
|
||||
'wiz.excludedM': '{m} non importables',
|
||||
'wiz.import': 'Importer',
|
||||
'wiz.importing': 'Import…',
|
||||
'wiz.back': 'Retour',
|
||||
'wiz.imported': '{n} importés',
|
||||
'wiz.skipped': '{n} ignorés',
|
||||
'wiz.conflict': 'Existe déjà (doublon ignoré)',
|
||||
'wiz.usageApplied': 'Usage fourni pour {n} nouveaux imports',
|
||||
'wiz.done': 'Terminé',
|
||||
'wiz.again': 'Importer un autre lot',
|
||||
'wiz.resultNote': 'Les fichiers au contenu identique sont ignorés automatiquement — relancer ne crée jamais de doublons.',
|
||||
'wiz.importedToast': '{n} flux importés',
|
||||
'wiz.warnTag': 'Avertissement',
|
||||
'wiz.confirmTag': 'À vérifier',
|
||||
'wiz.preflightTag': 'Pré-vérification échouée',
|
||||
'wiz.importedTag': 'Importé #{id}',
|
||||
};
|
||||
|
||||
@@ -1900,7 +1900,7 @@ export const ja: Dict = {
|
||||
'home.stageProgress': '五段階の進捗',
|
||||
'tasks.sourceWorkbench': '制作フロー',
|
||||
'jobst.idSuffix': ' · ComfyUI プロンプト ID {id}',
|
||||
'jobst.done': '完了',
|
||||
'jobst.done': '定着',
|
||||
'jobst.doneDetail': 'ComfyUI の実行が完了し、結果を保存しました',
|
||||
'jobst.dryRun': 'プレースホルダー',
|
||||
'jobst.dryRunDetail': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
|
||||
@@ -2095,4 +2095,136 @@ export const ja: Dict = {
|
||||
'wfd.nodesPickSuggest': '保存前に下で用途を選び、出力マッピングを完了してください。',
|
||||
'wfd.showRaw': '元の JSON を見る',
|
||||
'wfd.hideRaw': '元の JSON を閉じる',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'UI 形式ワークフローを一括取り込み、検索・絞り込み・注釈管理。完全な JSON は操作時のみ読み込み',
|
||||
'wl.batchImport': '一括取り込み',
|
||||
'wl.dedupe': '重複を削除',
|
||||
'wl.dedupeTitle': '重複ワークフローの削除',
|
||||
'wl.dedupeConfirm': '名前と内容が完全に同一の項目を削除します(最初に取り込んだものを残す)。名前が同じでも内容が異なるものは触れません。実行しますか?',
|
||||
'wl.dedupeDone': '重複 {n} 件を削除しました',
|
||||
'wl.dedupeNone': '重複するワークフローはありません',
|
||||
'wl.favCount': 'お気に入り {n} 件',
|
||||
'wl.searchPh': '名前・説明・タグ・モデルを検索…(/ で移動)',
|
||||
'wl.filterLabel': '絞り込み',
|
||||
'wl.filterAll': 'すべて',
|
||||
'wl.filterFav': 'お気に入り',
|
||||
'wl.filterPending': '要確認',
|
||||
'wl.filterDisabled': '無効',
|
||||
'wl.favAria': 'お気に入りに追加',
|
||||
'wl.unfavAria': 'お気に入りから外す',
|
||||
'wl.categoryLabel': 'カテゴリ',
|
||||
'wl.categoryAll': 'すべてのカテゴリ',
|
||||
'wl.familyLabel': 'モデルファミリー',
|
||||
'wl.familyAll': 'すべてのファミリー',
|
||||
'wl.mediaLabel': 'メディア',
|
||||
'wl.mediaAll': 'すべてのメディア',
|
||||
'wl.mediaImage': '画像',
|
||||
'wl.mediaVideo': '動画',
|
||||
'wl.mediaAudio': '音声',
|
||||
'wl.mediaIn': '入力',
|
||||
'wl.mediaOut': '出力',
|
||||
'wl.sortLabel': '並び替え',
|
||||
'wl.sortRecent': 'お気に入り / 最近',
|
||||
'wl.sortName': '名前',
|
||||
'wl.resetFilters': '絞り込みを解除',
|
||||
'wl.noMatch': '条件に合うワークフローがありません',
|
||||
'wl.resultCount': '{n} / {m} 件',
|
||||
'wl.selected': '{n} 件選択中',
|
||||
'wl.batchLabel': '一括操作',
|
||||
'wl.batchAnnotate': '一括注釈',
|
||||
'wl.batchEnable': '有効化',
|
||||
'wl.batchDisable': '無効化',
|
||||
'wl.clearSel': '選択を解除',
|
||||
'wl.batchDone': '{n} 件を更新しました',
|
||||
'wl.batchPartial': '{n} 件成功、{m} 件失敗(用途と出力の確認が必要)',
|
||||
'wl.selectAll': '表示中のワークフローをすべて選択',
|
||||
'wl.rowCheckAria': '{name} を選択',
|
||||
'wl.headMeta': 'メタデータ',
|
||||
'wl.headActions': '操作',
|
||||
'wl.enableAria': '{name} を有効化・無効化',
|
||||
'wl.enableBlocked': '用途と出力ノードを確定してから有効化してください',
|
||||
'wl.editMeta': 'メタデータを編集',
|
||||
'wl.lastUsed': '最終使用',
|
||||
'wl.moreCount': 'さらに {n} 件表示(全 {m} 件)',
|
||||
'wfCat.basic': '基本処理',
|
||||
'wfCat.audioTts': '音声生成',
|
||||
'wfCat.music': '音楽生成',
|
||||
'wfCat.image': '画像生成',
|
||||
'wfCat.video': '動画生成',
|
||||
'wfCat.digitalHuman': 'デジタルヒューマン',
|
||||
'wfCat.motionTransfer': 'モーション転送',
|
||||
'md.title': 'ワークフローの注釈',
|
||||
'md.batchTitle': '一括注釈({n} 件)',
|
||||
'md.batchHint': '入力したフィールドのみ適用。名前は検索→置換で変更、タグは追加されます。',
|
||||
'md.name': '表示名',
|
||||
'md.nameFind': '検索',
|
||||
'md.nameFindPh': '置換する文字…',
|
||||
'md.nameReplace': '置換後',
|
||||
'md.nameReplacePh': '置換する文字…',
|
||||
'md.nameHint': '「検索」が空なら名前は変更されません。',
|
||||
'md.description': '説明',
|
||||
'md.descriptionPh': 'このワークフローの用途を一行で…',
|
||||
'md.tags': 'タグ',
|
||||
'md.tagsPh': '例: 高速, 高品質',
|
||||
'md.tagsHint': 'カンマ区切り。一括モードでは追加されます。',
|
||||
'md.category': 'カテゴリ',
|
||||
'md.modelFamily': 'モデルファミリー',
|
||||
'md.apply': '適用',
|
||||
'md.applied': '{n} 件を更新しました',
|
||||
'md.appliedOne': '更新しました',
|
||||
'md.partial': '{n} 件成功、{m} 件失敗',
|
||||
'wiz.title': 'ワークフローの一括取り込み',
|
||||
'wiz.stepSource': '取り込み元',
|
||||
'wiz.stepReport': '事前チェック',
|
||||
'wiz.stepResult': '結果',
|
||||
'wiz.sourceLabel': '取り込み元',
|
||||
'wiz.modeScan': 'サーバーディレクトリをスキャン',
|
||||
'wiz.modeFiles': 'ローカルファイルを選択',
|
||||
'wiz.rootLabel': 'サーバーディレクトリ',
|
||||
'wiz.rootHint': 'サーバー上のディレクトリパス(例: リポジトリの example_ComfyUI_workflows)。UI 形式の JSON を再帰的にスキャンし、API 形式などは自動的にスキップします。',
|
||||
'wiz.preflight': '実機 ComfyUI で事前チェック(任意)',
|
||||
'wiz.preflightNone': 'スキップ(オフライン変換)',
|
||||
'wiz.preflightHint': '接続を選ぶと、変換結果をその ComfyUI のノート・モデルと照合します。',
|
||||
'wiz.pickFiles': 'ローカルファイル',
|
||||
'wiz.pickFilesBtn': 'JSON ファイルを選択(複数可)',
|
||||
'wiz.pickFilesHint': 'ComfyUI UI 形式のワークフロー JSON を選んでください。複数選択できます。',
|
||||
'wiz.parsed': '{n} ファイルを読み込みました',
|
||||
'wiz.notUi': '{n} ファイルは UI 形式でないため(nodes 配列なし)スキップしました',
|
||||
'wiz.noFiles': '先に取り込むファイルを選んでください',
|
||||
'wiz.runDry': '事前チェックを実行(保存しません)',
|
||||
'wiz.running': 'チェック中…',
|
||||
'wiz.total': '全体 {n} ファイル',
|
||||
'wiz.ok': '正常 {n}',
|
||||
'wiz.warn': '警告 {n}',
|
||||
'wiz.fail': '失敗 {n}',
|
||||
'wiz.confirmCount': '要確認 {n}',
|
||||
'wiz.importable': '取り込み可 {n}',
|
||||
'wiz.reportHint': '事前チェックは何も書き込みません。警告は用途を補えば有効な状態で取り込まれ、要確認・失敗のファイルは取り込まれません。',
|
||||
'wiz.filterLabel': 'レポートの絞り込み',
|
||||
'wiz.filterWarn': '警告のみ',
|
||||
'wiz.filterBlocked': '取り込み不可',
|
||||
'wiz.searchPh': 'ファイル名を検索…',
|
||||
'wiz.includeAria': '{name} を取り込む',
|
||||
'wiz.skipReason': '取り込み不可: {reason}',
|
||||
'wiz.issues': '変換メモ {n} 件',
|
||||
'wiz.usagePick': '用途:',
|
||||
'wiz.usageNone': '今はスキップ(取り込み後に要確認)',
|
||||
'wiz.selectedN': '{n} ファイルを取り込みます',
|
||||
'wiz.excludedM': '{m} 件は取り込み不可',
|
||||
'wiz.import': '取り込む',
|
||||
'wiz.importing': '取り込み中…',
|
||||
'wiz.back': '戻る',
|
||||
'wiz.imported': '{n} 件取り込み完了',
|
||||
'wiz.skipped': '{n} 件スキップ',
|
||||
'wiz.conflict': '既に存在(重複は自動スキップ)',
|
||||
'wiz.usageApplied': '新規取り込み {n} 件に用途を設定しました',
|
||||
'wiz.done': '完了',
|
||||
'wiz.again': '続けて取り込む',
|
||||
'wiz.resultNote': '内容が同一のファイルは自動スキップされ、再実行しても重複は作られません。',
|
||||
'wiz.importedToast': '{n} 件のワークフローを取り込みました',
|
||||
'wiz.warnTag': '警告',
|
||||
'wiz.confirmTag': '要確認',
|
||||
'wiz.preflightTag': '事前チェック不合格',
|
||||
'wiz.importedTag': '取り込み済み #{id}',
|
||||
};
|
||||
|
||||
@@ -1900,7 +1900,7 @@ export const ko: Dict = {
|
||||
'home.stageProgress': '5단계 진행',
|
||||
'tasks.sourceWorkbench': '제작 흐름',
|
||||
'jobst.idSuffix': ' · ComfyUI 프롬프트 ID {id}',
|
||||
'jobst.done': '완료',
|
||||
'jobst.done': '정착',
|
||||
'jobst.doneDetail': 'ComfyUI 실행이 끝나 결과를 저장했습니다',
|
||||
'jobst.dryRun': '자리표시자',
|
||||
'jobst.dryRunDetail': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
|
||||
@@ -2095,4 +2095,136 @@ export const ko: Dict = {
|
||||
'wfd.nodesPickSuggest': '저장하기 전에 아래에서 용도를 선택하고 출력 매핑을 완료하세요.',
|
||||
'wfd.showRaw': '원본 JSON 보기',
|
||||
'wfd.hideRaw': '원본 JSON 접기',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'UI 형식 워크플로 일괄 가져오기, 검색·필터·주석 관리. 전체 JSON은 작업 시에만 로드',
|
||||
'wl.batchImport': '일괄 가져오기',
|
||||
'wl.dedupe': '중복 정리',
|
||||
'wl.dedupeTitle': '중복 워크플로 삭제',
|
||||
'wl.dedupeConfirm': '이름과 내용이 완전히 같은 항목을 삭제합니다(가장 먼저 가져온 것 유지). 이름이 같아도 내용이 다르면 그대로 둡니다. 계속할까요?',
|
||||
'wl.dedupeDone': '중복 워크플로 {n}건 삭제',
|
||||
'wl.dedupeNone': '중복 워크플로가 없습니다',
|
||||
'wl.favCount': '즐겨찾기 {n}건',
|
||||
'wl.searchPh': '이름·설명·태그·모델 검색… (/ 키)',
|
||||
'wl.filterLabel': '필터',
|
||||
'wl.filterAll': '전체',
|
||||
'wl.filterFav': '즐겨찾기',
|
||||
'wl.filterPending': '확인 필요',
|
||||
'wl.filterDisabled': '비활성',
|
||||
'wl.favAria': '즐겨찾기 추가',
|
||||
'wl.unfavAria': '즐겨찾기 해제',
|
||||
'wl.categoryLabel': '분류',
|
||||
'wl.categoryAll': '전체 분류',
|
||||
'wl.familyLabel': '모델 계열',
|
||||
'wl.familyAll': '전체 계열',
|
||||
'wl.mediaLabel': '미디어',
|
||||
'wl.mediaAll': '전체 미디어',
|
||||
'wl.mediaImage': '이미지',
|
||||
'wl.mediaVideo': '영상',
|
||||
'wl.mediaAudio': '오디오',
|
||||
'wl.mediaIn': '입력',
|
||||
'wl.mediaOut': '출력',
|
||||
'wl.sortLabel': '정렬',
|
||||
'wl.sortRecent': '즐겨찾기 / 최근',
|
||||
'wl.sortName': '이름',
|
||||
'wl.resetFilters': '필터 해제',
|
||||
'wl.noMatch': '조건에 맞는 워크플로가 없습니다',
|
||||
'wl.resultCount': '{n} / {m}',
|
||||
'wl.selected': '{n}건 선택됨',
|
||||
'wl.batchLabel': '일괄 작업',
|
||||
'wl.batchAnnotate': '일괄 주석',
|
||||
'wl.batchEnable': '활성화',
|
||||
'wl.batchDisable': '비활성화',
|
||||
'wl.clearSel': '선택 해제',
|
||||
'wl.batchDone': '{n}건 업데이트',
|
||||
'wl.batchPartial': '{n}건 성공, {m}건 실패(용도와 출력 먼저 확정)',
|
||||
'wl.selectAll': '나열된 워크플로 모두 선택',
|
||||
'wl.rowCheckAria': '{name} 선택',
|
||||
'wl.headMeta': '메타데이터',
|
||||
'wl.headActions': '작업',
|
||||
'wl.enableAria': '{name} 활성화/비활성화',
|
||||
'wl.enableBlocked': '활성화 전에 용도와 출력 노드를 확정하세요',
|
||||
'wl.editMeta': '메타데이터 편집',
|
||||
'wl.lastUsed': '마지막 사용',
|
||||
'wl.moreCount': '{n}건 더 보기(전체 {m}건)',
|
||||
'wfCat.basic': '기본 처리',
|
||||
'wfCat.audioTts': '음성 생성',
|
||||
'wfCat.music': '음악 생성',
|
||||
'wfCat.image': '이미지 생성',
|
||||
'wfCat.video': '영상 생성',
|
||||
'wfCat.digitalHuman': '디지털 휴먼',
|
||||
'wfCat.motionTransfer': '모션 전이',
|
||||
'md.title': '워크플로 주석',
|
||||
'md.batchTitle': '일괄 주석({n}건)',
|
||||
'md.batchHint': '입력한 필드만 적용됩니다. 이름은 찾기→바꾸기로 변경, 태그는 추가됩니다.',
|
||||
'md.name': '표시 이름',
|
||||
'md.nameFind': '찾기',
|
||||
'md.nameFindPh': '바꿀 문자…',
|
||||
'md.nameReplace': '바꾸기',
|
||||
'md.nameReplacePh': '대체 문자…',
|
||||
'md.nameHint': '「찾기」를 비우면 이름이 유지됩니다.',
|
||||
'md.description': '설명',
|
||||
'md.descriptionPh': '이 워크플로가 하는 일을 한 줄로…',
|
||||
'md.tags': '태그',
|
||||
'md.tagsPh': '예: 빠름, 고품질',
|
||||
'md.tagsHint': '쉼표로 구분. 일괄 모드에서는 추가됩니다.',
|
||||
'md.category': '분류',
|
||||
'md.modelFamily': '모델 계열',
|
||||
'md.apply': '적용',
|
||||
'md.applied': '{n}건 업데이트',
|
||||
'md.appliedOne': '업데이트됨',
|
||||
'md.partial': '{n}건 성공, {m}건 실패',
|
||||
'wiz.title': '워크플로 일괄 가져오기',
|
||||
'wiz.stepSource': '원본',
|
||||
'wiz.stepReport': '사전 검사',
|
||||
'wiz.stepResult': '결과',
|
||||
'wiz.sourceLabel': '가져오기 원본',
|
||||
'wiz.modeScan': '서버 디렉터리 스캔',
|
||||
'wiz.modeFiles': '로컬 파일 선택',
|
||||
'wiz.rootLabel': '서버 디렉터리',
|
||||
'wiz.rootHint': '서버의 디렉터리 경로(예: 저장소의 example_ComfyUI_workflows). UI 형식 JSON을 재귀적으로 스캔하며 API 형식 등은 건너뜁니다.',
|
||||
'wiz.preflight': '실제 ComfyUI 사전 검사(선택)',
|
||||
'wiz.preflightNone': '건너뛰기(오프라인 변환)',
|
||||
'wiz.preflightHint': '연결을 선택하면 변환 결과를 해당 ComfyUI의 노드·모델과 대조합니다.',
|
||||
'wiz.pickFiles': '로컬 파일',
|
||||
'wiz.pickFilesBtn': 'JSON 파일 선택(여러 개)',
|
||||
'wiz.pickFilesHint': 'ComfyUI UI 형식 워크플로 JSON을 선택하세요. 다중 선택이 가능합니다.',
|
||||
'wiz.parsed': '{n}개 파일 읽음',
|
||||
'wiz.notUi': '{n}개 파일이 UI 형식이 아니어서(nodes 배열 없음) 건너뛰었습니다',
|
||||
'wiz.noFiles': '먼저 가져올 파일을 선택하세요',
|
||||
'wiz.runDry': '사전 검사 실행(저장 안 함)',
|
||||
'wiz.running': '검사 중…',
|
||||
'wiz.total': '총 {n}개 파일',
|
||||
'wiz.ok': '정상 {n}',
|
||||
'wiz.warn': '경고 {n}',
|
||||
'wiz.fail': '실패 {n}',
|
||||
'wiz.confirmCount': '확인 필요 {n}',
|
||||
'wiz.importable': '가져오기 가능 {n}',
|
||||
'wiz.reportHint': '사전 검사는 아무것도 저장하지 않습니다. 경고 항목은 용도를 지정하면 활성화 상태로 가져오며, 확인 필요·실패 항목은 절대 가져오지 않습니다.',
|
||||
'wiz.filterLabel': '보고서 필터',
|
||||
'wiz.filterWarn': '경고만',
|
||||
'wiz.filterBlocked': '가져올 수 없음',
|
||||
'wiz.searchPh': '파일 이름 검색…',
|
||||
'wiz.includeAria': '{name} 가져오기',
|
||||
'wiz.skipReason': '가져올 수 없음: {reason}',
|
||||
'wiz.issues': '변환 메모 {n}건',
|
||||
'wiz.usagePick': '용도:',
|
||||
'wiz.usageNone': '지금은 건너뜀(가져온 뒤 확인 필요)',
|
||||
'wiz.selectedN': '{n}개 파일을 가져옵니다',
|
||||
'wiz.excludedM': '{m}개 가져올 수 없음',
|
||||
'wiz.import': '가져오기',
|
||||
'wiz.importing': '가져오는 중…',
|
||||
'wiz.back': '이전',
|
||||
'wiz.imported': '{n}건 가져옴',
|
||||
'wiz.skipped': '{n}건 건너뜀',
|
||||
'wiz.conflict': '이미 있음(중복 자동 건너뜀)',
|
||||
'wiz.usageApplied': '새로 가져온 {n}건에 용도 지정',
|
||||
'wiz.done': '완료',
|
||||
'wiz.again': '다시 가져오기',
|
||||
'wiz.resultNote': '내용이 같은 파일은 자동으로 건너뛰며, 다시 실행해도 중복이 생기지 않습니다.',
|
||||
'wiz.importedToast': '워크플로 {n}건을 가져왔습니다',
|
||||
'wiz.warnTag': '경고',
|
||||
'wiz.confirmTag': '확인 필요',
|
||||
'wiz.preflightTag': '사전 검사 실패',
|
||||
'wiz.importedTag': '가져옴 #{id}',
|
||||
};
|
||||
|
||||
@@ -1900,8 +1900,8 @@ export const zh: Dict = {
|
||||
'home.stageProgress': '五段进度',
|
||||
'tasks.sourceWorkbench': '创作流程',
|
||||
'jobst.idSuffix': ' · ComfyUI 任务 ID {id}',
|
||||
'jobst.done': '完成',
|
||||
'jobst.doneDetail': 'ComfyUI 已执行完成,结果已保存',
|
||||
'jobst.done': '定影',
|
||||
'jobst.doneDetail': '生成完成,结果已定影保存',
|
||||
'jobst.dryRun': '占位结果',
|
||||
'jobst.dryRunDetail': '占位结果:当前开启了 Dry-run,没有调用 ComfyUI,不是真实生成',
|
||||
'jobst.noOutput': '无产出',
|
||||
@@ -2095,4 +2095,136 @@ export const zh: Dict = {
|
||||
'wfd.nodesPickSuggest': '请在下方选择用途并完成输出映射后再保存。',
|
||||
'wfd.showRaw': '查看原始 JSON',
|
||||
'wfd.hideRaw': '收起原始 JSON',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': '批量导入 UI 格式工作流,搜索筛选与标注管理;完整 JSON 只在试运行等操作时按需加载',
|
||||
'wl.batchImport': '批量导入',
|
||||
'wl.dedupe': '去重',
|
||||
'wl.dedupeTitle': '清理重复工作流',
|
||||
'wl.dedupeConfirm': '将删除名称与内容完全相同的重复条目(保留最早导入的一条)。同名但内容不同的是你的变体,不会被动。确定执行?',
|
||||
'wl.dedupeDone': '已删除 {n} 个重复工作流',
|
||||
'wl.dedupeNone': '没有发现重复工作流',
|
||||
'wl.favCount': '{n} 个收藏',
|
||||
'wl.searchPh': '搜索名称、说明、标签、模型…(按 / 聚焦)',
|
||||
'wl.filterLabel': '筛选',
|
||||
'wl.filterAll': '全部',
|
||||
'wl.filterFav': '收藏',
|
||||
'wl.filterPending': '待确认',
|
||||
'wl.filterDisabled': '已停用',
|
||||
'wl.favAria': '收藏',
|
||||
'wl.unfavAria': '取消收藏',
|
||||
'wl.categoryLabel': '分类',
|
||||
'wl.categoryAll': '全部分类',
|
||||
'wl.familyLabel': '模型家族',
|
||||
'wl.familyAll': '全部模型',
|
||||
'wl.mediaLabel': '媒介',
|
||||
'wl.mediaAll': '全部媒介',
|
||||
'wl.mediaImage': '图像',
|
||||
'wl.mediaVideo': '视频',
|
||||
'wl.mediaAudio': '音频',
|
||||
'wl.mediaIn': '输入',
|
||||
'wl.mediaOut': '输出',
|
||||
'wl.sortLabel': '排序',
|
||||
'wl.sortRecent': '收藏 / 最近使用',
|
||||
'wl.sortName': '名称',
|
||||
'wl.resetFilters': '清除筛选',
|
||||
'wl.noMatch': '没有符合条件的工作流',
|
||||
'wl.resultCount': '匹配 {n} / {m}',
|
||||
'wl.selected': '已选 {n} 项',
|
||||
'wl.batchLabel': '批量操作',
|
||||
'wl.batchAnnotate': '批量标注',
|
||||
'wl.batchEnable': '启用',
|
||||
'wl.batchDisable': '停用',
|
||||
'wl.clearSel': '清除选择',
|
||||
'wl.batchDone': '已更新 {n} 项',
|
||||
'wl.batchPartial': '{n} 项成功,{m} 项失败(需先确认用途与输出映射)',
|
||||
'wl.selectAll': '选择当前列出的全部工作流',
|
||||
'wl.rowCheckAria': '选择 {name}',
|
||||
'wl.headMeta': '标注',
|
||||
'wl.headActions': '操作',
|
||||
'wl.enableAria': '启用或停用 {name}',
|
||||
'wl.enableBlocked': '需先确认用途并指定输出节点,才能启用',
|
||||
'wl.editMeta': '编辑标注',
|
||||
'wl.lastUsed': '最近使用',
|
||||
'wl.moreCount': '再显示 {n} 项(共 {m} 项)',
|
||||
'wfCat.basic': '基础处理',
|
||||
'wfCat.audioTts': '语音生成',
|
||||
'wfCat.music': '音乐生成',
|
||||
'wfCat.image': '图像生成',
|
||||
'wfCat.video': '视频生成',
|
||||
'wfCat.digitalHuman': '数字人',
|
||||
'wfCat.motionTransfer': '动作迁移',
|
||||
'md.title': '标注工作流',
|
||||
'md.batchTitle': '批量标注({n} 项)',
|
||||
'md.batchHint': '只有填写了的字段会被应用;显示名按「查找→替换」批量重命名,标签为追加。',
|
||||
'md.name': '显示名',
|
||||
'md.nameFind': '查找',
|
||||
'md.nameFindPh': '要替换的文字…',
|
||||
'md.nameReplace': '替换为',
|
||||
'md.nameReplacePh': '替换后的文字…',
|
||||
'md.nameHint': '留空「查找」则不改名。',
|
||||
'md.description': '说明',
|
||||
'md.descriptionPh': '一句话说明这个工作流做什么…',
|
||||
'md.tags': '标签',
|
||||
'md.tagsPh': '如:快速, 高质量',
|
||||
'md.tagsHint': '用逗号分隔;批量模式下为追加标签。',
|
||||
'md.category': '分类',
|
||||
'md.modelFamily': '模型家族',
|
||||
'md.apply': '应用',
|
||||
'md.applied': '已更新 {n} 项',
|
||||
'md.appliedOne': '已更新',
|
||||
'md.partial': '{n} 项成功,{m} 项失败',
|
||||
'wiz.title': '批量导入工作流',
|
||||
'wiz.stepSource': '选择来源',
|
||||
'wiz.stepReport': '预检报告',
|
||||
'wiz.stepResult': '导入结果',
|
||||
'wiz.sourceLabel': '导入来源',
|
||||
'wiz.modeScan': '扫描服务器目录',
|
||||
'wiz.modeFiles': '选择本地文件',
|
||||
'wiz.rootLabel': '服务器目录',
|
||||
'wiz.rootHint': '服务器上的目录路径(如仓库的 example_ComfyUI_workflows),将递归扫描其中的 ComfyUI 界面格式 JSON;API 格式与其他 JSON 自动跳过。',
|
||||
'wiz.preflight': '对真实 ComfyUI 预检(可选)',
|
||||
'wiz.preflightNone': '跳过(离线转换)',
|
||||
'wiz.preflightHint': '选择连接后,转换结果会对照该 ComfyUI 的节点与模型做对象级检查。',
|
||||
'wiz.pickFiles': '本地文件',
|
||||
'wiz.pickFilesBtn': '选择 JSON 文件(可多选)',
|
||||
'wiz.pickFilesHint': '选择 ComfyUI 界面格式(UI 格式)的工作流 JSON,可一次选多个。',
|
||||
'wiz.parsed': '已读取 {n} 个文件',
|
||||
'wiz.notUi': '{n} 个文件不是 UI 格式(缺少 nodes 数组),已忽略',
|
||||
'wiz.noFiles': '请先选择要导入的文件',
|
||||
'wiz.runDry': '开始预检(不写入)',
|
||||
'wiz.running': '预检中…',
|
||||
'wiz.total': '共 {n} 个文件',
|
||||
'wiz.ok': '正常 {n}',
|
||||
'wiz.warn': '警告 {n}',
|
||||
'wiz.fail': '失败 {n}',
|
||||
'wiz.confirmCount': '需人工确认 {n}',
|
||||
'wiz.importable': '可导入 {n}',
|
||||
'wiz.reportHint': '预检不写入数据库。warn 项可补充用途后以启用状态导入;「需人工确认」与转换失败项不会被导入,也不会被标为可运行。',
|
||||
'wiz.filterLabel': '报告筛选',
|
||||
'wiz.filterWarn': '仅看警告',
|
||||
'wiz.filterBlocked': '不可导入',
|
||||
'wiz.searchPh': '搜索文件名…',
|
||||
'wiz.includeAria': '导入 {name}',
|
||||
'wiz.skipReason': '不可导入:{reason}',
|
||||
'wiz.issues': '{n} 条转换提示',
|
||||
'wiz.usagePick': '补充用途:',
|
||||
'wiz.usageNone': '暂不补充(导入后待确认)',
|
||||
'wiz.selectedN': '将导入 {n} 个文件',
|
||||
'wiz.excludedM': '{m} 个不可导入',
|
||||
'wiz.import': '确认导入',
|
||||
'wiz.importing': '导入中…',
|
||||
'wiz.back': '上一步',
|
||||
'wiz.imported': '导入成功 {n}',
|
||||
'wiz.skipped': '跳过 {n}',
|
||||
'wiz.conflict': '已存在(重复导入自动跳过)',
|
||||
'wiz.usageApplied': '已为 {n} 个新导入项补充用途',
|
||||
'wiz.done': '完成',
|
||||
'wiz.again': '再导一批',
|
||||
'wiz.resultNote': '内容相同的重复文件会被自动跳过,重复执行不会产生重复条目。',
|
||||
'wiz.importedToast': '已导入 {n} 个工作流',
|
||||
'wiz.warnTag': '警告',
|
||||
'wiz.confirmTag': '需人工确认',
|
||||
'wiz.preflightTag': '预检未通过',
|
||||
'wiz.importedTag': '已导入 #{id}',
|
||||
};
|
||||
|
||||
@@ -589,6 +589,11 @@
|
||||
function avatarStyle(n: number): string {
|
||||
return `var(--avatar-${Math.min(Math.max(n, 1), 5)})`;
|
||||
}
|
||||
|
||||
// HEL-680 灰阶头像:1–2 亮阶配深字,3–5 暗阶配浅字,按阶数决定。
|
||||
function avatarLight(n: number | undefined): boolean {
|
||||
return Math.min(Math.max(n ?? 1, 1), 5) <= 2;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet skillRow(skill: SkillAdminEntry, detail: string)}
|
||||
@@ -655,7 +660,7 @@
|
||||
class:selected
|
||||
onclick={() => selectAgent(agent.key)}
|
||||
>
|
||||
<span class="avatar" class:dark={agent.avatar_dark} style:background={avatarStyle(agent.avatar)}>
|
||||
<span class="avatar" class:dark={avatarLight(agent.avatar)} style:background={avatarStyle(agent.avatar)}>
|
||||
{agent.char}
|
||||
</span>
|
||||
<span class="agent-meta">
|
||||
@@ -693,7 +698,7 @@
|
||||
<section class="mid">
|
||||
{#if activeAgent}
|
||||
<header class="chat-head">
|
||||
<span class="avatar lg" class:dark={activeAgent.avatar_dark} style:background={avatarStyle(activeAgent.avatar)}>
|
||||
<span class="avatar lg" class:dark={avatarLight(activeAgent.avatar)} style:background={avatarStyle(activeAgent.avatar)}>
|
||||
{activeAgent.char}
|
||||
</span>
|
||||
<div class="chat-head-meta">
|
||||
|
||||
+181
-59
@@ -4,62 +4,67 @@
|
||||
|
||||
:root {
|
||||
/* ------------------------------------------------------------------
|
||||
* 融合视觉 design tokens — HEL-649 整站施工规范 §一(流水台结构 ×
|
||||
* 放映厅配色)。Single source of truth for the whole app: pages and
|
||||
* components reference these variables only — no hardcoded colors,
|
||||
* no ad-hoc overrides. Gold is for key actions and the current
|
||||
* selection only; status colors each carry exactly one meaning.
|
||||
* 无彩灰阶 design tokens — HEL-680 S1(白栖知 theme-1「暗房放大机」
|
||||
* × HEL-679 已确认融合样稿)。Single source of truth for the whole
|
||||
* app: pages and components reference these variables only — no
|
||||
* hardcoded colors, no ad-hoc overrides. 界面只有暖调黑白灰,作品是
|
||||
* 屏幕上唯一成片的颜色;状态色仅按主题卡的两处有限例外(琥珀
|
||||
* #A3915F、红 #B3564A)使用,不放大成全站主色。选中=取景框角线,
|
||||
* 完成=「定影」,结果=相纸白边。
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/* 底色:蓝黑四阶,逐层浮起 (§1.1) */
|
||||
--bg: #07090e;
|
||||
--bg-2: #0c0f16;
|
||||
--bg-3: #121724;
|
||||
--bg-4: #1a2133;
|
||||
--line: #1b2233;
|
||||
--line-2: #2a3450;
|
||||
--page-glow: radial-gradient(1200px 500px at 70% -10%, rgba(38, 52, 92, 0.25), transparent 60%);
|
||||
--float-bg: linear-gradient(180deg, #10152a, var(--bg-2));
|
||||
--overlay: rgba(4, 6, 10, 0.6);
|
||||
--media-scrim: rgba(7, 9, 14, 0.65);
|
||||
/* 底色:显影灰阶四阶,逐层浮起(theme-1:#212120 底 / #2C2C2A 面板) */
|
||||
--bg: #212120;
|
||||
--bg-2: #262625;
|
||||
--bg-3: #2c2c2a;
|
||||
--bg-4: #383835;
|
||||
--line: #313130;
|
||||
--line-2: #454542;
|
||||
--page-glow: radial-gradient(1200px 500px at 70% -10%, rgba(245, 244, 238, 0.035), transparent 60%);
|
||||
--float-bg: linear-gradient(180deg, #2e2e2c, var(--bg-2));
|
||||
--overlay: rgba(18, 18, 17, 0.62);
|
||||
--media-scrim: rgba(18, 18, 17, 0.66);
|
||||
--on-media: #fff;
|
||||
--on-media-line: rgba(255, 255, 255, 0.05);
|
||||
--composer-bg: linear-gradient(180deg, #0e1220, var(--bg-2));
|
||||
--composer-focus: 0 0 0 1px rgba(242, 199, 132, 0.12), 0 8px 28px rgba(0, 0, 0, 0.4);
|
||||
--composer-bg: linear-gradient(180deg, #2a2a28, var(--bg-2));
|
||||
--composer-focus: 0 0 0 1px rgba(245, 244, 238, 0.12), 0 8px 28px rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* 放映金:主按钮、当前选中、改过的参数、聚焦框、Toast 动作 (§1.2) */
|
||||
--accent: #f2c784;
|
||||
--accent-ink: #23170a;
|
||||
--accent-soft: rgba(242, 199, 132, 0.13);
|
||||
--accent-hi: #f7d9a2;
|
||||
--accent-lo: #ecaf63;
|
||||
--accent-glow: rgba(236, 175, 99, 0.28);
|
||||
--accent-line: rgba(242, 199, 132, 0.55);
|
||||
/* 相纸白(重点色):主按钮、当前选中、改过的参数、聚焦框、Toast 动作
|
||||
(theme-1 #F5F4EE 重点) */
|
||||
--accent: #f5f4ee;
|
||||
--accent-ink: #212120;
|
||||
--accent-soft: rgba(245, 244, 238, 0.08);
|
||||
--accent-hi: #ffffff;
|
||||
--accent-lo: #ddddd7;
|
||||
--accent-glow: rgba(240, 239, 233, 0.2);
|
||||
--accent-line: rgba(245, 244, 238, 0.55);
|
||||
--accent-grad: linear-gradient(180deg, var(--accent-hi), var(--accent-lo));
|
||||
--accent-grad-hover: linear-gradient(180deg, #fbe3b6, #f0b968);
|
||||
--accent-ring: 0 0 0 1px rgba(242, 199, 132, 0.7), 0 0 22px var(--accent-glow);
|
||||
--btn-glow: 0 2px 14px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
--btn-glow-hover: 0 4px 18px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
--accent-wash: linear-gradient(180deg, rgba(242, 199, 132, 0.05), transparent 40%);
|
||||
--sel-shadow: 0 0 0 1px rgba(242, 199, 132, 0.15), 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
--accent-grad-hover: linear-gradient(180deg, #ffffff, #e8e7df);
|
||||
--accent-ring: 0 0 0 1px rgba(245, 244, 238, 0.7), 0 0 22px var(--accent-glow);
|
||||
--btn-glow: 0 2px 10px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||
--btn-glow-hover: 0 3px 14px rgba(0, 0, 0, 0.44), inset 0 1px 0 rgba(255, 255, 255, 0.65);
|
||||
--accent-wash: linear-gradient(180deg, rgba(245, 244, 238, 0.05), transparent 40%);
|
||||
--sel-shadow: 0 0 0 1px rgba(245, 244, 238, 0.14), 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
|
||||
/* 信号色:绿=完成/已确认/通过,青=进行中/在线,红=失败/危险,
|
||||
琥珀=待确认/文件缺失/限额 (§1.3) */
|
||||
--ok: #49cb90;
|
||||
--ok-soft: rgba(73, 203, 144, 0.13);
|
||||
--run: #54dfd2;
|
||||
--run-soft: rgba(84, 223, 210, 0.13);
|
||||
--run-grad: linear-gradient(90deg, #3bbfb4, var(--run));
|
||||
--err: #f0645a;
|
||||
--err-soft: rgba(240, 100, 90, 0.13);
|
||||
--err-line: rgba(240, 100, 90, 0.6);
|
||||
--warn: #f0b45c;
|
||||
--warn-soft: rgba(240, 180, 92, 0.13);
|
||||
/* 信号色:完成=显影亮灰(「定影」,配图标/文字双编码),进行中=灰阶
|
||||
+动效;红/琥珀只按 theme-1 卡片的有限例外使用(红=失败/危险
|
||||
#B3564A 系,琥珀=待确认/文件缺失 #A3915F) */
|
||||
--ok: #ddddd7;
|
||||
--ok-soft: rgba(221, 221, 215, 0.1);
|
||||
--run: #b8b6ac;
|
||||
--run-soft: rgba(184, 182, 172, 0.12);
|
||||
--run-grad: linear-gradient(90deg, #8f8e85, #c6c5c0);
|
||||
--err: #d07a6b;
|
||||
--err-solid: #b3564a;
|
||||
--err-soft: rgba(181, 86, 74, 0.15);
|
||||
--err-line: rgba(208, 122, 107, 0.55);
|
||||
--warn: #a3915f;
|
||||
--warn-soft: rgba(163, 145, 95, 0.13);
|
||||
|
||||
/* 文字与字体 (§1.4);--text-2 是深底可读性下限,不得再暗 */
|
||||
--text: #f4f6fa;
|
||||
--text-2: #aab2c5;
|
||||
--text-3: #6d7690;
|
||||
/* 文字与字体;--text-3 是深底可读性下限(≥4.5:1 on --bg-3),不得再暗 */
|
||||
--text: #f0efe9;
|
||||
--text-2: #a8a69c;
|
||||
--text-3: #94938c;
|
||||
--font: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
--mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
--fs-title: 15px;
|
||||
@@ -95,8 +100,14 @@
|
||||
/* 专属符号 (§1.7):齿孔只在记录流/输入台分隔带,光晕只在播放舞台 */
|
||||
--sprocket: radial-gradient(circle 2px, var(--line-2) 98%, transparent);
|
||||
--stage-bg:
|
||||
radial-gradient(60% 45% at 50% 38%, rgba(242, 199, 132, 0.07), transparent 70%),
|
||||
radial-gradient(80% 70% at 50% 50%, #0d1220, #05070c);
|
||||
radial-gradient(60% 45% at 50% 38%, rgba(245, 244, 238, 0.045), transparent 70%),
|
||||
radial-gradient(80% 70% at 50% 50%, #232322, #171716);
|
||||
|
||||
/* 取景框角线与相纸白边 (HEL-680):内容级选中/结果呈现的统一语言 */
|
||||
--frame-w: 2px;
|
||||
--frame-arm: 12px;
|
||||
--frame-inset: -4px;
|
||||
--paper-w: 4px;
|
||||
|
||||
/* 动效 (§1.6,取 HEL-648 实测值);只动 transform / opacity */
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
@@ -113,13 +124,13 @@
|
||||
--d-progress: 400ms;
|
||||
--press-scale: 0.97;
|
||||
|
||||
/* Avatar five-color set — five agents only; the one colorful gradient
|
||||
family the spec keeps (§3.6). */
|
||||
--avatar-1: linear-gradient(135deg, #e8a33d, #b96a1b);
|
||||
--avatar-2: linear-gradient(135deg, #5b8def, #3a5fa9);
|
||||
--avatar-3: linear-gradient(135deg, #3fb97f, #2a7a54);
|
||||
--avatar-4: linear-gradient(135deg, #b98be0, #7a4fa9);
|
||||
--avatar-5: linear-gradient(135deg, #d98e6a, #a95535);
|
||||
/* Avatar five-step grayscale (HEL-680 无彩化):1–2 亮阶配深字,
|
||||
3–5 暗阶配浅字;仍是五个身份位,只是从彩色渐变改为显影灰阶。 */
|
||||
--avatar-1: linear-gradient(135deg, #e8e7df, #cdcbc4);
|
||||
--avatar-2: linear-gradient(135deg, #b5b3aa, #9c9a91);
|
||||
--avatar-3: linear-gradient(135deg, #6b6a66, #55544f);
|
||||
--avatar-4: linear-gradient(135deg, #55544f, #424140);
|
||||
--avatar-5: linear-gradient(135deg, #424140, #2e2e2c);
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Legacy token names (§六 映射表) — aliases only, never new values,
|
||||
@@ -297,7 +308,7 @@ code,
|
||||
color: var(--err);
|
||||
}
|
||||
.a-btn.danger-solid {
|
||||
background: var(--err);
|
||||
background: var(--err-solid);
|
||||
color: var(--on-media);
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
@@ -550,11 +561,47 @@ button.status-chip:active {
|
||||
.a-chip.changed {
|
||||
color: var(--accent);
|
||||
}
|
||||
/* Selected filter chip (§3.5 筛选芯片组): 当前选中 = 金色文字 + accent-soft 底 */
|
||||
/* Selected filter chip (§3.5 筛选芯片组): 当前选中 = 取景框角线缩影 +
|
||||
相纸白文字 + accent-soft 底 (HEL-680) */
|
||||
.a-chip.sel {
|
||||
position: relative;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.a-chip.sel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0);
|
||||
background-position:
|
||||
left top,
|
||||
left top,
|
||||
right top,
|
||||
right top,
|
||||
left bottom,
|
||||
left bottom,
|
||||
right bottom,
|
||||
right bottom;
|
||||
background-size:
|
||||
6px 1.5px,
|
||||
1.5px 6px,
|
||||
6px 1.5px,
|
||||
1.5px 6px,
|
||||
6px 1.5px,
|
||||
1.5px 6px,
|
||||
6px 1.5px,
|
||||
1.5px 6px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.a-chip.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
@@ -753,6 +800,81 @@ button.a-tile:disabled {
|
||||
box-shadow: 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 取景框角线选中 (HEL-680):内容级选中态的唯一语言——四角短线取景,
|
||||
* 不整框描边。挂在需要角线的元素上(元素自身可 overflow 可见;被
|
||||
* overflow:hidden 裁切的容器改用 --frame-inset 正值画在内侧)。
|
||||
* ------------------------------------------------------------------ */
|
||||
.a-frame {
|
||||
position: relative;
|
||||
}
|
||||
.a-frame::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: var(--frame-inset, -4px);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0);
|
||||
background-position:
|
||||
left top,
|
||||
left top,
|
||||
right top,
|
||||
right top,
|
||||
left bottom,
|
||||
left bottom,
|
||||
right bottom,
|
||||
right bottom;
|
||||
background-size:
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/* 相纸白边 (HEL-680):结果媒体外的相纸白框,底边略厚如手工相纸。
|
||||
挂在承载 img/video 的媒体容器上;容器尺寸由调用方决定。 */
|
||||
.a-photo {
|
||||
background: var(--accent);
|
||||
border: var(--paper-w, 4px) solid var(--accent);
|
||||
border-bottom-width: calc(var(--paper-w, 4px) + 2px);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 「定影」完成章 (HEL-680):已定影结果右上角的完成印记,随 jobst.done
|
||||
文案走(中文=定影)。absolute 定位,挂在有媒体的结果容器内。 */
|
||||
.a-fix {
|
||||
position: absolute;
|
||||
top: var(--sp-3);
|
||||
right: var(--sp-3);
|
||||
z-index: 2;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 3px;
|
||||
background: var(--media-scrim);
|
||||
color: var(--ok);
|
||||
font-size: var(--fs-mini);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-indent: 0.1em;
|
||||
transform: rotate(-4deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Floating surface for pop / drawer / confirm / toast / lightbox (§四.3). */
|
||||
.a-float {
|
||||
background: var(--float-bg);
|
||||
|
||||
@@ -7,10 +7,8 @@
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import WorkflowImportDrawer from '$lib/components/WorkflowImportDrawer.svelte';
|
||||
import WorkflowTestDialog from '$lib/components/WorkflowTestDialog.svelte';
|
||||
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowOutputMapEntry, type WorkflowUsage, type WorkflowConnectionCheck } from '$lib/api';
|
||||
import { WORKFLOW_USAGES } from '$lib/comfy/types';
|
||||
import WorkflowLibrary from '$lib/components/workflows/WorkflowLibrary.svelte';
|
||||
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type WorkflowMeta } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { classifyEndpoint, endpointTypeLabel, type EndpointKind } from '$lib/endpointKind';
|
||||
@@ -25,12 +23,17 @@
|
||||
queryKey: ['settings'],
|
||||
queryFn: settings.get,
|
||||
});
|
||||
const workflowsQuery = createQuery({
|
||||
queryKey: ['workflows'],
|
||||
queryFn: workflows.list,
|
||||
// HEL-682: the library browses metadata only (view=meta) — the heavy
|
||||
// workflow_json payloads are fetched per-row inside WorkflowLibrary
|
||||
// exactly when an action needs them.
|
||||
const wfMetaQuery = createQuery({
|
||||
queryKey: ['workflows', 'meta'],
|
||||
queryFn: workflows.listMeta,
|
||||
});
|
||||
|
||||
const endpoints = $derived($settingsQuery.data?.comfyui_endpoints ?? []);
|
||||
const endpoints = $derived(
|
||||
$settingsQuery.data?.comfyui_endpoints ?? [],
|
||||
);
|
||||
const activeId = $derived(
|
||||
$settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? '',
|
||||
);
|
||||
@@ -281,7 +284,7 @@
|
||||
}
|
||||
|
||||
function workflowsBound(id: string): number {
|
||||
return ($workflowsQuery.data ?? []).filter((wf) => wf.endpoint_id === id).length;
|
||||
return ($wfMetaQuery.data ?? []).filter((wf) => wf.endpoint_id === id).length;
|
||||
}
|
||||
|
||||
const deleteMessage = $derived.by(() => {
|
||||
@@ -327,188 +330,6 @@
|
||||
},
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Workflow library (binding + per-row recheck / test). The import
|
||||
// wizard itself lives in WorkflowImportDrawer (HEL-655 E 批).
|
||||
// ------------------------------------------------------------------
|
||||
let wfImportOpen = $state(false);
|
||||
|
||||
function startWfImport() {
|
||||
wfImportOpen = true;
|
||||
}
|
||||
|
||||
function usageLabel(u: WorkflowUsage | null | undefined): string {
|
||||
return u ? t(`usage.${u}`) : t('usage.pending');
|
||||
}
|
||||
|
||||
function wfProblems(wf: Workflow): string[] {
|
||||
return (wf.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message);
|
||||
}
|
||||
|
||||
// HEL-643 L2: per-row 重新检查 / 测试 in the workflow library.
|
||||
let wfRecheckId = $state<number | null>(null);
|
||||
const wfRecheckResult = $state<Record<number, WorkflowConnectionCheck>>({});
|
||||
|
||||
async function recheckRow(wf: Workflow) {
|
||||
wfRecheckId = wf.id;
|
||||
try {
|
||||
const res = await workflows.check(wf.id, { refresh: true });
|
||||
wfRecheckResult[wf.id] = res;
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
wfRecheckId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function recheckLine(wf: Workflow): { tone: 'ok' | 'bad' | 'warn'; text: string } | null {
|
||||
const check = wfRecheckResult[wf.id];
|
||||
if (!check) return null;
|
||||
if (!check.reachable) return { tone: 'warn', text: t('conn.wfCheckUnreachable') };
|
||||
if (check.ok) return { tone: 'ok', text: t('conn.wfCheckOk') };
|
||||
const n = check.findings.length;
|
||||
return { tone: 'bad', text: t('conn.wfCheckProblems', { n }) };
|
||||
}
|
||||
|
||||
let testWf = $state<Workflow | null>(null);
|
||||
let testOpen = $state(false);
|
||||
|
||||
function openTest(wf: Workflow) {
|
||||
testWf = wf;
|
||||
testOpen = true;
|
||||
}
|
||||
|
||||
function lastTestLine(wf: Workflow): string | null {
|
||||
const lt = wf.last_test;
|
||||
if (!lt) return null;
|
||||
if (lt.status === 'done' && lt.output_count > 0) {
|
||||
return t('conn.wfTestPassed', { n: lt.output_count, time: formatDbTime(lt.completed_at ?? lt.created_at) });
|
||||
}
|
||||
if (lt.status === 'pending' || lt.status === 'running') {
|
||||
return t('conn.wfTestRunning', { time: formatDbTime(lt.created_at) });
|
||||
}
|
||||
return t('conn.wfTestFailed', { time: formatDbTime(lt.completed_at ?? lt.created_at) });
|
||||
}
|
||||
|
||||
function formatDbTime(at: string | null): string {
|
||||
if (!at) return '—';
|
||||
const ts = Date.parse(at.replace(' ', 'T') + 'Z');
|
||||
const d = new Date(Number.isFinite(ts) ? ts : at);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const wfConfirmMutation = createMutation({
|
||||
mutationFn: ({ id, usage }: { id: number; usage: WorkflowUsage }) =>
|
||||
workflows.update(id, { usage, usage_source: 'user' }),
|
||||
onSuccess: (wf) => {
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(t('conn.wfConfirmed', { usage: usageLabel(wf.usage) }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
t('conn.wfImportFailed', {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// HEL-638: per-row output-node designation for pending workflows with
|
||||
// unknown custom output nodes (imported earlier or parked by migration).
|
||||
// ------------------------------------------------------------------
|
||||
let wfMapEditId = $state<number | null>(null);
|
||||
let wfMapPicked = $state<Record<string, boolean>>({});
|
||||
let wfMapKind = $state<Record<string, 'image' | 'video'>>({});
|
||||
|
||||
function rowOutputCandidates(wf: Workflow) {
|
||||
return (wf.output_schema ?? []).filter((o) => o.evidence === 'unknown_node');
|
||||
}
|
||||
|
||||
function rowNeedsOutputMap(wf: Workflow): boolean {
|
||||
return Boolean(wf.needs_mapping) && !(wf.output_map ?? []).length;
|
||||
}
|
||||
|
||||
function toggleWfMapEdit(wf: Workflow) {
|
||||
if (wfMapEditId === wf.id) {
|
||||
wfMapEditId = null;
|
||||
return;
|
||||
}
|
||||
const picked: Record<string, boolean> = {};
|
||||
const kinds: Record<string, 'image' | 'video'> = {};
|
||||
const mapped = new Map((wf.output_map ?? []).map((e) => [e.nodeId, e.kind]));
|
||||
for (const c of rowOutputCandidates(wf)) {
|
||||
const known = mapped.get(c.nodeId);
|
||||
if (known) picked[c.nodeId] = true;
|
||||
kinds[c.nodeId] = known ?? c.suggested_kind ?? 'image';
|
||||
}
|
||||
wfMapPicked = picked;
|
||||
wfMapKind = kinds;
|
||||
wfMapEditId = wf.id;
|
||||
}
|
||||
|
||||
const wfMapMutation = createMutation({
|
||||
mutationFn: ({ id, outputMap }: { id: number; outputMap: WorkflowOutputMapEntry[] }) =>
|
||||
workflows.update(
|
||||
id,
|
||||
outputMap.length
|
||||
? { output_map: outputMap, output_map_source: 'user' }
|
||||
: { output_map: [] },
|
||||
),
|
||||
onSuccess: (wf) => {
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
wfMapEditId = null;
|
||||
const nodes = (wf.output_map ?? []).map((e) => e.nodeId).join('、');
|
||||
toast.success(
|
||||
wf.output_map?.length
|
||||
? t('conn.wfOutputApplied', { nodes })
|
||||
: t('conn.wfOutputCleared'),
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
t('conn.wfImportFailed', {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function submitWfMap(wf: Workflow) {
|
||||
if ($wfMapMutation.isPending) return;
|
||||
const outputMap = rowOutputCandidates(wf)
|
||||
.filter((c) => wfMapPicked[c.nodeId])
|
||||
.map((c) => ({
|
||||
nodeId: c.nodeId,
|
||||
kind: wfMapKind[c.nodeId] ?? c.suggested_kind ?? 'image',
|
||||
label: c.label,
|
||||
...(c.class_type ? { class_type: c.class_type } : {}),
|
||||
}));
|
||||
$wfMapMutation.mutate({ id: wf.id, outputMap });
|
||||
}
|
||||
|
||||
const wfBindMutation = createMutation({
|
||||
mutationFn: ({ id, endpointId }: { id: number; endpointId: string | null }) =>
|
||||
workflows.update(id, { endpoint_id: endpointId }),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
},
|
||||
});
|
||||
|
||||
const wfDeleteMutation = createMutation({
|
||||
mutationFn: (id: number) => workflows.delete(id),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(t('conn.wfDeleted'));
|
||||
},
|
||||
onError: (err) => {
|
||||
// e.g. 409 "该工作流还有 N 个排队或执行中的任务…" (HEL-619)
|
||||
toast.error(apiErrorMessage(err));
|
||||
},
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ------------------------------------------------------------------
|
||||
@@ -529,7 +350,9 @@
|
||||
// 任务使用目标 (a6): where each kind of job actually runs — a workflow's
|
||||
// own binding first, otherwise the default connection.
|
||||
function kindTarget(kind: 'image' | 'video' | null): { value: string; kind: EndpointKind | null } {
|
||||
const wfs = ($workflowsQuery.data ?? []).filter((w) => kind === null || w.kind === kind);
|
||||
const wfs: WorkflowMeta[] = ($wfMetaQuery.data ?? []).filter(
|
||||
(w) => kind === null || w.kind === kind,
|
||||
);
|
||||
const ids = new Set(wfs.map((w) => w.endpoint_id || activeId).filter(Boolean));
|
||||
if (wfs.length === 0) ids.add(activeId);
|
||||
if (ids.size === 1) {
|
||||
@@ -710,208 +533,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ---------------- workflow library ---------------- -->
|
||||
<section class="wf-panel" id="wf-library">
|
||||
<div class="panel-head">
|
||||
<h2>{t('conn.wfTitle')}</h2>
|
||||
<span class="panel-hint">{t('conn.wfHint')}</span>
|
||||
<span class="panel-actions">
|
||||
<Button variant="secondary" size="sm" onclick={startWfImport}>
|
||||
<Icon name="upload" size={13} />
|
||||
{t('conn.wfImportAction')}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if ($workflowsQuery.data ?? []).length === 0}
|
||||
<EmptyState title={t('conn.wfEmpty')} />
|
||||
<p class="missing-note">{t('conn.missingMaterial')}</p>
|
||||
{:else}
|
||||
{@const wfs = $workflowsQuery.data ?? []}
|
||||
{@const pending = wfs.filter((w) => !w.usage).length}
|
||||
<p class="wf-count">
|
||||
{t('conn.wfCount', {
|
||||
n: wfs.length,
|
||||
m: wfs.filter((w) => w.kind === 'image').length,
|
||||
k: wfs.filter((w) => w.kind === 'video').length,
|
||||
})}
|
||||
{#if pending}
|
||||
<span class="wf-pending-note">· {t('conn.wfPendingCount', { n: pending })}</span>
|
||||
{/if}
|
||||
</p>
|
||||
<div class="wf-table">
|
||||
{#each wfs as wf (wf.id)}
|
||||
<div class="wf-row" class:pending={!wf.usage}>
|
||||
<span class="wf-kind {wf.kind}">
|
||||
<Icon name={wf.kind === 'image' ? 'image' : 'film'} size={14} />
|
||||
</span>
|
||||
<span class="wf-name" title={wf.description ?? wf.name}>{wf.name}</span>
|
||||
{#if wf.usage}
|
||||
<span
|
||||
class="tag tag-usage"
|
||||
title={wf.usage_source === 'user' ? t('conn.wfSourceUser') : t('conn.wfSourceAuto')}
|
||||
>
|
||||
{usageLabel(wf.usage)}
|
||||
</span>
|
||||
{:else}
|
||||
<select
|
||||
class="a-select wf-confirm-select"
|
||||
aria-label={t('conn.wfConfirmAria')}
|
||||
disabled={$wfConfirmMutation.isPending}
|
||||
onchange={(e) => {
|
||||
const usage = e.currentTarget.value as WorkflowUsage;
|
||||
if (usage) $wfConfirmMutation.mutate({ id: wf.id, usage });
|
||||
e.currentTarget.value = '';
|
||||
}}
|
||||
>
|
||||
<option value="">{t('usage.pending')}…</option>
|
||||
{#each WORKFLOW_USAGES as u (u)}
|
||||
<option value={u}>{usageLabel(u)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
{#if rowNeedsOutputMap(wf)}
|
||||
<button
|
||||
type="button"
|
||||
class="tag tag-mapbtn"
|
||||
class:open={wfMapEditId === wf.id}
|
||||
title={t('conn.wfOutputNeedMap')}
|
||||
onclick={() => toggleWfMapEdit(wf)}
|
||||
>
|
||||
<Icon name="alert" size={12} />
|
||||
{t('conn.wfOutputPendingBadge')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !wf.is_enabled && wf.usage && !rowNeedsOutputMap(wf)}
|
||||
<span class="tag tag-disabled">{t('conn.wfDisabledBadge')}</span>
|
||||
{/if}
|
||||
{#if wfProblems(wf).length}
|
||||
<span class="tag tag-error" title={wfProblems(wf).join('\n')}>
|
||||
{t('conn.wfBrokenBadge')}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="wf-params">{t('conn.wfParams', { n: wf.input_schema?.length ?? 0 })}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link-accent wf-test-btn"
|
||||
title={t('wft.title')}
|
||||
onclick={() => openTest(wf)}
|
||||
>
|
||||
<Icon name="play" size={13} />
|
||||
{t('conn.wfTestBtn')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="link-accent wf-test-btn"
|
||||
disabled={wfRecheckId === wf.id}
|
||||
title={t('conn.wfCheckBtn')}
|
||||
onclick={() => recheckRow(wf)}
|
||||
>
|
||||
<Icon name="retry" size={13} />
|
||||
{wfRecheckId === wf.id ? t('conn.wfChecking') : t('conn.wfCheckBtn')}
|
||||
</button>
|
||||
<select
|
||||
class="a-select"
|
||||
value={wf.endpoint_id ?? ''}
|
||||
onchange={(e) =>
|
||||
$wfBindMutation.mutate({
|
||||
id: wf.id,
|
||||
endpointId: e.currentTarget.value || null,
|
||||
})}
|
||||
>
|
||||
<option value="">{t('conn.wfConnectionActive')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="link-danger"
|
||||
title={t('conn.delete')}
|
||||
onclick={() => $wfDeleteMutation.mutate(wf.id)}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{@const lastLine = lastTestLine(wf)}
|
||||
{@const recheck = recheckLine(wf)}
|
||||
{#if lastLine || recheck}
|
||||
<div class="wf-meta-row">
|
||||
{#if lastLine}<span class="wf-meta-item">{t('conn.wfLastTest')}:{lastLine}</span>{/if}
|
||||
{#if recheck}
|
||||
<span class="wf-meta-item recheck" class:ok={recheck.tone === 'ok'} class:bad={recheck.tone === 'bad'} class:warn={recheck.tone === 'warn'}>
|
||||
{#if recheck.tone === 'bad'}
|
||||
{@const check = wfRecheckResult[wf.id]}
|
||||
<span class="wf-recheck-text" title={(check?.findings ?? []).map((f) => f.message).join('\n')}>
|
||||
{recheck.text}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="wf-recheck-text">{recheck.text}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if wfMapEditId === wf.id}
|
||||
<div class="wf-map-editor">
|
||||
<p class="wf-map-hint">{t('conn.wfOutputHint')}</p>
|
||||
<div class="wf-slot-rows">
|
||||
{#each rowOutputCandidates(wf) as c (c.nodeId)}
|
||||
<div class="wf-slot-row" class:off={!wfMapPicked[c.nodeId]}>
|
||||
<label class="wf-out-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={wfMapPicked[c.nodeId] ?? false}
|
||||
onchange={(e) =>
|
||||
(wfMapPicked = {
|
||||
...wfMapPicked,
|
||||
[c.nodeId]: e.currentTarget.checked,
|
||||
})}
|
||||
/>
|
||||
<span class="wf-slot-id mono">{c.nodeId}</span>
|
||||
<span class="wf-slot-name" title={c.class_type}>{c.label}</span>
|
||||
</label>
|
||||
<select
|
||||
class="a-select"
|
||||
aria-label={t('conn.wfOutputKindAria')}
|
||||
disabled={!wfMapPicked[c.nodeId]}
|
||||
value={wfMapKind[c.nodeId] ?? c.suggested_kind ?? 'image'}
|
||||
onchange={(e) =>
|
||||
(wfMapKind = {
|
||||
...wfMapKind,
|
||||
[c.nodeId]: e.currentTarget.value as 'image' | 'video',
|
||||
})}
|
||||
>
|
||||
<option value="image">{t('conn.wfKindImage')}</option>
|
||||
<option value="video">{t('conn.wfKindVideo')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => (wfMapEditId = null)}
|
||||
>
|
||||
{t('conn.wfMapCancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!Object.values(wfMapPicked).some(Boolean)}
|
||||
loading={$wfMapMutation.isPending}
|
||||
onclick={() => submitWfMap(wf)}
|
||||
>
|
||||
{t('conn.wfOutputApply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<WorkflowLibrary rows={$wfMetaQuery.data ?? []} {endpoints} />
|
||||
</section>
|
||||
|
||||
{#if formMode !== 'idle'}
|
||||
@@ -1104,17 +726,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<WorkflowImportDrawer
|
||||
bind:open={wfImportOpen}
|
||||
{endpoints}
|
||||
onchanged={() => client.invalidateQueries({ queryKey: ['workflows'] })}
|
||||
/>
|
||||
<WorkflowTestDialog
|
||||
bind:open={testOpen}
|
||||
workflow={testWf}
|
||||
{endpoints}
|
||||
onchanged={() => client.invalidateQueries({ queryKey: ['workflows'] })}
|
||||
/>
|
||||
</AppShell>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -1376,231 +987,6 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---------------- workflow panel ---------------- */
|
||||
.wf-panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.panel-hint {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.panel-actions {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.wf-count {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.missing-note {
|
||||
margin: 10px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wf-test-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wf-test-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.wf-meta-row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 10px 8px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.wf-meta-item {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-meta-item.recheck .wf-recheck-text {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-meta-item.recheck.ok .wf-recheck-text {
|
||||
color: var(--success);
|
||||
}
|
||||
.wf-meta-item.recheck.bad .wf-recheck-text {
|
||||
color: var(--error);
|
||||
cursor: help;
|
||||
}
|
||||
.wf-meta-item.recheck.warn .wf-recheck-text {
|
||||
color: var(--warn);
|
||||
}
|
||||
.wf-slot-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.wf-slot-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 7px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.wf-slot-id {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
min-width: 64px;
|
||||
}
|
||||
.wf-slot-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wf-slot-row .a-select {
|
||||
width: 130px;
|
||||
}
|
||||
.wf-slot-row.off {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.wf-out-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.wf-out-check input[type='checkbox'] {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.tag-mapbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--error);
|
||||
background: var(--error-dim);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.tag-mapbtn.open {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.wf-map-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin: 2px 0 6px;
|
||||
border-radius: 7px;
|
||||
border: 1px dashed var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.wf-map-hint {
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-map-editor .form-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.wf-params {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.tag-usage {
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.tag-disabled {
|
||||
color: var(--text-3);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.wf-pending-note {
|
||||
color: var(--warn);
|
||||
}
|
||||
.wf-row.pending .wf-name {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-confirm-select {
|
||||
max-width: 150px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wf-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wf-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 150ms;
|
||||
}
|
||||
.wf-row:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.wf-kind {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-kind.image,
|
||||
.wf-kind.video {
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.wf-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wf-params {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-row .a-select {
|
||||
width: 220px;
|
||||
}
|
||||
/* ---------------- form column ---------------- */
|
||||
.side-col {
|
||||
width: 384px;
|
||||
@@ -1879,9 +1265,6 @@
|
||||
z-index: 790;
|
||||
background: var(--overlay);
|
||||
}
|
||||
.wf-import-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
@@ -1910,15 +1293,4 @@
|
||||
.card-foot {
|
||||
margin-left: 0;
|
||||
}
|
||||
.wf-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wf-name {
|
||||
flex-basis: 100%;
|
||||
order: 5;
|
||||
}
|
||||
.wf-row .a-select {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
}</style>
|
||||
|
||||
@@ -209,7 +209,13 @@
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each visible as item (item.path)}
|
||||
<div class="card" class:selected={selected.has(item.path)}>
|
||||
<div
|
||||
class="card"
|
||||
class:selected={selected.has(item.path)}
|
||||
class:a-frame={selected.has(item.path)}
|
||||
style:--frame-inset="3px"
|
||||
style:--frame-arm="16px"
|
||||
>
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -217,12 +223,12 @@
|
||||
onchange={() => toggle(item.path)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="media"
|
||||
onclick={() => openPreview(item)}
|
||||
title={item.name}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="media a-photo"
|
||||
onclick={() => openPreview(item)}
|
||||
title={item.name}
|
||||
>
|
||||
<SafeMedia src={assetUrl(item.path)} kind={item.kind} alt={item.name} controls={false} />
|
||||
</button>
|
||||
<div class="meta">
|
||||
@@ -341,7 +347,6 @@
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.card.selected {
|
||||
border-color: var(--accent-line);
|
||||
background: linear-gradient(180deg, var(--accent-soft), transparent 32%), var(--bg-2);
|
||||
}
|
||||
.card :global(.media-el) {
|
||||
@@ -380,6 +385,14 @@
|
||||
cursor: zoom-in;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 相纸白边 (HEL-680):素材墙里的作品按相纸呈现 */
|
||||
.media.a-photo {
|
||||
border: var(--paper-w, 4px) solid var(--accent);
|
||||
border-bottom-width: calc(var(--paper-w, 4px) + 2px);
|
||||
background: var(--accent);
|
||||
border-radius: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.media :global(img),
|
||||
.media :global(video) {
|
||||
width: 100%;
|
||||
|
||||
@@ -392,14 +392,8 @@
|
||||
char: string;
|
||||
dark?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
role: 'story',
|
||||
labelKey: 'models.agentStory',
|
||||
color: 'var(--avatar-1)',
|
||||
char: '剧',
|
||||
dark: true,
|
||||
},
|
||||
{ role: 'style', labelKey: 'models.agentStyle', color: 'var(--avatar-2)', char: '视' },
|
||||
{ role: 'story', labelKey: 'models.agentStory', color: 'var(--avatar-1)', char: '剧', dark: true },
|
||||
{ role: 'style', labelKey: 'models.agentStyle', color: 'var(--avatar-2)', char: '视', dark: true },
|
||||
{ role: 'script', labelKey: 'models.agentScript', color: 'var(--avatar-3)', char: '分' },
|
||||
{ role: 'assets', labelKey: 'models.agentAssets', color: 'var(--avatar-4)', char: '素' },
|
||||
{ role: 'video', labelKey: 'models.agentVideo', color: 'var(--avatar-5)', char: '镜' },
|
||||
|
||||
@@ -496,7 +496,11 @@
|
||||
<aside class="side-col">
|
||||
{#if currentAgent}
|
||||
<div class="side-card agent-card">
|
||||
<span class="agent-avatar" style:background={`var(--avatar-${currentAgent.avatar})`}>
|
||||
<span
|
||||
class="agent-avatar"
|
||||
class:light={(currentAgent.avatar ?? 1) <= 2}
|
||||
style:background={`var(--avatar-${currentAgent.avatar})`}
|
||||
>
|
||||
{currentAgent.char}
|
||||
</span>
|
||||
<div class="agent-copy">
|
||||
@@ -740,6 +744,10 @@
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* HEL-680 灰阶头像:1–2 亮阶用深字 */
|
||||
.agent-avatar.light {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.agent-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# S4:工作流批量导入向导与管理页(HEL-682)
|
||||
|
||||
复用 `/connections` 工作流库,接入 S2(HEL-681)的数据层端点:批量导入向导 +
|
||||
元数据轻量管理列表。本批只动 `calliope-web`,后端零改动。
|
||||
|
||||
## 批量导入向导(`WorkflowBatchImportWizard.svelte`)
|
||||
|
||||
- 三步式抽屉(780px):选择来源 → 预检报告 → 导入结果。
|
||||
- 来源两种:扫描服务器目录(`POST /api/workflows/import-scan`,默认
|
||||
`example_ComfyUI_workflows`,可选对真实 ComfyUI 做 L2 预检)或本地多选
|
||||
文件(客户端解析,非 UI 格式自动忽略,`POST /api/workflows/convert`)。
|
||||
- 预检报告逐文件给出 ok/warn/fail、转换提示、建议元数据(用途/分类/模型
|
||||
家族/输入输出媒介);warn 项可就地补充用途(目录模式在导入后 PATCH 到
|
||||
新行);「需人工确认」与转换失败项不可勾选、不会被导入、永远不被标作
|
||||
可运行。
|
||||
- 重复导入按内容去重:重复执行全部 conflict 跳过,界面明示「已存在」。
|
||||
|
||||
## 管理列表改元数据驱动(`WorkflowLibrary.svelte`,自 connections 页抽出)
|
||||
|
||||
- 列表数据源换成 `GET /api/workflows?view=meta`(无 workflow_json /
|
||||
input_schema,152 条约 90KB),完整 JSON 只在试运行、输出指定等操作时
|
||||
按需单条加载并缓存。
|
||||
- 工具栏:搜索(`/` 聚焦)、类型/收藏/待确认/停用筛选、分类/模型家族/
|
||||
媒介下拉、收藏/最近与名称排序、清除筛选;行内:分类与模型家族标签、
|
||||
输入→输出媒介图标、收藏星标、启停开关(用途/输出未 settle 时禁用并
|
||||
提示)、试运行(复用 `WorkflowTestDialog`)、L2 复查、绑定连接、删除;
|
||||
分页渲染(60/批,「再显示」)。
|
||||
- 批量:勾选 + 全选 → 批量标注(查找替换重命名、统一说明、追加标签、
|
||||
分类/模型家族)与批量启停。
|
||||
- 新增去重入口(`POST /api/workflows/dedupe`,字节级相同才删,保留最早)。
|
||||
- 原有能力保留:用途确认下拉、输出节点指定(HEL-638)、单个 API 格式
|
||||
导入抽屉(HEL-655)。
|
||||
|
||||
## 其他
|
||||
|
||||
- `api.ts`/`comfy/types.ts`:`listMeta/convert/importBatch/importScanDry/
|
||||
importScanCommit/touch/dedupe` 客户端与 `WorkflowMeta` 等类型;`update`
|
||||
载荷支持 favorite/tags/category/model_family。
|
||||
- i18n:`wl.* / md.* / wiz.* / wfCat.*` 共 130 键 × 7 语言;分类展示用
|
||||
标签映射,不把 30 字目录名暴露给用户。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,315 @@
|
||||
{
|
||||
"id": "e3f2b845-8f2c-4b5a-9caf-eac1029d3e7e",
|
||||
"revision": 0,
|
||||
"last_node_id": 173,
|
||||
"last_link_id": 338,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 169,
|
||||
"type": "MiniMaxH3GuidedWriter",
|
||||
"pos": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
654
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "options",
|
||||
"shape": 7,
|
||||
"type": "H3_REWRITER_OPTIONS",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "rewritten_prompt",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
336
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "integrated_multimodal_description",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "overall_soundscape",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "non_diegetic_music",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"title": "Write the prompt",
|
||||
"properties": {
|
||||
"Node name for S&R": "MiniMaxH3GuidedWriter",
|
||||
"max_duration": 30,
|
||||
"cnr_id": "minimax-h3-prompt-rewriter",
|
||||
"ver": "a59888df88a69385d429b6865ea441e7844f49c9"
|
||||
},
|
||||
"widgets_values": [
|
||||
"图1的男人和图2的女人赤身躺在床上,女人跨坐在他身上,缓慢地起伏,双手撑在他胸口,头向后仰,呼吸越来越急。男人仰面躺着,喘息着低声说:\"看着我。\"女人俯下身吻他,镜头从侧面缓慢推近。\n",
|
||||
"on disk: Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf [qwen35, 5.2 GB]",
|
||||
"T2VA",
|
||||
"16:9",
|
||||
10,
|
||||
true,
|
||||
42,
|
||||
"fixed",
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
false,
|
||||
"",
|
||||
false,
|
||||
""
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"prompt": "图1的男人和图2的女人赤身躺在床上,女人跨坐在他身上,缓慢地起伏,双手撑在他胸口,头向后仰,呼吸越来越急。男人仰面躺着,喘息着低声说:\"看着我。\"女人俯下身吻他,镜头从侧面缓慢推近。\n",
|
||||
"model": "on disk: Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf [qwen35, 5.2 GB]",
|
||||
"task": "T2VA",
|
||||
"resolution": "16:9",
|
||||
"duration": 10,
|
||||
"greedy": true,
|
||||
"seed": 42,
|
||||
"control_after_generate": "fixed",
|
||||
"keep_model_loaded": false,
|
||||
"aspect_ratio": "",
|
||||
"reference_material": "",
|
||||
"bypass": false,
|
||||
"system_prompt": "",
|
||||
"repeat_last": false,
|
||||
"library_pick": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 170,
|
||||
"type": "MiniMaxH3PromptCheck",
|
||||
"pos": [
|
||||
460,
|
||||
0
|
||||
],
|
||||
"size": [
|
||||
390,
|
||||
420
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "options",
|
||||
"shape": 7,
|
||||
"type": "H3_REWRITER_OPTIONS",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"label": "ref_0",
|
||||
"name": "references.ref_0",
|
||||
"shape": 7,
|
||||
"type": "IMAGE,VIDEO,AUDIO",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"link": 336
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "prompt",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
337
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "integrated_multimodal_description",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "subject_definitions",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "retention_analysis",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "detailed_description",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "overall_soundscape",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "non_diegetic_music",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "findings",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
338
|
||||
]
|
||||
}
|
||||
],
|
||||
"title": "Check it against the H3 rules",
|
||||
"properties": {
|
||||
"Node name for S&R": "MiniMaxH3PromptCheck",
|
||||
"max_duration": 30,
|
||||
"cnr_id": "minimax-h3-prompt-rewriter",
|
||||
"ver": "a59888df88a69385d429b6865ea441e7844f49c9"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
"T2VA",
|
||||
5
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"prompt": "",
|
||||
"task": "T2VA",
|
||||
"duration": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 171,
|
||||
"type": "PreviewAny",
|
||||
"pos": [
|
||||
900,
|
||||
0
|
||||
],
|
||||
"size": [
|
||||
560,
|
||||
480
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 337
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"title": "The finished prompt",
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.30.0"
|
||||
},
|
||||
"widgets_values": [],
|
||||
"widgets_values_named": {}
|
||||
},
|
||||
{
|
||||
"id": 172,
|
||||
"type": "PreviewAny",
|
||||
"pos": [
|
||||
900,
|
||||
520
|
||||
],
|
||||
"size": [
|
||||
560,
|
||||
240
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "source",
|
||||
"type": "*",
|
||||
"link": 338
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"title": "What the check found",
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewAny",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.30.0"
|
||||
},
|
||||
"widgets_values": [],
|
||||
"widgets_values_named": {}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
336,
|
||||
169,
|
||||
0,
|
||||
170,
|
||||
2,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
337,
|
||||
170,
|
||||
0,
|
||||
171,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
338,
|
||||
170,
|
||||
8,
|
||||
172,
|
||||
0,
|
||||
"STRING"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1.1772972972972988,
|
||||
"offset": [
|
||||
429.8282570474093,
|
||||
283.1220192192576
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.53.10"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"id":"fae76a0b-6efc-499c-ae87-9d887bcd785f","revision":0,"last_node_id":10,"last_link_id":5,"nodes":[{"id":10,"type":"DJZ-LoadLatent","pos":[960,680],"size":[390,60],"flags":{},"order":1,"mode":0,"inputs":[{"label":"latent_file","localized_name":"latent_file","name":"latent_file","type":"COMBO","widget":{"name":"latent_file"},"link":null}],"outputs":[{"label":"LATENT","localized_name":"Latent","name":"LATENT","type":"LATENT","links":[5]}],"properties":{"cnr_id":"djz-nodes","ver":"f6ca9376132600e6245d3d9d2f1e5d0ffa448d99","Node name for S&R":"DJZ-LoadLatent","aux_id":"MushroomFleet/DJZ-Nodes","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.4.1"}},"widgets_values":["latents\\ComfyUI_00001_nmxvu_1762517653.latent"]},{"id":3,"type":"VAEDecode","pos":[1460,760],"size":[140,50],"flags":{},"order":2,"mode":0,"inputs":[{"label":"Latent","localized_name":"Latent","name":"samples","type":"LATENT","link":5},{"label":"VAE","localized_name":"vae","name":"vae","type":"VAE","link":4}],"outputs":[{"label":"图像","localized_name":"图像","name":"IMAGE","type":"IMAGE","links":[2]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.57","Node name for S&R":"VAEDecode","ue_properties":{"widget_ue_connectable":{},"version":"7.1","input_ue_unconnectable":{}}},"widgets_values":[]},{"id":4,"type":"VHS_VideoCombine","pos":[1680,750],"size":[400,334],"flags":{},"order":3,"mode":0,"inputs":[{"label":"图像","localized_name":"图像","name":"images","type":"IMAGE","link":2},{"label":"音频","localized_name":"音频","name":"audio","shape":7,"type":"AUDIO","link":null},{"label":"批次管理","localized_name":"批次管理","name":"meta_batch","shape":7,"type":"VHS_BatchManager","link":null},{"localized_name":"vae","name":"vae","shape":7,"type":"VAE","link":null},{"label":"帧率","localized_name":"帧率","name":"frame_rate","type":"FLOAT","widget":{"name":"frame_rate"},"link":null},{"label":"循环次数","localized_name":"循环次数","name":"loop_count","type":"INT","widget":{"name":"loop_count"},"link":null},{"label":"文件名前缀","localized_name":"文件名前缀","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null},{"label":"格式","localized_name":"格式","name":"format","type":"COMBO","widget":{"name":"format"},"link":null},{"label":"Ping-Pong","localized_name":"Ping-Pong","name":"pingpong","type":"BOOLEAN","widget":{"name":"pingpong"},"link":null},{"label":"保存到输出文件夹","localized_name":"保存到输出文件夹","name":"save_output","type":"BOOLEAN","widget":{"name":"save_output"},"link":null},{"name":"pix_fmt","type":["yuv420p","yuv420p10le"],"widget":{"name":"pix_fmt"},"link":null},{"name":"crf","type":"INT","widget":{"name":"crf"},"link":null},{"label":"保存元数据","localized_name":"保存元数据","name":"save_metadata","type":"BOOLEAN","widget":{"name":"save_metadata"},"link":null},{"label":"修剪音频","localized_name":"修剪音频","name":"trim_to_audio","type":"BOOLEAN","widget":{"name":"trim_to_audio"},"link":null}],"outputs":[{"label":"文件名","localized_name":"文件名","name":"Filenames","type":"VHS_FILENAMES","links":null}],"properties":{"cnr_id":"comfyui-videohelpersuite","ver":"8e4d79471bf1952154768e8435a9300077b534fa","Node name for S&R":"VHS_VideoCombine","ue_properties":{"widget_ue_connectable":{},"version":"7.1","input_ue_unconnectable":{}}},"widgets_values":{"frame_rate":16,"loop_count":0,"filename_prefix":"video/WanVideo_","format":"video/h264-mp4","pix_fmt":"yuv420p","crf":19,"save_metadata":true,"trim_to_audio":false,"pingpong":false,"save_output":true,"videopreview":{"hidden":false,"paused":false,"params":{"filename":"WanVideo__00001.mp4","subfolder":"video","type":"output","format":"video/h264-mp4","frame_rate":16,"workflow":"WanVideo__00001.png","fullpath":"E:\\ComfyUI-Data\\output\\video\\WanVideo__00001.mp4"}}}},{"id":5,"type":"VAELoader","pos":[980,820],"size":[360,60],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"vae名称","name":"vae_name","type":"COMBO","widget":{"name":"vae_name"},"link":null}],"outputs":[{"localized_name":"VAE","name":"VAE","type":"VAE","links":[4]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.68","Node name for S&R":"VAELoader","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.4.1"}},"widgets_values":["Wan2_1_VAE_bf16.safetensors"]}],"links":[[2,3,0,4,0,"IMAGE"],[4,5,0,3,1,"VAE"],[5,10,0,3,0,"LATENT"]],"groups":[],"config":{},"extra":{"ue_links":[],"ds":{"scale":1.0834705943389082,"offset":[-573.5954979317662,-431.50654580611246]},"links_added_by_ue":[]},"version":0.4}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"id":"51bbecaf-42f9-4de3-95e4-b6db447eb5db","revision":0,"last_node_id":3,"last_link_id":2,"nodes":[{"id":3,"type":"PreviewImage","pos":[1480,500],"size":[400,260],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"图像","name":"images","type":"IMAGE","link":2}],"outputs":[{"localized_name":"images","name":"images","type":"IMAGE","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.21.1","Node name for S&R":"PreviewImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":[]},{"id":2,"type":"GuHaiAutoImageSplit","pos":[1160,500],"size":[270,210],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"图像","name":"图像","type":"IMAGE","link":1},{"localized_name":"保存目录","name":"保存目录","type":"STRING","widget":{"name":"保存目录"},"link":null},{"localized_name":"水平张数","name":"水平张数","type":"INT","widget":{"name":"水平张数"},"link":null},{"localized_name":"垂直张数","name":"垂直张数","type":"INT","widget":{"name":"垂直张数"},"link":null},{"localized_name":"移除画布边缘","name":"移除画布边缘","type":"BOOLEAN","widget":{"name":"移除画布边缘"},"link":null},{"localized_name":"移除描边","name":"移除描边","type":"INT","widget":{"name":"移除描边"},"link":null},{"localized_name":"文件名前缀","name":"文件名前缀","type":"STRING","widget":{"name":"文件名前缀"},"link":null},{"localized_name":"保存格式","name":"保存格式","type":"COMBO","widget":{"name":"保存格式"},"link":null}],"outputs":[{"localized_name":"分割图像","name":"分割图像","type":"IMAGE","links":[2]}],"properties":{"aux_id":"goohai/Goohaitools-comfyui","ver":"885cac3dd323d26a77b69ae904a9a344f0dee7d0","Node name for S&R":"GuHaiAutoImageSplit","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["output",3,3,true,8,"孤海图像分割_","PNG"]},{"id":1,"type":"LoadImage","pos":[850,490],"size":[270,320],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"图像","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"选择文件上传","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"图像","name":"IMAGE","type":"IMAGE","links":[1]},{"localized_name":"遮罩","name":"MASK","type":"MASK","links":null}],"properties":{"cnr_id":"comfy-core","ver":"0.21.1","Node name for S&R":"LoadImage","ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{},"version":"7.8"}},"widgets_values":["Image (4) (1).png","image"]}],"links":[[1,1,0,2,0,"IMAGE"],[2,2,0,3,0,"IMAGE"]],"groups":[{"id":1,"title":"图像分割","bounding":[810,420,1100,400],"color":"#3f789e","flags":{}}],"config":{},"extra":{"ds":{"scale":0.9090909090909091,"offset":[-259.9355305037313,15.820561879510858]},"ue_links":[]},"version":0.4}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"id":"74ef48f7-4441-45af-a6a0-a2bba28aeff5","revision":0,"last_node_id":18,"last_link_id":19,"nodes":[{"id":18,"type":"AudioCrop","pos":[-322,508],"size":[315,82],"flags":{},"order":1,"mode":0,"inputs":[{"label":"audio","localized_name":"audio","name":"audio","type":"AUDIO","link":18},{"label":"start_time","localized_name":"start_time","name":"start_time","type":"STRING","widget":{"name":"start_time"},"link":null},{"label":"end_time","localized_name":"end_time","name":"end_time","type":"STRING","widget":{"name":"end_time"},"link":null}],"outputs":[{"label":"AUDIO","localized_name":"音频","name":"AUDIO","type":"AUDIO","slot_index":0,"links":[19]}],"properties":{"cnr_id":"audio-separation-nodes-comfyui","ver":"1.4.0","Node name for S&R":"AudioCrop","ue_properties":{"widget_ue_connectable":{},"version":"7.4.1","input_ue_unconnectable":{}}},"widgets_values":["0:00","1:00"]},{"id":10,"type":"LoadAudio","pos":[-765,507],"size":[381.5714111328125,144],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"音频","name":"audio","type":"COMBO","widget":{"name":"audio"},"link":null},{"localized_name":"音频UI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null},{"localized_name":"选择文件上传","name":"upload","type":"AUDIOUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"label":"音频","localized_name":"音频","name":"AUDIO","type":"AUDIO","slot_index":0,"links":[18]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.68","Node name for S&R":"LoadAudio","ue_properties":{"widget_ue_connectable":{},"version":"7.4.1","input_ue_unconnectable":{}}},"widgets_values":["没出息.WAV",null,null]},{"id":15,"type":"PreviewAudio","pos":[416,514],"size":[318.9408688964845,88],"flags":{},"order":3,"mode":0,"inputs":[{"label":"音频","localized_name":"音频","name":"audio","type":"AUDIO","link":15},{"localized_name":"音频UI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.68","Node name for S&R":"PreviewAudio","ue_properties":{"widget_ue_connectable":{},"version":"7.4.1","input_ue_unconnectable":{}}},"widgets_values":[]},{"id":11,"type":"AudioSeparation","pos":[45,494],"size":[315,166],"flags":{},"order":2,"mode":0,"inputs":[{"label":"audio","localized_name":"audio","name":"audio","type":"AUDIO","link":19},{"label":"chunk_fade_shape","localized_name":"chunk_fade_shape","name":"chunk_fade_shape","shape":7,"type":"COMBO","widget":{"name":"chunk_fade_shape"},"link":null},{"label":"chunk_length","localized_name":"chunk_length","name":"chunk_length","shape":7,"type":"FLOAT","widget":{"name":"chunk_length"},"link":null},{"label":"chunk_overlap","localized_name":"chunk_overlap","name":"chunk_overlap","shape":7,"type":"FLOAT","widget":{"name":"chunk_overlap"},"link":null}],"outputs":[{"label":"Bass","localized_name":"Bass","name":"Bass","type":"AUDIO","links":null},{"label":"Drums","localized_name":"Drums","name":"Drums","type":"AUDIO","links":null},{"label":"Other","localized_name":"Other","name":"Other","type":"AUDIO","links":null},{"label":"Vocals","localized_name":"Vocals","name":"Vocals","type":"AUDIO","slot_index":3,"links":[15]}],"properties":{"cnr_id":"audio-separation-nodes-comfyui","ver":"1.4.0","Node name for S&R":"AudioSeparation","ue_properties":{"widget_ue_connectable":{},"version":"7.4.1","input_ue_unconnectable":{}}},"widgets_values":["logarithmic",16,1.1]}],"links":[[15,11,3,15,0,"AUDIO"],[18,10,0,18,0,"AUDIO"],[19,18,0,11,0,"AUDIO"]],"groups":[],"config":{},"extra":{"ue_links":[],"links_added_by_ue":[],"ds":{"scale":0.9158251011505646,"offset":[1084.0109668805326,-51.53135329805568]}},"version":0.4}
|
||||
@@ -0,0 +1 @@
|
||||
{"id":"48bb093c-f7a8-421c-8fc4-2b6acc133512","revision":0,"last_node_id":4,"last_link_id":3,"nodes":[{"id":4,"type":"SaveAudioMP3","pos":[580,23110],"size":[270,136],"flags":{},"order":1,"mode":0,"inputs":[{"label":"音频","localized_name":"音频","name":"audio","type":"AUDIO","link":3},{"label":"文件名前缀","localized_name":"文件名前缀","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null},{"label":"品质","localized_name":"品质","name":"quality","type":"COMBO","widget":{"name":"quality"},"link":null},{"localized_name":"audioUI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null}],"outputs":[],"properties":{"ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{}},"Node name for S&R":"SaveAudioMP3"},"widgets_values":["audio/ComfyUI","V0"]},{"id":1,"type":"LoadAudio","pos":[180,23110],"size":[310,140],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"音频","name":"audio","type":"COMBO","widget":{"name":"audio"},"link":null},{"localized_name":"音频UI","name":"audioUI","type":"AUDIO_UI","widget":{"name":"audioUI"},"link":null},{"localized_name":"选择文件上传","name":"upload","type":"AUDIOUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"音频","name":"AUDIO","type":"AUDIO","links":[3]}],"properties":{"ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{}},"Node name for S&R":"LoadAudio"},"widgets_values":["0001.G.E.M. 邓紫棋 - 光年之外.m4a",null,""]}],"links":[[3,1,0,4,0,"AUDIO"]],"groups":[],"config":{},"extra":{"ue_links":[],"links_added_by_ue":[],"ds":{"scale":1.2839025177495265,"offset":[349.7067995460809,-22788.486109072262]}},"version":0.4}
|
||||
@@ -0,0 +1 @@
|
||||
{"id":"e6b92a63-6d7e-4d2b-b9e3-2e054825db02","revision":0,"last_node_id":7,"last_link_id":6,"nodes":[{"id":3,"type":"SaveImage","pos":[-500,-530],"size":[280,280],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"图片","name":"images","type":"IMAGE","link":2},{"localized_name":"文件名前缀","name":"filename_prefix","type":"STRING","widget":{"name":"filename_prefix"},"link":null}],"outputs":[],"properties":{"Node name for S&R":"SaveImage","cnr_id":"comfy-core","ver":"0.16.4","enableTabs":false,"tabWidth":65,"tabXOffset":10,"hasSecondTab":false,"secondTabText":"Send Back","secondTabOffset":80,"secondTabWidth":65,"ue_properties":{"widget_ue_connectable":{},"version":"7.7","input_ue_unconnectable":{}}},"widgets_values":["ComfyUI"]},{"id":1,"type":"RTXVideoSuperResolution","pos":[-830,-560],"size":[300,110],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":1},{"localized_name":"resize_type","name":"resize_type","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"resize_type"},"link":null},{"localized_name":"resize_type.scale","name":"resize_type.scale","type":"FLOAT","widget":{"name":"resize_type.scale"},"link":null},{"localized_name":"quality","name":"quality","type":"COMBO","widget":{"name":"quality"},"link":null}],"outputs":[{"localized_name":"upscaled_images","name":"upscaled_images","type":"IMAGE","links":[2,5]}],"properties":{"Node name for S&R":"RTXVideoSuperResolution","aux_id":"Comfy-Org/Nvidia_RTX_Nodes_ComfyUI","ver":"0.1.0","cnr_id":"comfyui_nvidia_rtx_nodes","enableTabs":false,"tabWidth":65,"tabXOffset":10,"hasSecondTab":false,"secondTabText":"Send Back","secondTabOffset":80,"secondTabWidth":65,"ue_properties":{"widget_ue_connectable":{},"version":"7.7","input_ue_unconnectable":{}}},"widgets_values":["scale by multiplier",2,"ULTRA"]},{"id":2,"type":"LoadImage","pos":[-1150,-630],"size":[290,320],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"图像","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"选择文件上传","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"图像","name":"IMAGE","type":"IMAGE","links":[1,6]},{"localized_name":"遮罩","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"LoadImage","cnr_id":"comfy-core","ver":"0.16.4","enableTabs":false,"tabWidth":65,"tabXOffset":10,"hasSecondTab":false,"secondTabText":"Send Back","secondTabOffset":80,"secondTabWidth":65,"ue_properties":{"widget_ue_connectable":{},"version":"7.7","input_ue_unconnectable":{}}},"widgets_values":["ComfyUI_01117_.png","image"]},{"id":7,"type":"Image Comparer (rgthree)","pos":[-180,-620],"size":[210,78],"flags":{},"order":3,"mode":0,"inputs":[{"dir":3,"label":"图像_A","localized_name":"图像_A","name":"image_a","type":"IMAGE","link":6},{"dir":3,"label":"图像_B","localized_name":"图像_B","name":"image_b","type":"IMAGE","link":5}],"outputs":[],"title":"图像对比","properties":{"ue_properties":{"widget_ue_connectable":{},"input_ue_unconnectable":{}},"comparer_mode":"Slide"},"widgets_values":[[]]}],"links":[[1,2,0,1,0,"IMAGE"],[2,1,0,3,0,"IMAGE"],[5,1,0,7,1,"IMAGE"],[6,2,0,7,0,"IMAGE"]],"groups":[],"config":{},"extra":{"workflowRendererVersion":"Vue-corrected","ds":{"scale":1.7985878990921387,"offset":[589.555558343045,830.7344763619243]},"frontendVersion":"1.39.19","VHS_latentpreview":false,"VHS_latentpreviewrate":0,"VHS_MetadataImage":true,"VHS_KeepIntermediate":true,"ue_links":[],"links_added_by_ue":[]},"version":0.4}
|
||||
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user