Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea3a1c6f06 | ||
|
|
e2b69aff16 | ||
|
|
cb35d9819a | ||
|
|
afbf50447a | ||
|
|
ab48895b99 | ||
|
|
dca216e2c7 | ||
|
|
37cb6ec209 | ||
|
|
31e5906462 | ||
|
|
8d7e58645c | ||
|
|
930cca653f | ||
|
|
15ff2a3cac | ||
|
|
3dfed6780c | ||
|
|
f2ca010fcd | ||
|
|
30851d57f1 | ||
|
|
a1c01f1e90 | ||
|
|
fb6139d45c | ||
|
|
af0417e874 | ||
|
|
b2bcbc10df | ||
|
|
2004f527cc | ||
|
|
dca88b3765 | ||
|
|
54880e900b | ||
|
|
4c849e9ef9 | ||
|
|
46fd5bbc86 | ||
|
|
fc6616b118 | ||
|
|
22f82fbed6 | ||
|
|
67eb822efe | ||
|
|
99574c2226 | ||
|
|
88965fa70d | ||
|
|
9d54b9d8dd | ||
|
|
a666f6bd12 | ||
|
|
b02c50fdf7 | ||
|
|
7e9a5be19d | ||
|
|
e49b8b0072 | ||
|
|
ddc02fbe4c | ||
|
|
d076978a4a | ||
|
|
4e200cc8ac | ||
|
|
bfcf542e59 | ||
|
|
3d1c4ca5af | ||
|
|
24129438e8 | ||
|
|
155d914df3 | ||
|
|
64c1116bca | ||
|
|
03bebec94a | ||
|
|
0f4d6a8961 | ||
|
|
06c9bd5000 | ||
|
|
acb6beca1c | ||
|
|
a6b04e5c91 | ||
|
|
634965f5f5 | ||
|
|
e62499c517 | ||
|
|
b6b3a9fed3 | ||
|
|
7bf63d0587 | ||
|
|
5cb75d5718 | ||
|
|
2304ab1538 | ||
|
|
8e84e4c21f | ||
|
|
35fd07af24 | ||
|
|
012a946f85 | ||
|
|
875dfcb41a | ||
|
|
780ac52ffc | ||
|
|
d4e199f3c8 | ||
|
|
ae9e34f6ab | ||
|
|
16933b1dfc | ||
|
|
07f6475b1a | ||
|
|
ac1c0f98a6 | ||
|
|
aaddd3dcaf | ||
|
|
2b3bbf9d03 | ||
|
|
bc24370233 | ||
|
|
2f267a751f | ||
|
|
0841f5a296 | ||
|
|
35aad34c7c | ||
|
|
bef28facee | ||
|
|
316fba97e1 | ||
|
|
1c00587a56 | ||
|
|
7c49250267 | ||
|
|
c5aa575ac3 | ||
|
|
6623ffa04c | ||
|
|
0ee8280ddb | ||
|
|
1fc8bdb8bb | ||
|
|
2c3c05277b | ||
|
|
2689f5354a | ||
|
|
7bd8c29faa | ||
|
|
4f3293fc02 | ||
|
|
4c87c6dea0 | ||
|
|
d882fd62d8 | ||
|
|
b4c7334f45 | ||
|
|
6064bd805d | ||
|
|
c3deaf90bb | ||
|
|
949cd2ecad | ||
|
|
0c620e61fe | ||
|
|
6f4d50f4f0 | ||
|
|
8b03291327 | ||
|
|
225fcb49ea | ||
|
|
70a860ce2a | ||
|
|
e42212a16f | ||
|
|
f5f19f0366 | ||
|
|
bfe7b20b73 | ||
|
|
2ed9d25c24 | ||
|
|
48e16ae443 | ||
|
|
319c7b049b | ||
|
|
e3bfe771af | ||
|
|
e6f761f8c8 | ||
|
|
30e8e85766 |
@@ -156,7 +156,8 @@
|
||||
"job.started",
|
||||
"shot.capture.saved",
|
||||
"shot.updated",
|
||||
"story.ready"
|
||||
"story.ready",
|
||||
"workbench.stage.updated"
|
||||
],
|
||||
"prompt_profiles": [
|
||||
"minimax_h3_ref",
|
||||
|
||||
@@ -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,52 @@
|
||||
"""Capture successful task replies without treating chat/errors as confirmed work."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db
|
||||
|
||||
|
||||
def complete_task(session_id: int, final: str) -> None:
|
||||
from calliope.agent.harness.plugins.interaction import latest_open_question
|
||||
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
if latest_open_question(session_id):
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET task_status='awaiting_input' WHERE id=?", (session_id,)
|
||||
)
|
||||
else:
|
||||
session = conn.execute(
|
||||
"SELECT * FROM agent_sessions WHERE id=?", (session_id,)
|
||||
).fetchone()
|
||||
message = conn.execute(
|
||||
"SELECT id FROM agent_messages WHERE session_id=? AND role='assistant' "
|
||||
"AND content=? AND (status IS NULL OR status NOT IN ('error','cancelled')) "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(session_id, final),
|
||||
).fetchone()
|
||||
if session and message and final.strip():
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO agent_deliverables "
|
||||
"(session_id,source_message_id,project_id,stage_key,title,content) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(
|
||||
session_id,
|
||||
message["id"],
|
||||
session["project_id"],
|
||||
session["agent_key"] or "script",
|
||||
session["title"][:200],
|
||||
final,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET task_status='awaiting_confirm' WHERE id=?",
|
||||
(session_id,),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET task_status='completed' WHERE id=?", (session_id,)
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -40,8 +40,10 @@ class AgentRunner:
|
||||
conn = self._db()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(status, session_id),
|
||||
"UPDATE agent_sessions SET status = ?, task_status=CASE "
|
||||
"WHEN ? = 'running' THEN 'running' WHEN ? = 'error' THEN 'error' "
|
||||
"ELSE task_status END, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(status, status, status, session_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
@@ -201,7 +203,8 @@ class AgentRunner:
|
||||
).fetchall()]
|
||||
if stuck:
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET status = 'idle' WHERE status = 'running'"
|
||||
"UPDATE agent_sessions SET status = 'idle', task_status='interrupted' "
|
||||
"WHERE status = 'running'"
|
||||
)
|
||||
conn.commit()
|
||||
logger.warning(
|
||||
@@ -295,6 +298,15 @@ class AgentRunner:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
conn = self._db()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET task_summary=? WHERE id=?",
|
||||
(user_message.strip()[:300], session_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
self._set_status(session_id, "running")
|
||||
task = asyncio.create_task(
|
||||
self._run_session(session_id), name=f"agent-session-{session_id}"
|
||||
@@ -398,6 +410,9 @@ class AgentRunner:
|
||||
session_id=session_id,
|
||||
on_message=self._make_message_sink(session_id, default_agent_name),
|
||||
)
|
||||
from calliope.agent.deliverables import complete_task
|
||||
|
||||
complete_task(session_id, final)
|
||||
# Final answer is persisted + broadcast by the loop's emit() call
|
||||
# (which goes through the message sink). Only set status here.
|
||||
self._set_status(session_id, "idle")
|
||||
@@ -408,6 +423,14 @@ class AgentRunner:
|
||||
content="— run cancelled —",
|
||||
status="cancelled",
|
||||
)
|
||||
conn = self._db()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET task_status='cancelled' WHERE id=?", (session_id,)
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
self._set_status(session_id, "idle")
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — surfaced to the chat as the reply
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -13,6 +14,22 @@ from calliope.comfyui.registry import IMAGE_CLASSES, AUDIO_CLASSES, VIDEO_CLASSE
|
||||
|
||||
logger = logging.getLogger("calliope.comfyui")
|
||||
|
||||
# Reachability probe budget (HEL-619): matches the connections-page test
|
||||
# probe (5s). Without it health() inherits the 120s client default, so a
|
||||
# bad endpoint hangs every queued job for two minutes before failing.
|
||||
HEALTH_TIMEOUT_SEC = 5.0
|
||||
|
||||
# /object_info fetch is a few MB on big instances; the preflight check
|
||||
# (HEL-642/643) caches it per endpoint for this long. Manual refresh
|
||||
# (`force_refresh=True`) bypasses the TTL.
|
||||
OBJECT_INFO_TTL_SEC = 300.0
|
||||
_object_info_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def clear_object_info_cache() -> None:
|
||||
"""Drop cached /object_info payloads (tests swap fake endpoints in)."""
|
||||
_object_info_cache.clear()
|
||||
|
||||
|
||||
def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
|
||||
"""Raise with ComfyUI's own error body instead of an opaque status line.
|
||||
@@ -76,11 +93,40 @@ class ComfyUIClient:
|
||||
|
||||
async def health(self) -> bool:
|
||||
try:
|
||||
resp = await self._http.get(f"{self.base_url}/system_stats")
|
||||
resp = await self._http.get(
|
||||
f"{self.base_url}/system_stats", timeout=HEALTH_TIMEOUT_SEC
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _cache_key(self) -> str:
|
||||
auth = self._headers.get("Authorization") or next(
|
||||
(v for k, v in self._headers.items() if k.lower() not in ("authorization",)), ""
|
||||
)
|
||||
return f"{self.base_url}|{auth}"
|
||||
|
||||
async def object_info(self, force_refresh: bool = False) -> dict[str, Any]:
|
||||
"""The server's full ``/object_info`` node catalogue, cached per endpoint.
|
||||
|
||||
Cached for OBJECT_INFO_TTL_SEC; ``force_refresh`` re-fetches (the
|
||||
connections page "重新检查" button after the user installs nodes).
|
||||
Raises on transport/HTTP errors so callers can report honestly.
|
||||
"""
|
||||
key = self._cache_key()
|
||||
now = time.monotonic()
|
||||
if not force_refresh:
|
||||
hit = _object_info_cache.get(key)
|
||||
if hit and now - hit[0] < OBJECT_INFO_TTL_SEC:
|
||||
return hit[1]
|
||||
resp = await self._http.get(f"{self.base_url}/object_info", timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("ComfyUI 返回的 /object_info 不是对象")
|
||||
_object_info_cache[key] = (now, data)
|
||||
return data
|
||||
|
||||
async def upload_image(self, path: Path, subfolder: str = "calliope") -> str:
|
||||
data = path.read_bytes()
|
||||
files = {"image": (path.name, data, "application/octet-stream")}
|
||||
@@ -285,16 +331,46 @@ class ComfyUIClient:
|
||||
except Exception as exc:
|
||||
logger.warning("ComfyUI interrupt failed: %s", exc)
|
||||
|
||||
def extract_outputs(self, history: dict[str, Any]) -> list[dict[str, str]]:
|
||||
# History items carry no explicit media type — infer from the file
|
||||
# extension first, then the history key the node reported under.
|
||||
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mkv", ".mov", ".gif", ".m4v", ".avi", ".flv"})
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"})
|
||||
|
||||
@classmethod
|
||||
def _infer_media_kind(cls, filename: str, history_key: str) -> str | None:
|
||||
ext = Path(str(filename)).suffix.lower()
|
||||
if ext in cls._VIDEO_EXTS:
|
||||
return "video"
|
||||
if ext in cls._IMAGE_EXTS:
|
||||
return "image"
|
||||
if history_key in ("gifs", "videos"):
|
||||
return "video"
|
||||
return None
|
||||
|
||||
def extract_outputs(
|
||||
self, history: dict[str, Any], node_kinds: dict[str, str] | None = None
|
||||
) -> list[dict[str, str]]:
|
||||
"""Media items in a finished prompt's history.
|
||||
|
||||
``node_kinds`` (HEL-638) scopes the harvest to the workflow's
|
||||
confirmed output nodes — {nodeId: image|video} — so results from
|
||||
nodes the user never designated are ignored, and each item carries
|
||||
the node id plus its inferred media kind for mismatch checks.
|
||||
"""
|
||||
outputs: list[dict[str, str]] = []
|
||||
for _node_id, node_out in (history.get("outputs") or {}).items():
|
||||
for node_id, node_out in (history.get("outputs") or {}).items():
|
||||
if node_kinds is not None and str(node_id) not in node_kinds:
|
||||
continue
|
||||
for key in ("images", "gifs", "videos"):
|
||||
for item in node_out.get(key) or []:
|
||||
filename = item.get("filename", "")
|
||||
outputs.append(
|
||||
{
|
||||
"filename": item.get("filename", ""),
|
||||
"filename": filename,
|
||||
"subfolder": item.get("subfolder", ""),
|
||||
"type": item.get("type", "output"),
|
||||
"node_id": str(node_id),
|
||||
"media_kind": self._infer_media_kind(filename, key) or "",
|
||||
}
|
||||
)
|
||||
return outputs
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Structural checks on an imported ComfyUI API workflow.
|
||||
|
||||
``error`` findings mean ComfyUI will reject the prompt as exported, so the
|
||||
studio blocks generation; ``warning`` findings only explain odd parameters.
|
||||
Nothing here repairs a workflow — a broken export has to be re-exported from
|
||||
a ComfyUI that has the missing custom nodes installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
REEXPORT_HINT = "请在已安装对应自定义节点的 ComfyUI 中打开该工作流,重新导出 API 格式 JSON 后再导入"
|
||||
|
||||
# Nodes that cannot run without a model link.
|
||||
_NEEDS_MODEL = frozenset(
|
||||
{"KSampler", "KSamplerAdvanced", "BasicGuider", "CFGGuider", "BasicScheduler"}
|
||||
)
|
||||
|
||||
|
||||
def _title(node_id: str, node: dict[str, Any]) -> str:
|
||||
title = str((node.get("_meta") or {}).get("title") or "").strip()
|
||||
return f"节点 {node_id}「{title}」" if title else f"节点 {node_id}"
|
||||
|
||||
|
||||
def workflow_warnings(workflow: dict[str, Any]) -> list[dict[str, str]]:
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
found: list[dict[str, str]] = []
|
||||
nodes = {str(k): v for k, v in workflow.items() if isinstance(v, dict)}
|
||||
|
||||
untyped = [nid for nid, node in nodes.items() if not str(node.get("class_type") or "").strip()]
|
||||
for nid in untyped:
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "missing_class_type",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, nodes[nid])}缺少节点类型(class_type),"
|
||||
"导出时 ComfyUI 未加载该自定义节点",
|
||||
}
|
||||
)
|
||||
|
||||
consumed: set[str] = set()
|
||||
for nid, node in nodes.items():
|
||||
inputs = node.get("inputs") or {}
|
||||
for name, val in inputs.items():
|
||||
if isinstance(val, list) and len(val) == 2 and isinstance(val[0], (str, int)):
|
||||
src = str(val[0])
|
||||
consumed.add(src)
|
||||
if src not in nodes:
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "dangling_link",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, node)}的输入「{name}」"
|
||||
f"连到了不存在的节点 {src}",
|
||||
}
|
||||
)
|
||||
ct = str(node.get("class_type") or "")
|
||||
if ct in _NEEDS_MODEL and not isinstance(inputs.get("model"), list):
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "missing_model_link",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, node)}({ct})没有连接模型(model),"
|
||||
"模型加载链路在导出时丢失",
|
||||
}
|
||||
)
|
||||
|
||||
for nid, node in nodes.items():
|
||||
ct = str(node.get("class_type") or "")
|
||||
if ct.startswith("Empty") and "Latent" in ct and nid not in consumed:
|
||||
found.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "orphan_latent",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, nodes[nid])}没有接到任何节点,"
|
||||
"它的宽高不会生效,已不在表单中显示",
|
||||
}
|
||||
)
|
||||
|
||||
# HEL-636/638: a workflow without any recognizable output node can never
|
||||
# return a result — block it with a readable reason instead of letting
|
||||
# ComfyUI run blind. Unknown candidates (needs_mapping) don't trigger
|
||||
# this: the user designates them on the import/connections page, and
|
||||
# until then the row stays disabled.
|
||||
from calliope.comfyui.parser import detect_output_nodes
|
||||
|
||||
outputs = detect_output_nodes(workflow)
|
||||
if not any(o.get("kind") in ("image", "video") for o in outputs):
|
||||
unknown = [o for o in outputs if o.get("evidence") == "unknown_node"]
|
||||
if not unknown:
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "no_output",
|
||||
"node_id": "",
|
||||
"message": "未在图中找到输出节点(SaveImage / 视频合成等),"
|
||||
"请确认导出的是 API 格式且包含保存节点",
|
||||
}
|
||||
)
|
||||
else:
|
||||
names = "、".join(f"节点 {o['nodeId']}" for o in unknown[:4])
|
||||
found.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "unknown_output_node",
|
||||
"node_id": names,
|
||||
"message": f"存在无法识别的输出节点({names}),"
|
||||
"需人工指定实际输出节点及其媒体类型(图片/视频)后才能生成",
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def blocking_problem(workflow: dict[str, Any]) -> str | None:
|
||||
"""One Chinese sentence explaining why this workflow cannot be submitted."""
|
||||
errors = [w["message"] for w in workflow_warnings(workflow) if w["level"] == "error"]
|
||||
if not errors:
|
||||
return None
|
||||
shown = ";".join(errors[:4]) + (f";等 {len(errors)} 处" if len(errors) > 4 else "")
|
||||
return f"该工作流文件不完整,ComfyUI 无法执行:{shown}。{REEXPORT_HINT}。"
|
||||
@@ -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,148 @@
|
||||
"""Stored output-node mapping for imported workflows (HEL-638).
|
||||
|
||||
Unknown custom output nodes are never auto-typed. The import confirm page
|
||||
(or the connections table) stores the user's explicit designation — which
|
||||
node ids are the real outputs and each one's media type — in
|
||||
``workflows.output_map`` with ``output_map_source='user'``. Everything that
|
||||
re-derives schemas (reanalyze, migration, plain field edits) must keep that
|
||||
decision; only a fresh explicit payload replaces it.
|
||||
|
||||
Rows without unknown candidates carry an ``auto`` map derived from the
|
||||
trusted registry/tagged outputs so the generation path always knows which
|
||||
history nodes to harvest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_outputs
|
||||
from calliope.comfyui.registry import (
|
||||
IMAGE_OUTPUT_CLASSES,
|
||||
NUMBER_CLASSES,
|
||||
TEXT_AREA_CLASSES,
|
||||
VIDEO_OUTPUT_CLASSES,
|
||||
)
|
||||
|
||||
VALID_OUTPUT_KINDS = ("image", "video")
|
||||
|
||||
|
||||
def _wiring_class(class_type: str) -> bool:
|
||||
"""Parameter / wiring classes can never be designated as outputs."""
|
||||
return (
|
||||
not class_type
|
||||
or class_type in TEXT_AREA_CLASSES
|
||||
or class_type in NUMBER_CLASSES
|
||||
or class_type.startswith("Empty")
|
||||
or class_type.startswith("Primitive")
|
||||
)
|
||||
|
||||
|
||||
def _entry_public(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
out = {
|
||||
"nodeId": str(entry.get("nodeId") or ""),
|
||||
"kind": entry.get("kind"),
|
||||
}
|
||||
if entry.get("label"):
|
||||
out["label"] = entry["label"]
|
||||
if entry.get("class_type"):
|
||||
out["class_type"] = entry["class_type"]
|
||||
return out
|
||||
|
||||
|
||||
def auto_output_map(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Trusted outputs only (tagged titles / registry classes)."""
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
return [
|
||||
_entry_public(o)
|
||||
for o in parse_dynamic_outputs(workflow)
|
||||
if o.get("kind") in VALID_OUTPUT_KINDS
|
||||
]
|
||||
|
||||
|
||||
def needs_user_output_map(workflow: dict[str, Any]) -> bool:
|
||||
"""True when the graph has unknown output candidates (HEL-638)."""
|
||||
if not isinstance(workflow, dict):
|
||||
return False
|
||||
return any(
|
||||
o.get("evidence") == "unknown_node" for o in parse_dynamic_outputs(workflow)
|
||||
)
|
||||
|
||||
|
||||
def validate_output_map(
|
||||
workflow: dict[str, Any], entries: Any
|
||||
) -> list[str]:
|
||||
"""Chinese problems with a user-supplied output map; empty = valid."""
|
||||
if not isinstance(entries, list) or not entries:
|
||||
return ["输出节点映射不能为空:请至少指定一个输出节点及其媒体类型"]
|
||||
seen: set[str] = set()
|
||||
problems: list[str] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
problems.append("输出节点映射格式有误(应为对象数组)")
|
||||
continue
|
||||
nid = str(entry.get("nodeId") or "")
|
||||
node = workflow.get(nid) if isinstance(workflow, dict) else None
|
||||
if not nid or not isinstance(node, dict):
|
||||
problems.append(f"输出节点 {nid or '?'} 不存在于该工作流中")
|
||||
continue
|
||||
ct = str(node.get("class_type") or "").strip()
|
||||
if not ct:
|
||||
problems.append(f"节点 {nid} 缺少节点类型,不能作为输出节点")
|
||||
elif _wiring_class(ct) and ct not in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
|
||||
problems.append(f"节点 {nid}({ct})是参数/连线节点,不能作为输出节点")
|
||||
if entry.get("kind") not in VALID_OUTPUT_KINDS:
|
||||
problems.append(f"输出节点 {nid} 的媒体类型必须是图片或视频")
|
||||
if nid in seen:
|
||||
problems.append(f"输出节点 {nid} 重复指定")
|
||||
seen.add(nid)
|
||||
return problems
|
||||
|
||||
|
||||
def load_output_map(raw: str | None) -> list[dict[str, Any]]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def resolve_output_map(
|
||||
output_map_raw: str | None, source: str | None, workflow: dict[str, Any]
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""The map generation harvests, plus whose decision it is.
|
||||
|
||||
A user map wins as long as its node ids still exist in the stored JSON
|
||||
(defensive pruning — PATCH cannot edit the JSON, but keep it safe);
|
||||
otherwise the trusted auto map applies. Empty list back means "not
|
||||
confirmed yet".
|
||||
"""
|
||||
if source == "user":
|
||||
entries = [
|
||||
e for e in load_output_map(output_map_raw) if str(e.get("nodeId") or "") in
|
||||
(workflow if isinstance(workflow, dict) else {})
|
||||
]
|
||||
if entries:
|
||||
return entries, "user"
|
||||
return [], None
|
||||
auto = auto_output_map(workflow)
|
||||
return (auto, "auto") if auto else ([], None)
|
||||
|
||||
|
||||
def mapping_problem(
|
||||
workflow: dict[str, Any], output_map_raw: str | None, source: str | None
|
||||
) -> str | None:
|
||||
"""Chinese reason this workflow's outputs are not confirmed, else None."""
|
||||
if not needs_user_output_map(workflow):
|
||||
return None
|
||||
entries, resolved = resolve_output_map(output_map_raw, source, workflow)
|
||||
if resolved == "user" and entries:
|
||||
return None
|
||||
return (
|
||||
"该工作流含无法识别的自定义输出节点,需在「ComfyUI 连接」页指定"
|
||||
"实际输出节点及其媒体类型(图片/视频)后才能启用或生成"
|
||||
)
|
||||
@@ -3,18 +3,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.patcher import FIELD_KEY_SEP, resolve_patch_field
|
||||
from calliope.comfyui.registry import (
|
||||
AUDIO_CLASSES,
|
||||
IMAGE_CLASSES,
|
||||
IMAGE_OUTPUT_CLASSES,
|
||||
IMAGE_URL_CLASSES,
|
||||
NUMBER_CLASSES,
|
||||
OPTIONAL_FRAME_INPUTS,
|
||||
TEXT_AREA_CLASSES,
|
||||
VIDEO_CLASSES,
|
||||
VIDEO_OUTPUT_CLASSES,
|
||||
ComfyOutputKind,
|
||||
class_to_input_kind,
|
||||
class_to_output_kind,
|
||||
optional_frame_sibling,
|
||||
)
|
||||
from calliope.comfyui.roles import (
|
||||
USAGE_VALUES,
|
||||
normalize_input_role,
|
||||
normalize_output_role,
|
||||
parse_title_tag,
|
||||
@@ -76,9 +85,17 @@ def effective_input_schema(
|
||||
Auto-detected (and empty) schemas are re-derived from the workflow JSON
|
||||
on every read, so parser upgrades reach workflows imported earlier
|
||||
without a re-import. Tagged ``(Input:…)`` schemas are kept as stored.
|
||||
Entries the user corrected on the import confirm page carry
|
||||
``manual: true`` — their role/label survive re-derivation.
|
||||
"""
|
||||
if not stored or any(isinstance(e, dict) and e.get("auto") for e in stored):
|
||||
if not stored:
|
||||
return parse_dynamic_inputs(workflow)
|
||||
manual_by_id = {
|
||||
str(e.get("nodeId")): e for e in stored if isinstance(e, dict) and e.get("manual")
|
||||
}
|
||||
if any(isinstance(e, dict) and e.get("auto") for e in stored):
|
||||
fresh = parse_dynamic_inputs(workflow)
|
||||
return [manual_by_id.get(str(e.get("nodeId")), e) for e in fresh]
|
||||
return stored
|
||||
|
||||
|
||||
@@ -236,6 +253,12 @@ _NUMBER_LIMITS: dict[str, dict[str, Any]] = {
|
||||
"megapixels": {"min": 0.1, "max": 16.0, "step": 0.1},
|
||||
}
|
||||
|
||||
# Text encoders whose ``resolution`` widget sizes the generated image when an
|
||||
# input image is wired (edit mode); in text-to-image it only sizes references.
|
||||
_RESOLUTION_ENCODERS: dict[str, dict[str, Any]] = {
|
||||
"TextEncodeQwenImage21": {"min": 0, "max": 4096, "step": 32, "integer": True},
|
||||
}
|
||||
|
||||
# comfy_extras/nodes_resolution.py — option strings must match exactly.
|
||||
RESOLUTION_ASPECT_RATIOS: dict[str, tuple[int, int]] = {
|
||||
"1:1 (Square)": (1, 1),
|
||||
@@ -260,6 +283,213 @@ def resolution_selector_size(aspect_ratio: str, megapixels: float, multiple: int
|
||||
)
|
||||
|
||||
|
||||
# ComfyUI Autogrow media inputs a studio form fills as one 1…max list
|
||||
# (HEL-643 generalizes the original image-only registry to three media
|
||||
# kinds). The workflow wires ``<group>.<prefix>N``; extra items are cloned
|
||||
# loader nodes added at patch time (patcher.py). This registry only
|
||||
# beautifies known classes (label + real max + hint key); detection itself
|
||||
# is generic — any ``<group>.<prefix>N`` run of same-family media-loader
|
||||
# links folds into a group, image / video / audio alike.
|
||||
MEDIA_GROUPS: dict[str, dict[str, dict[str, Any]]] = {
|
||||
"MiniMaxH3ReferenceToVideo": {
|
||||
"ref_images": {
|
||||
"prefix": "ref_image_",
|
||||
"max": 9,
|
||||
"label": "参考图",
|
||||
"kind": "image",
|
||||
},
|
||||
"ref_videos": {
|
||||
"prefix": "ref_video_",
|
||||
"max": 3,
|
||||
"label": "参考视频",
|
||||
"kind": "video",
|
||||
},
|
||||
"ref_audios": {
|
||||
"prefix": "ref_audio_",
|
||||
"max": 3,
|
||||
"label": "参考音频",
|
||||
"kind": "audio",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Backwards-compatible alias for the original image-only view.
|
||||
IMAGE_GROUPS = MEDIA_GROUPS
|
||||
|
||||
# Consuming-node classes whose groups carry official usage limits worth a
|
||||
# hint in the UI. Purely cosmetic — detection never depends on it.
|
||||
_MEDIA_HINTS: dict[str, str] = {
|
||||
"MiniMaxH3ReferenceToVideo": "minimax_h3_ref2va",
|
||||
}
|
||||
|
||||
# ``ref_images.ref_image_0`` / ``images.image_1`` style Autogrow input names.
|
||||
_GROUP_INPUT_RE = re.compile(
|
||||
r"^(?P<group>[A-Za-z0-9_+-]+)\.(?P<rest>[A-Za-z0-9_+-]+?)(?P<n>\d+)$"
|
||||
)
|
||||
|
||||
# Consumer input name → (role, label) of the LoadImage wired into it.
|
||||
_FRAME_SLOT_ROLES: dict[str, tuple[str, str]] = {
|
||||
"first_frame": ("first_frame", "首帧"),
|
||||
"start_image": ("first_frame", "首帧"),
|
||||
"last_frame": ("last_frame", "尾帧"),
|
||||
"end_image": ("last_frame", "尾帧"),
|
||||
}
|
||||
|
||||
# Group media kind → (schema kind, role, default label, default max).
|
||||
_GROUP_KIND_META: dict[str, tuple[str, str, str, int]] = {
|
||||
"image": ("image_list", "reference_image", "参考图", 9),
|
||||
"video": ("video_list", "reference_video", "参考视频", 9),
|
||||
"audio": ("audio_list", "reference_audio", "参考音频", 9),
|
||||
}
|
||||
|
||||
# Loader class family for each media kind (registry.py sets).
|
||||
_GROUP_SOURCE_CLASSES: dict[str, frozenset[str]] = {
|
||||
"image": IMAGE_CLASSES,
|
||||
"video": VIDEO_CLASSES,
|
||||
"audio": AUDIO_CLASSES,
|
||||
}
|
||||
|
||||
|
||||
def media_group_spec(class_type: str, group: str) -> dict[str, Any] | None:
|
||||
return MEDIA_GROUPS.get(class_type, {}).get(group)
|
||||
|
||||
|
||||
def image_group_spec(class_type: str, group: str) -> dict[str, Any] | None:
|
||||
return media_group_spec(class_type, group)
|
||||
|
||||
|
||||
def image_group_prefix(node: dict[str, Any], group: str) -> str | None:
|
||||
"""The ``<prefix>`` this node numbers its ``<group>.<prefix>N`` inputs with.
|
||||
|
||||
Known classes read it from the registry; anything else derives it from
|
||||
the node's own wired input names (generic Autogrow detection).
|
||||
"""
|
||||
spec = image_group_spec(str(node.get("class_type") or ""), group)
|
||||
if spec:
|
||||
return str(spec["prefix"])
|
||||
head = f"{group}."
|
||||
prefixes = set()
|
||||
for name in (node.get("inputs") or {}):
|
||||
if not name.startswith(head):
|
||||
continue
|
||||
m = _GROUP_INPUT_RE.match(name)
|
||||
if m and m.group("group") == group:
|
||||
prefixes.add(m.group("rest"))
|
||||
if len(prefixes) == 1:
|
||||
return prefixes.pop()
|
||||
return None
|
||||
|
||||
|
||||
def image_group_slots(
|
||||
node: dict[str, Any], group: str, prefix: str
|
||||
) -> list[tuple[int, str, Any]]:
|
||||
"""``[(N, input name, link), …]`` wired into ``<group>.<prefix>N``, by N."""
|
||||
head = f"{group}.{prefix}"
|
||||
out: list[tuple[int, str, Any]] = []
|
||||
for name, val in (node.get("inputs") or {}).items():
|
||||
if name.startswith(head) and name[len(head):].isdigit():
|
||||
out.append((int(name[len(head):]), name, val))
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def _detected_group_runs(
|
||||
workflow: dict[str, Any], node: dict[str, Any]
|
||||
) -> list[tuple[str, str, list[str], str]]:
|
||||
"""``[(group, prefix, source loader ids, media kind)]`` for Autogrow inputs.
|
||||
|
||||
A run qualifies when the numbers are consecutive, every slot links to a
|
||||
loader of the SAME media family (image / video / audio), and the run
|
||||
either starts at 0 (single-slot Autogrow exports like H3's
|
||||
``ref_image_0``) or has ≥2 slots. Single runs numbered from 1 (e.g.
|
||||
TextEncodeQwenImage21's ``images.image_1``) stay ordinary image slots.
|
||||
"""
|
||||
runs: dict[tuple[str, str], dict[int, Any]] = {}
|
||||
for name, val in (node.get("inputs") or {}).items():
|
||||
m = _GROUP_INPUT_RE.match(name)
|
||||
if not m or not (isinstance(val, list) and len(val) == 2):
|
||||
continue
|
||||
runs.setdefault((m.group("group"), m.group("rest")), {})[int(m.group("n"))] = val
|
||||
out: list[tuple[str, str, list[str], str]] = []
|
||||
for (group, prefix), slots in sorted(runs.items()):
|
||||
ns = sorted(slots)
|
||||
if any(b - a != 1 for a, b in zip(ns, ns[1:])):
|
||||
continue
|
||||
if not (ns[0] == 0 or len(ns) >= 2):
|
||||
continue
|
||||
sources: list[str] = []
|
||||
family: str | None = None
|
||||
for n in ns:
|
||||
link = slots[n]
|
||||
src = workflow.get(str(link[0]))
|
||||
if not isinstance(src, dict):
|
||||
family = None
|
||||
break
|
||||
ct = str(src.get("class_type") or "")
|
||||
kinds = [
|
||||
kind
|
||||
for kind, classes in _GROUP_SOURCE_CLASSES.items()
|
||||
if ct in classes
|
||||
]
|
||||
if len(kinds) != 1 or (family is not None and kinds[0] != family):
|
||||
family = None
|
||||
break
|
||||
family = kinds[0]
|
||||
sources.append(str(link[0]))
|
||||
if family and sources:
|
||||
out.append((group, prefix, sources, family))
|
||||
return out
|
||||
|
||||
|
||||
def _media_groups(
|
||||
workflow: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], set[str]]:
|
||||
"""Group entries for Autogrow reference inputs (image/video/audio) plus
|
||||
the loader node ids they absorb."""
|
||||
entries: list[dict[str, Any]] = []
|
||||
absorbed: set[str] = set()
|
||||
for nid, node in sorted(
|
||||
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
|
||||
key=lambda kv: _node_sort_key(kv[0]),
|
||||
):
|
||||
class_type = str(node.get("class_type") or "")
|
||||
for group, _prefix, sources, media_kind in _detected_group_runs(workflow, node):
|
||||
spec = media_group_spec(class_type, group) or {}
|
||||
kind, role, default_label, default_max = _GROUP_KIND_META[media_kind]
|
||||
absorbed.update(sources)
|
||||
hint = _MEDIA_HINTS.get(class_type)
|
||||
entries.append(
|
||||
_entry(
|
||||
f"{nid}{FIELD_KEY_SEP}{group}",
|
||||
str(spec.get("label") or default_label),
|
||||
role,
|
||||
kind,
|
||||
None,
|
||||
True,
|
||||
min=1,
|
||||
max=spec.get("max", default_max),
|
||||
media_hint=hint,
|
||||
)
|
||||
)
|
||||
return entries, absorbed
|
||||
|
||||
|
||||
def _image_slot(
|
||||
nid: str, workflow: dict[str, Any], consumers: dict[str, list[tuple[str, str]]]
|
||||
) -> tuple[str, str] | None:
|
||||
"""(role, label) of a LoadImage from what consumes it; None when unwired."""
|
||||
uses = consumers.get(nid, [])
|
||||
if not uses:
|
||||
return None
|
||||
for _cid, name in uses:
|
||||
if name in _FRAME_SLOT_ROLES:
|
||||
return _FRAME_SLOT_ROLES[name]
|
||||
for cid, name in uses:
|
||||
ct = str((workflow.get(cid) or {}).get("class_type") or "")
|
||||
if "TextEncode" in ct and name.startswith("image"):
|
||||
return "image", "输入图"
|
||||
return "image", ""
|
||||
|
||||
|
||||
def _key(node_id: str, node: dict[str, Any], field: str) -> str:
|
||||
"""Plain nodeId when that already patches ``field``; else ``nodeId@field``."""
|
||||
return (
|
||||
@@ -283,6 +513,7 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
)
|
||||
|
||||
texts: list[tuple[str, dict[str, Any], str, bool]] = [] # (id, node, field, negative)
|
||||
groups, grouped_loaders = _media_groups(workflow)
|
||||
media: list[dict[str, Any]] = []
|
||||
numbers: list[dict[str, Any]] = []
|
||||
taken_roles: set[str] = set()
|
||||
@@ -308,23 +539,39 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
lowered = title.lower()
|
||||
|
||||
if ct in IMAGE_CLASSES or ct in _MASK_IMAGE_CLASSES:
|
||||
if nid in grouped_loaders:
|
||||
continue
|
||||
is_mask = ct in _MASK_IMAGE_CLASSES or "mask" in lowered or "遮罩" in lowered
|
||||
label = title if title and title != ct else ("遮罩" if is_mask else "参考图")
|
||||
if is_mask and "mask" not in label.lower() and "遮罩" not in label:
|
||||
label = f"遮罩 · {label}"
|
||||
has_default = isinstance(inputs.get("image"), str) and bool(inputs.get("image"))
|
||||
slot = _image_slot(nid, workflow, consumers)
|
||||
if is_mask:
|
||||
label = title if title and title != ct else "遮罩"
|
||||
if "mask" not in label.lower() and "遮罩" not in label:
|
||||
label = f"遮罩 · {label}"
|
||||
has_default = isinstance(inputs.get("image"), str) and bool(inputs.get("image"))
|
||||
media.append(
|
||||
_entry(_key(nid, node, "image"), label, "mask", "image", None, not has_default)
|
||||
)
|
||||
continue
|
||||
if slot is None:
|
||||
# Nothing consumes it — ComfyUI never runs it, so no upload slot.
|
||||
continue
|
||||
role, label = slot
|
||||
media.append(
|
||||
_entry(
|
||||
_key(nid, node, "image"),
|
||||
label,
|
||||
"mask" if is_mask else "image",
|
||||
label or (title if title and title != ct else "参考图"),
|
||||
role,
|
||||
"image",
|
||||
None,
|
||||
not has_default,
|
||||
True,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if ct in VIDEO_CLASSES or ct in AUDIO_CLASSES:
|
||||
if nid in grouped_loaders:
|
||||
# Absorbed into an Autogrow media group (HEL-643) — the
|
||||
# group entry owns its upload slots.
|
||||
continue
|
||||
kind = "video" if ct in VIDEO_CLASSES else "audio"
|
||||
field = resolve_patch_field(node)
|
||||
has_default = isinstance(inputs.get(field), str) and bool(inputs.get(field))
|
||||
@@ -346,6 +593,24 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
texts.append((nid, node, neg_field, True))
|
||||
elif field:
|
||||
texts.append((nid, node, field, _is_negative(nid, lowered, consumers)))
|
||||
edits_image = any(
|
||||
k.startswith("images.") and isinstance(v, list) for k, v in inputs.items()
|
||||
)
|
||||
if ct in _RESOLUTION_ENCODERS and edits_image and "resolution" not in taken_roles:
|
||||
value = inputs.get("resolution")
|
||||
if _is_scalar(value) and not isinstance(value, str):
|
||||
taken_roles.add("resolution")
|
||||
numbers.append(
|
||||
_entry(
|
||||
_key(nid, node, "resolution"),
|
||||
"输出分辨率",
|
||||
"resolution",
|
||||
"number",
|
||||
value,
|
||||
False,
|
||||
**_RESOLUTION_ENCODERS[ct],
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if ct in _SAMPLER_CLASSES:
|
||||
@@ -391,6 +656,9 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
add_number(nid, node, "cfg", "cfg", "CFG")
|
||||
continue
|
||||
if ct.startswith("Empty") and "Latent" in ct:
|
||||
if nid not in consumers:
|
||||
# Orphan latent (Qwen edit sizes from its encoder): editing it changes nothing.
|
||||
continue
|
||||
add_number(nid, node, "width", "width", "宽度")
|
||||
add_number(nid, node, "height", "height", "高度")
|
||||
add_number(nid, node, "batch_size", "batch", "批次数量")
|
||||
@@ -423,6 +691,8 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
)
|
||||
)
|
||||
|
||||
media = _with_optional_frames(workflow, media)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
positives = [t for t in texts if not t[3]]
|
||||
negatives = [t for t in texts if t[3]]
|
||||
@@ -462,9 +732,46 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
)
|
||||
results.extend(numbers)
|
||||
results.extend(media)
|
||||
results.extend(groups)
|
||||
return results
|
||||
|
||||
|
||||
def _with_optional_frames(
|
||||
workflow: dict[str, Any], media: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Insert an optional slot after each frame slot whose node also accepts
|
||||
an unwired optional frame (H3 FL2VA: 首帧 → 尾帧)."""
|
||||
out = list(media)
|
||||
for nid, node in sorted(
|
||||
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
|
||||
key=lambda kv: _node_sort_key(kv[0]),
|
||||
):
|
||||
for field in OPTIONAL_FRAME_INPUTS.get(str(node.get("class_type") or ""), {}):
|
||||
sibling = optional_frame_sibling(node, field)
|
||||
if sibling is None:
|
||||
continue
|
||||
loader_id = str(node["inputs"][sibling][0])
|
||||
loader = workflow.get(loader_id)
|
||||
if not isinstance(loader, dict) or loader.get("class_type") not in IMAGE_CLASSES:
|
||||
continue
|
||||
at = next(
|
||||
(
|
||||
i
|
||||
for i, e in enumerate(out)
|
||||
if e["nodeId"] == _key(loader_id, loader, "image")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if at is None:
|
||||
continue
|
||||
role, label = _FRAME_SLOT_ROLES[field]
|
||||
out.insert(
|
||||
at + 1,
|
||||
_entry(f"{nid}{FIELD_KEY_SEP}{field}", label, role, "image", None, False),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _is_negative(
|
||||
node_id: str, lowered_title: str, consumers: dict[str, list[tuple[str, str]]]
|
||||
) -> bool:
|
||||
@@ -496,4 +803,341 @@ def parse_dynamic_outputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"kind": out_kind,
|
||||
}
|
||||
)
|
||||
if not results:
|
||||
results = detect_output_nodes(workflow)
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output (sink) detection + five-usage classification (HEL-636)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Nodes whose output another node consumes are wiring, not results. A node
|
||||
# nothing consumes is a sink; media sinks are the workflow's outputs.
|
||||
_INPUT_ONLY_CLASSES = IMAGE_CLASSES | IMAGE_URL_CLASSES | AUDIO_CLASSES | VIDEO_CLASSES
|
||||
# Latent/source shapers (EmptyLatentImage…) are wiring even when nothing
|
||||
# consumes them — they never carry results out of the graph.
|
||||
_SOURCE_ONLY_CLASSES = _INPUT_ONLY_CLASSES | TEXT_AREA_CLASSES | NUMBER_CLASSES
|
||||
|
||||
# Classes that are known-internal tails: standard decode steps and cache
|
||||
# cleaners consume media but never emit history outputs, so they can never
|
||||
# be output candidates (HEL-638: a graph ending in these has no output).
|
||||
_NON_OUTPUT_CLASSES = frozenset(
|
||||
{"VAEDecode", "VAEDecodeTiled", "VAEDecodeAudio", "easy cleanGpuUsed"}
|
||||
)
|
||||
|
||||
# Widget names save-style custom nodes carry, and link input names that mark
|
||||
# "consumes rendered media" — used to flag unknown sinks that need mapping.
|
||||
_SAVE_WIDGET_NAMES = frozenset(
|
||||
{"filename_prefix", "filename", "prefix", "path", "output_path", "save_output"}
|
||||
)
|
||||
_MEDIA_LINK_NAMES = frozenset(
|
||||
{"images", "image", "video", "frames", "latent", "latents", "audio", "audios"}
|
||||
)
|
||||
|
||||
|
||||
def _sinks(
|
||||
workflow: dict[str, Any], consumers: dict[str, list[tuple[str, str]]]
|
||||
) -> list[tuple[str, dict[str, Any]]]:
|
||||
out: list[tuple[str, dict[str, Any]]] = []
|
||||
for nid, node in sorted(
|
||||
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
|
||||
key=lambda kv: _node_sort_key(kv[0]),
|
||||
):
|
||||
if not consumers.get(nid):
|
||||
out.append((nid, node))
|
||||
return out
|
||||
|
||||
|
||||
def _unknown_sink_looks_like_save(node: dict[str, Any], workflow: dict[str, Any]) -> bool:
|
||||
inputs = node.get("inputs") or {}
|
||||
for name, val in inputs.items():
|
||||
if name in _SAVE_WIDGET_NAMES and isinstance(val, (str, int, float, bool)):
|
||||
return True
|
||||
if name in _MEDIA_LINK_NAMES and isinstance(val, list) and len(val) == 2:
|
||||
src = workflow.get(str(val[0]))
|
||||
if isinstance(src, dict) and "decode" in str(src.get("class_type") or "").lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _node_has_save_widget(node: dict[str, Any]) -> bool:
|
||||
"""A concrete save widget (filename/prefix/path) — the only signal that
|
||||
counts for unknown nodes that something else consumes; media links from
|
||||
decode steps are meaningless there (every transform has them)."""
|
||||
inputs = node.get("inputs") or {}
|
||||
return any(
|
||||
name in _SAVE_WIDGET_NAMES and isinstance(val, (str, int, float, bool))
|
||||
for name, val in inputs.items()
|
||||
)
|
||||
|
||||
|
||||
def _video_semantics(workflow: dict[str, Any]) -> bool:
|
||||
"""Auxiliary evidence the graph renders video (frame counts, video nodes)."""
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
ct = str(node.get("class_type") or "").lower()
|
||||
if "video" in ct or "animatediff" in ct:
|
||||
return True
|
||||
for name in (node.get("inputs") or {}):
|
||||
if name in _FRAME_INPUTS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _suggest_output_kind(class_type: str, videoish: bool) -> str | None:
|
||||
"""Best-guess media type for an unknown candidate — UI default only."""
|
||||
lower = class_type.lower()
|
||||
if "video" in lower or "animatediff" in lower:
|
||||
return "video"
|
||||
if "image" in lower or "save" in lower:
|
||||
return "image"
|
||||
return "video" if videoish else None
|
||||
|
||||
|
||||
def _unknown_output_entry(
|
||||
nid: str, node: dict[str, Any], workflow: dict[str, Any], videoish: bool
|
||||
) -> dict[str, Any]:
|
||||
ct = str(node.get("class_type") or "")
|
||||
return {
|
||||
"nodeId": nid,
|
||||
"label": _title_of(node) or ct,
|
||||
"role": None,
|
||||
# HEL-638: unknown custom nodes are never auto-typed — the user
|
||||
# picks the media type; `suggested_kind` is only a form default.
|
||||
"kind": None,
|
||||
"class_type": ct,
|
||||
"evidence": "unknown_node",
|
||||
"suggested_kind": _suggest_output_kind(ct, videoish),
|
||||
}
|
||||
|
||||
|
||||
def detect_output_nodes(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Infer output nodes of an untagged workflow from its sinks.
|
||||
|
||||
Exact registry classes (SaveImage, VHS_VideoCombine…) count whether or
|
||||
not something downstream consumes them (a SaveVideo feeding a
|
||||
cache-cleanup tail is still the output). Every OTHER class at an output
|
||||
position is an *unknown candidate*: kind stays null, the import page
|
||||
asks the user to designate the real output node(s) and their media
|
||||
type, and the workflow stays pending until then (HEL-638). Sinks with
|
||||
no save features only become candidates when the graph has no trusted
|
||||
output at all — next to a real saver they are utility tails.
|
||||
"""
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
consumers = _consumers(workflow)
|
||||
videoish = _video_semantics(workflow)
|
||||
registry_outputs: list[dict[str, Any]] = []
|
||||
candidates: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _is_wiring(ct: str) -> bool:
|
||||
return (
|
||||
not ct
|
||||
or ct in _SOURCE_ONLY_CLASSES
|
||||
or ct in _NON_OUTPUT_CLASSES
|
||||
or ct.startswith("Empty")
|
||||
)
|
||||
|
||||
# 1) Trusted registry outputs at sink positions.
|
||||
for nid, node in _sinks(workflow, consumers):
|
||||
ct = str(node.get("class_type") or "")
|
||||
if ct in IMAGE_OUTPUT_CLASSES or ct in VIDEO_OUTPUT_CLASSES:
|
||||
registry_outputs.append(
|
||||
{
|
||||
"nodeId": nid,
|
||||
"label": _title_of(node) or ct,
|
||||
"role": None,
|
||||
"kind": "video" if ct in VIDEO_OUTPUT_CLASSES else "image",
|
||||
"class_type": ct,
|
||||
"evidence": "registry",
|
||||
}
|
||||
)
|
||||
seen.add(nid)
|
||||
|
||||
# 2) Registry output classes consumed by other nodes are outputs too.
|
||||
for nid, node in sorted(
|
||||
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
|
||||
key=lambda kv: _node_sort_key(kv[0]),
|
||||
):
|
||||
ct = str(node.get("class_type") or "")
|
||||
if nid in seen or ct not in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
|
||||
continue
|
||||
registry_outputs.append(
|
||||
{
|
||||
"nodeId": nid,
|
||||
"label": _title_of(node) or ct,
|
||||
"role": None,
|
||||
"kind": "video" if ct in VIDEO_OUTPUT_CLASSES else "image",
|
||||
"class_type": ct,
|
||||
"evidence": "registry",
|
||||
}
|
||||
)
|
||||
seen.add(nid)
|
||||
|
||||
# 3) Unknown sink candidates. Save-looking sinks always need mapping;
|
||||
# feature-less sinks only when nothing trusted exists (utility tails
|
||||
# next to a real saver stay invisible).
|
||||
has_trusted = bool(registry_outputs)
|
||||
for nid, node in _sinks(workflow, consumers):
|
||||
ct = str(node.get("class_type") or "")
|
||||
if nid in seen or _is_wiring(ct) or ct in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
|
||||
continue
|
||||
if _unknown_sink_looks_like_save(node, workflow) or not has_trusted:
|
||||
candidates.append(_unknown_output_entry(nid, node, workflow, videoish))
|
||||
seen.add(nid)
|
||||
|
||||
# 4) Unknown save-looking nodes that something consumes (a custom saver
|
||||
# feeding a cleanup tail is still an output candidate). Only concrete
|
||||
# save widgets count here — decode-fed media links are ubiquitous among
|
||||
# mid-graph transforms (CreateVideo & co.) and never mark an output.
|
||||
for nid, node in sorted(
|
||||
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
|
||||
key=lambda kv: _node_sort_key(kv[0]),
|
||||
):
|
||||
ct = str(node.get("class_type") or "")
|
||||
if nid in seen or _is_wiring(ct) or ct in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
|
||||
continue
|
||||
if _node_has_save_widget(node):
|
||||
candidates.append(_unknown_output_entry(nid, node, workflow, videoish))
|
||||
seen.add(nid)
|
||||
|
||||
results = registry_outputs + candidates
|
||||
results.sort(key=lambda r: _node_sort_key(r["nodeId"]))
|
||||
return results
|
||||
|
||||
|
||||
# Title heuristics the studio form applies to tagged (Input:…) slots; the
|
||||
# classifier must judge a workflow the same way the form renders it.
|
||||
_TAGGED_FIRST_RE = re.compile(r"first|start|首帧", re.IGNORECASE)
|
||||
_TAGGED_LAST_RE = re.compile(r"last|end|尾帧", re.IGNORECASE)
|
||||
_TAGGED_MASK_RE = re.compile(r"mask|遮罩", re.IGNORECASE)
|
||||
|
||||
|
||||
def _slot_effective_role(entry: dict[str, Any]) -> str | None:
|
||||
"""The role the generate form will act on for this schema entry."""
|
||||
kind = str(entry.get("kind") or "")
|
||||
if kind == "image_list":
|
||||
return "reference_image"
|
||||
role = normalize_input_role(entry.get("role"))
|
||||
if entry.get("auto") or kind not in ("image", "image_url"):
|
||||
return role
|
||||
label = str(entry.get("label") or "")
|
||||
if _TAGGED_MASK_RE.search(label):
|
||||
return "mask"
|
||||
if _TAGGED_FIRST_RE.search(label):
|
||||
return "first_frame"
|
||||
if _TAGGED_LAST_RE.search(label):
|
||||
return "last_frame"
|
||||
return role
|
||||
|
||||
|
||||
def classify_usage(workflow: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Judge a workflow's usage from graph evidence (HEL-636 §2).
|
||||
|
||||
Returns ``usage`` plus everything the import confirm page shows:
|
||||
``ambiguous`` (needs a human decision), ``needs_mapping`` (unknown
|
||||
output nodes), ``problem`` (Chinese reason when usage is None) and
|
||||
``evidence`` (output nodes, image/video slots, summary lines). Never
|
||||
looks at file names, model names or fixed node ids.
|
||||
"""
|
||||
inputs = parse_dynamic_inputs(workflow) if isinstance(workflow, dict) else []
|
||||
outputs = parse_dynamic_outputs(workflow)
|
||||
media_outputs = [o for o in outputs if o.get("kind") in ("image", "video")]
|
||||
unknown_outputs = [o for o in outputs if o.get("evidence") == "unknown_node"]
|
||||
out_kinds = sorted({o["kind"] for o in media_outputs})
|
||||
|
||||
image_slots: list[dict[str, Any]] = []
|
||||
video_slots: list[dict[str, Any]] = []
|
||||
for entry in inputs:
|
||||
kind = str(entry.get("kind") or "")
|
||||
if kind in ("image", "image_list", "image_url"):
|
||||
image_slots.append(
|
||||
{
|
||||
"nodeId": entry.get("nodeId"),
|
||||
"label": entry.get("label"),
|
||||
"kind": kind,
|
||||
"role": _slot_effective_role(entry),
|
||||
}
|
||||
)
|
||||
elif kind == "video":
|
||||
video_slots.append(
|
||||
{
|
||||
"nodeId": entry.get("nodeId"),
|
||||
"label": entry.get("label"),
|
||||
"kind": kind,
|
||||
"role": normalize_input_role(entry.get("role")),
|
||||
}
|
||||
)
|
||||
|
||||
effective = [s["role"] or "image" for s in image_slots if s["role"] != "mask"]
|
||||
bulk = [r for r in effective if r in (None, "image")]
|
||||
has_frame = any(r in ("first_frame", "last_frame") for r in effective)
|
||||
has_ref_group = any(s["kind"] == "image_list" for s in image_slots)
|
||||
|
||||
summary: list[str] = []
|
||||
for o in media_outputs:
|
||||
zh = "图片" if o["kind"] == "image" else "视频"
|
||||
summary.append(f"输出:节点 {o['nodeId']}({o.get('class_type') or o['label']})→ {zh}")
|
||||
if unknown_outputs:
|
||||
names = "、".join(
|
||||
f"节点 {o['nodeId']}({o.get('class_type') or '?'})" for o in unknown_outputs[:4]
|
||||
)
|
||||
summary.append(f"存在无法识别的输出节点:{names},需人工指定输出节点及媒体类型")
|
||||
for s in image_slots:
|
||||
if s["role"] != "mask":
|
||||
summary.append(f"图片输入:节点 {s['nodeId']}({s['label']})")
|
||||
if video_slots:
|
||||
summary.append("含视频输入槽(本版本不支持参考视频上传)")
|
||||
|
||||
usage: str | None = None
|
||||
ambiguous = False
|
||||
problem: str | None = None
|
||||
if not out_kinds and not unknown_outputs:
|
||||
problem = (
|
||||
"未在图中找到输出节点(SaveImage / 视频合成等),"
|
||||
"请确认导出的是 API 格式且包含保存节点"
|
||||
)
|
||||
elif unknown_outputs:
|
||||
# HEL-638: any unknown custom output node keeps the workflow
|
||||
# pending — no name-based guessing, and picking a usage alone must
|
||||
# not bypass the output-node designation.
|
||||
names = "、".join(
|
||||
f"节点 {o['nodeId']}({o.get('class_type') or '?'})" for o in unknown_outputs[:4]
|
||||
)
|
||||
ambiguous = True
|
||||
problem = (
|
||||
f"存在无法识别的输出节点({names}),"
|
||||
"需人工指定实际输出节点及其媒体类型(图片/视频)后才能保存启用"
|
||||
)
|
||||
elif len(out_kinds) > 1:
|
||||
ambiguous = True
|
||||
problem = "同一工作流同时输出图片和视频,需人工选择主用途"
|
||||
elif len(out_kinds) == 1:
|
||||
if out_kinds[0] == "image":
|
||||
usage = "image2image" if bulk else "text2image"
|
||||
elif has_ref_group or len(bulk) >= 2:
|
||||
usage = "ref2video"
|
||||
elif has_frame and not bulk:
|
||||
usage = "image2video"
|
||||
elif not effective:
|
||||
usage = "text2video"
|
||||
else:
|
||||
ambiguous = True
|
||||
problem = "视频工作流中的单张图片输入无法判定是首帧还是参考图,需人工确认"
|
||||
|
||||
return {
|
||||
"usage": usage,
|
||||
"ambiguous": ambiguous,
|
||||
"needs_mapping": bool(unknown_outputs),
|
||||
"problem": problem,
|
||||
"evidence": {
|
||||
"outputs": outputs,
|
||||
"image_slots": image_slots,
|
||||
"video_slots": video_slots,
|
||||
"summary": summary,
|
||||
},
|
||||
"usable_usage": list(USAGE_VALUES),
|
||||
}
|
||||
|
||||
@@ -5,12 +5,45 @@ from __future__ import annotations
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.registry import class_to_patch_field
|
||||
from calliope.comfyui.registry import (
|
||||
AUDIO_CLASSES,
|
||||
IMAGE_CLASSES,
|
||||
VIDEO_CLASSES,
|
||||
VIDEO_FILE_CLASSES,
|
||||
class_to_patch_field,
|
||||
optional_frame_sibling,
|
||||
)
|
||||
|
||||
# Input keys of the form "<nodeId>@<widget>" target one named widget on a
|
||||
# node (auto-detected inputs: KSampler seed/steps/cfg, latent width/height…).
|
||||
FIELD_KEY_SEP = "@"
|
||||
|
||||
# Media-loader families and the Chinese slot label used on cloned nodes.
|
||||
_GROUP_LABELS: tuple[tuple[frozenset[str], str], ...] = (
|
||||
(IMAGE_CLASSES, "参考图"),
|
||||
(VIDEO_CLASSES, "参考视频"),
|
||||
(AUDIO_CLASSES, "参考音频"),
|
||||
)
|
||||
|
||||
|
||||
def _loader_label(class_type: str) -> str:
|
||||
for classes, label in _GROUP_LABELS:
|
||||
if class_type in classes:
|
||||
return label
|
||||
return "参考素材"
|
||||
|
||||
|
||||
def _loader_field(class_type: str, inputs: dict[str, Any]) -> str:
|
||||
"""The widget a loader node of this class stores its file value in."""
|
||||
if class_type in IMAGE_CLASSES:
|
||||
return "image"
|
||||
if class_type in VIDEO_CLASSES:
|
||||
return "file" if class_type in VIDEO_FILE_CLASSES else "video"
|
||||
if class_type in AUDIO_CLASSES:
|
||||
# VHS_LoadAudio names its widget "audio:" (with the colon).
|
||||
return "audio" if "audio" in inputs else "audio:"
|
||||
return "image"
|
||||
|
||||
|
||||
def _resolve_field(field: str, inputs: dict[str, Any]) -> str:
|
||||
"""Map the computed patch field onto a key that exists on this node.
|
||||
@@ -38,6 +71,98 @@ def resolve_patch_field(node: dict[str, Any]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _next_node_id(workflow: dict[str, Any]) -> str:
|
||||
numeric = [int(k) for k in workflow if str(k).isdigit()]
|
||||
return str(max(numeric, default=0) + 1)
|
||||
|
||||
|
||||
def apply_media_group(
|
||||
workflow: dict[str, Any], node_id: str, group: str, paths: list[str]
|
||||
) -> None:
|
||||
"""Rewire an Autogrow media group (image/video/audio) to exactly ``paths``.
|
||||
|
||||
``<group>.<prefix>0 … N-1`` each get their own loader node: the
|
||||
workflow's existing ones are reused in slot order, extra ones are clones
|
||||
of the first, and slots/loaders left over from the export are removed so
|
||||
no stale media reaches ComfyUI. File values are local paths —
|
||||
``prepare_media_inputs`` uploads them right before queueing.
|
||||
"""
|
||||
from calliope.comfyui.parser import image_group_prefix, image_group_slots
|
||||
|
||||
node = workflow.get(node_id)
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
prefix = image_group_prefix(node, group)
|
||||
if prefix is None:
|
||||
return
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
slots = image_group_slots(node, group, prefix)
|
||||
loaders: list[str] = []
|
||||
for _n, name, link in slots:
|
||||
inputs.pop(name, None)
|
||||
if isinstance(link, list) and len(link) == 2:
|
||||
src = workflow.get(str(link[0]))
|
||||
if isinstance(src, dict) and str(src.get("class_type") or "") in (
|
||||
IMAGE_CLASSES | VIDEO_CLASSES | AUDIO_CLASSES
|
||||
):
|
||||
loaders.append(str(link[0]))
|
||||
if not loaders:
|
||||
return
|
||||
template = copy.deepcopy(workflow[loaders[0]])
|
||||
label = _loader_label(str(template.get("class_type") or ""))
|
||||
|
||||
still_used: set[str] = set()
|
||||
for nid, other in workflow.items():
|
||||
if nid == node_id or not isinstance(other, dict):
|
||||
continue
|
||||
for val in (other.get("inputs") or {}).values():
|
||||
if isinstance(val, list) and len(val) == 2:
|
||||
still_used.add(str(val[0]))
|
||||
for nid in loaders[len(paths):]:
|
||||
if nid not in still_used and nid not in loaders[: len(paths)]:
|
||||
workflow.pop(nid, None)
|
||||
|
||||
for i, path in enumerate(paths):
|
||||
if i < len(loaders):
|
||||
nid = loaders[i]
|
||||
loader = workflow[nid]
|
||||
else:
|
||||
nid = _next_node_id(workflow)
|
||||
loader = copy.deepcopy(template)
|
||||
loader_inputs = dict(loader.get("inputs") or {})
|
||||
field = _loader_field(str(loader.get("class_type") or ""), loader_inputs)
|
||||
loader_inputs[field] = path
|
||||
loader["inputs"] = loader_inputs
|
||||
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{label} {i + 1}"}
|
||||
workflow[nid] = loader
|
||||
inputs[f"{group}.{prefix}{i}"] = [nid, 0]
|
||||
node["inputs"] = inputs
|
||||
|
||||
|
||||
# Original name kept: image groups were the first (and most common) family.
|
||||
apply_image_group = apply_media_group
|
||||
|
||||
|
||||
def apply_optional_frame(
|
||||
workflow: dict[str, Any], node_id: str, field: str, sibling: str, path: str
|
||||
) -> None:
|
||||
"""Wire ``path`` into an unwired optional frame input (H3 ``last_frame``)
|
||||
through a new LoadImage cloned from the ``sibling`` frame's loader."""
|
||||
node = workflow[node_id]
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
src = workflow.get(str(inputs[sibling][0]))
|
||||
if isinstance(src, dict) and str(src.get("class_type") or "") in IMAGE_CLASSES:
|
||||
loader = copy.deepcopy(src)
|
||||
else:
|
||||
loader = {"class_type": "LoadImage", "inputs": {}}
|
||||
loader["inputs"] = {**(loader.get("inputs") or {}), "image": path}
|
||||
loader["_meta"] = {"title": "尾帧" if field == "last_frame" else field}
|
||||
nid = _next_node_id(workflow)
|
||||
workflow[nid] = loader
|
||||
inputs[field] = [nid, 0]
|
||||
node["inputs"] = inputs
|
||||
|
||||
|
||||
def patch_workflow(
|
||||
base: dict[str, Any],
|
||||
values_by_node_id: dict[str, Any],
|
||||
@@ -54,6 +179,19 @@ def patch_workflow(
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
if field is not None and field not in inputs and any(
|
||||
k.startswith(f"{field}.") for k in inputs
|
||||
):
|
||||
paths = [value] if isinstance(value, str) else value
|
||||
if isinstance(paths, list):
|
||||
clean = [p for p in paths if isinstance(p, str) and p.strip()]
|
||||
if clean:
|
||||
apply_media_group(patched, key, field, clean)
|
||||
continue
|
||||
if field is not None and (sibling := optional_frame_sibling(node, field)):
|
||||
if isinstance(value, str) and value.strip():
|
||||
apply_optional_frame(patched, key, field, sibling, value)
|
||||
continue
|
||||
if field is None:
|
||||
field = resolve_patch_field(node)
|
||||
elif isinstance(inputs.get(field), list):
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""L2 connection preflight: check a workflow against a live /object_info (HEL-642/643).
|
||||
|
||||
Layer 1 (lint.py) judges the JSON on its own; this module judges the JSON
|
||||
against what the target ComfyUI actually has installed. Two generic rules,
|
||||
zero hard-coding for any specific workflow or model name:
|
||||
|
||||
1. Node existence — every ``class_type`` in the JSON must exist as a key of
|
||||
the server's /object_info catalogue; missing ones are custom nodes the
|
||||
user has not installed.
|
||||
2. Combo value legality — for every combo input the server declares (an
|
||||
input whose spec carries a candidate list — unet_name/clip_name/
|
||||
sampler/scheduler/…), the JSON's current widget value must be one of the
|
||||
candidates; a miss usually means a model file or component is missing.
|
||||
|
||||
File-upload widgets (LoadImage.image and friends) are skipped: their combo
|
||||
lists are the server's input directory at export time and the value is
|
||||
replaced by an upload before every submit, so a miss there says nothing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.registry import AUDIO_CLASSES, IMAGE_CLASSES, VIDEO_CLASSES
|
||||
|
||||
# Media-loader classes and the widget names that hold their file values —
|
||||
# upload-backed combos that cannot be judged before an upload happens.
|
||||
_MEDIA_LOADER_FIELDS: tuple[frozenset[str], frozenset[str]] = (
|
||||
(IMAGE_CLASSES, frozenset({"image"})),
|
||||
(AUDIO_CLASSES, frozenset({"audio", "audio:"})),
|
||||
(VIDEO_CLASSES, frozenset({"file", "video"})),
|
||||
)
|
||||
|
||||
# object_info widget-config keys that mark an input as file-upload backed.
|
||||
_UPLOAD_CONFIG_KEYS = ("image_upload", "audio_upload", "video_upload", "upload")
|
||||
|
||||
_MAX_CANDIDATES_SHOWN = 6
|
||||
|
||||
|
||||
def _node_title(node: dict[str, Any]) -> str:
|
||||
title = (node.get("_meta") or {}).get("title")
|
||||
return str(title).strip() if title else ""
|
||||
|
||||
|
||||
def _is_upload_backed(class_type: str, input_name: str, config: Any) -> bool:
|
||||
if isinstance(config, dict) and any(config.get(k) for k in _UPLOAD_CONFIG_KEYS):
|
||||
return True
|
||||
for classes, fields in _MEDIA_LOADER_FIELDS:
|
||||
if class_type in classes and input_name in fields:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _combo_candidates(spec: Any) -> list[str] | None:
|
||||
"""The candidate list when ``spec`` is a combo input, else None.
|
||||
|
||||
A combo spec is ``[["a", "b"], {config}]`` — first element a list of
|
||||
plain strings. Typed inputs (``["FLOAT", {…}]``) return None.
|
||||
"""
|
||||
if not (isinstance(spec, list) and len(spec) >= 1):
|
||||
return None
|
||||
values = spec[0]
|
||||
if not isinstance(values, list) or not values:
|
||||
return None
|
||||
if not all(isinstance(v, str) for v in values):
|
||||
return None
|
||||
return values
|
||||
|
||||
|
||||
def check_workflow_against(
|
||||
object_info: dict[str, Any], workflow: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Findings for every problem the target server's catalogue reveals.
|
||||
|
||||
Each finding is ``{level, code, node_id, node_title, message, hint}``
|
||||
(``level`` is always ``"error"`` for now — both rules are hard misses).
|
||||
"""
|
||||
findings: list[dict[str, Any]] = []
|
||||
if not isinstance(object_info, dict) or not isinstance(workflow, dict):
|
||||
return findings
|
||||
for node_id, node in workflow.items():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
class_type = str(node.get("class_type") or "")
|
||||
title = _node_title(node)
|
||||
label = f"节点 {node_id}「{title}」" if title else f"节点 {node_id}"
|
||||
spec = object_info.get(class_type)
|
||||
if not isinstance(spec, dict):
|
||||
findings.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "missing_node",
|
||||
"node_id": str(node_id),
|
||||
"node_title": title,
|
||||
"message": (
|
||||
f"缺少自定义节点 {class_type or '(未知类名)'}({label})——"
|
||||
"该 ComfyUI 未安装此节点,需先安装对应的节点包"
|
||||
),
|
||||
"hint": "安装节点包后回到本页点「重新检查」即可复查,无需重新导入。",
|
||||
}
|
||||
)
|
||||
continue
|
||||
declared = (spec.get("input") or {}) if isinstance(spec.get("input"), dict) else {}
|
||||
inputs = node.get("inputs") or {}
|
||||
for section in ("required", "optional"):
|
||||
entries = declared.get(section)
|
||||
if not isinstance(entries, dict):
|
||||
continue
|
||||
for input_name, in_spec in entries.items():
|
||||
value = inputs.get(input_name)
|
||||
# Wired inputs (links) and absent widgets are not combos to judge.
|
||||
if isinstance(value, (list, dict)) or value is None:
|
||||
continue
|
||||
config = in_spec[1] if isinstance(in_spec, list) and len(in_spec) > 1 else None
|
||||
if _is_upload_backed(class_type, str(input_name), config):
|
||||
continue
|
||||
candidates = _combo_candidates(in_spec)
|
||||
if candidates is None:
|
||||
continue
|
||||
if value in candidates:
|
||||
continue
|
||||
shown = "、".join(candidates[:_MAX_CANDIDATES_SHOWN])
|
||||
if len(candidates) > _MAX_CANDIDATES_SHOWN:
|
||||
shown += f" 等 {len(candidates)} 项"
|
||||
findings.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "value_not_in_list",
|
||||
"node_id": str(node_id),
|
||||
"node_title": title,
|
||||
"message": (
|
||||
f"{label}:值「{value}」不在 ComfyUI 的可选列表"
|
||||
f"(参数 {input_name})——多为对应的模型文件或组件未安装"
|
||||
),
|
||||
"hint": f"该 ComfyUI 上可选:{shown}" if shown else None,
|
||||
}
|
||||
)
|
||||
return findings
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Unified class_type → kind + patch-field registry (MuseAgent port)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
ComfyInputKind = Literal["text", "textarea", "number", "image", "image_url", "audio", "video"]
|
||||
ComfyOutputKind = Literal["image", "video", "other"]
|
||||
@@ -30,8 +30,27 @@ VIDEO_OUTPUT_CLASSES = frozenset(
|
||||
{"VHS_VideoCombine", "SaveVideo", "VideoOutput", "AnimateDiffCombine"}
|
||||
)
|
||||
IMAGE_OUTPUT_CLASSES = frozenset(
|
||||
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket"}
|
||||
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket",
|
||||
"SaveImageAdvanced"}
|
||||
)
|
||||
# Frame-conditioned video nodes and the optional frame input an export may
|
||||
# leave unwired. MiniMax H3's FL2VA node takes `first_frame` plus an
|
||||
# optional `last_frame`; the studio offers the slot and the patcher adds
|
||||
# the LoadImage only when the user supplies that frame.
|
||||
OPTIONAL_FRAME_INPUTS: dict[str, dict[str, str]] = {
|
||||
"MiniMaxH3ImageToVideo": {"last_frame": "first_frame"},
|
||||
}
|
||||
|
||||
|
||||
def optional_frame_sibling(node: dict[str, Any], field: str) -> str | None:
|
||||
"""The wired frame input an unwired optional ``field`` mirrors
|
||||
(``last_frame`` → ``first_frame``); None when it does not apply."""
|
||||
inputs = node.get("inputs") or {}
|
||||
sibling = OPTIONAL_FRAME_INPUTS.get(str(node.get("class_type") or ""), {}).get(field)
|
||||
if sibling is None or field in inputs:
|
||||
return None
|
||||
link = inputs.get(sibling)
|
||||
return sibling if isinstance(link, list) and len(link) == 2 else None
|
||||
|
||||
|
||||
def class_to_input_kind(class_type: str) -> ComfyInputKind:
|
||||
|
||||
@@ -30,6 +30,27 @@ OUTPUT_ROLE_ALIASES: dict[str, frozenset[str]] = {
|
||||
"video": frozenset({"video", "vid"}),
|
||||
}
|
||||
|
||||
# The five workflow usages the studio distinguishes (HEL-636). Derived from
|
||||
# graph evidence only (output media + consumed image inputs) — never from
|
||||
# file names, model names or fixed node ids.
|
||||
WorkflowUsage = str
|
||||
USAGE_VALUES: tuple[str, ...] = (
|
||||
"text2image",
|
||||
"image2image",
|
||||
"text2video",
|
||||
"image2video",
|
||||
"ref2video",
|
||||
)
|
||||
|
||||
|
||||
def usage_to_kind(usage: str | None) -> str | None:
|
||||
"""Output media kind of a usage (image / video); None when unknown."""
|
||||
if usage in ("text2image", "image2image"):
|
||||
return "image"
|
||||
if usage in ("text2video", "image2video", "ref2video"):
|
||||
return "video"
|
||||
return None
|
||||
|
||||
|
||||
def parse_title_tag(title: str) -> tuple[str | None, str | None, str]:
|
||||
"""Return (kind, role, label) from a Comfy node title.
|
||||
|
||||
@@ -158,8 +158,12 @@ def smart_fill_inputs(
|
||||
break
|
||||
|
||||
if ref_images:
|
||||
for inp, path in zip(ref_image_slots(inputs), ref_images):
|
||||
values[str(inp["nodeId"])] = path
|
||||
group = next((inp for inp in inputs if inp.get("kind") == "image_list"), None)
|
||||
if group is not None:
|
||||
values[str(group["nodeId"])] = list(ref_images[: group.get("max") or len(ref_images)])
|
||||
else:
|
||||
for inp, path in zip(ref_image_slots(inputs), ref_images):
|
||||
values[str(inp["nodeId"])] = path
|
||||
|
||||
if ref_videos:
|
||||
for inp, path in zip(ref_video_slots(inputs), ref_videos):
|
||||
|
||||
@@ -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
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -19,8 +20,9 @@ def validate_input_values(
|
||||
``min``/``max``); select values must be one of ``options``. Blank values
|
||||
are dropped so the workflow keeps its own value. Keys not in the schema
|
||||
pass through untouched (other callers patch extra keys, e.g. uploads).
|
||||
Only an auto-detected positive prompt must be present — tagged workflows
|
||||
mark every input required, and blank ones fall back to the workflow's value.
|
||||
Auto-detected required entries (positive prompt, wired image slots,
|
||||
reference-image lists) must be present — tagged workflows mark every
|
||||
input required, and blank ones fall back to the workflow's value.
|
||||
"""
|
||||
by_key = {str(e.get("nodeId")): e for e in schema if isinstance(e, dict)}
|
||||
clean: dict[str, Any] = {}
|
||||
@@ -61,6 +63,30 @@ def validate_input_values(
|
||||
errors.append(f"「{label}」不能大于 {_fmt(hi)}")
|
||||
continue
|
||||
clean[key] = value
|
||||
elif kind in ("image_list", "video_list", "audio_list"):
|
||||
paths = [raw] if isinstance(raw, str) else raw
|
||||
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
|
||||
errors.append(f"「{label}」的取值格式不对,请重新上传")
|
||||
continue
|
||||
paths = [p for p in paths if p.strip()]
|
||||
unit = _LIST_UNITS[kind]
|
||||
noun = _LIST_NOUNS[kind]
|
||||
hi = entry.get("max")
|
||||
if hi is not None and len(paths) > hi:
|
||||
errors.append(
|
||||
f"{label}最多 {hi} {unit}(当前 {len(paths)} {unit}),请删除多余的{noun}"
|
||||
)
|
||||
continue
|
||||
bad = [
|
||||
f"{label} {i}:{msg}"
|
||||
for i, p in enumerate(paths, 1)
|
||||
if (msg := _media_file_problem(p, kind))
|
||||
]
|
||||
if bad:
|
||||
errors.extend(bad)
|
||||
continue
|
||||
if paths:
|
||||
clean[key] = paths
|
||||
elif kind == "select":
|
||||
options = entry.get("options") or []
|
||||
if options and raw not in options:
|
||||
@@ -70,8 +96,51 @@ def validate_input_values(
|
||||
else:
|
||||
clean[key] = raw
|
||||
for key, entry in by_key.items():
|
||||
required_prompt = entry.get("auto") and entry.get("required") and entry.get("role") == "prompt"
|
||||
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
|
||||
if required_prompt and key not in clean and not has_fallback:
|
||||
errors.append(f"请填写「{entry.get('label') or key}」")
|
||||
required = entry.get("auto") and entry.get("required")
|
||||
label = entry.get("label") or key
|
||||
if required and entry.get("role") == "prompt":
|
||||
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
|
||||
if key not in clean and not has_fallback:
|
||||
errors.append(f"请填写「{label}」")
|
||||
elif required and entry.get("kind") in ("image_list", "video_list", "audio_list"):
|
||||
lo = entry.get("min") or 1
|
||||
unit = _LIST_UNITS[entry.get("kind")]
|
||||
if len(clean.get(key) or []) < lo and not any(label in e for e in errors):
|
||||
errors.append(f"需要至少 {lo} {unit}{label}")
|
||||
elif required and entry.get("kind") == "image" and entry.get("role") != "mask":
|
||||
if key not in clean:
|
||||
errors.append(f"请上传{label}")
|
||||
return clean, errors
|
||||
|
||||
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp"})
|
||||
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mov"})
|
||||
_AUDIO_EXTS = frozenset({".mp3", ".wav", ".m4a"})
|
||||
_LIST_UNITS = {"image_list": "张", "video_list": "段", "audio_list": "段"}
|
||||
_LIST_NOUNS = {"image_list": "图片", "video_list": "视频", "audio_list": "音频"}
|
||||
_LIST_EXTS = {"image_list": _IMAGE_EXTS, "video_list": _VIDEO_EXTS, "audio_list": _AUDIO_EXTS}
|
||||
_LIST_FORMAT_HINT = {
|
||||
"image_list": "格式不支持,请使用 PNG / JPG / WebP",
|
||||
"video_list": "格式不支持,请使用 MP4 / WebM / MOV",
|
||||
"audio_list": "格式不支持,请使用 MP3 / WAV / M4A",
|
||||
}
|
||||
|
||||
|
||||
def _media_file_problem(path: str, kind: str) -> str | None:
|
||||
"""Why ``path`` cannot fill a media-list slot of this kind (None = fine)."""
|
||||
from calliope import config
|
||||
|
||||
target = Path(path)
|
||||
if target.suffix.lower() not in _LIST_EXTS[kind]:
|
||||
return _LIST_FORMAT_HINT[kind]
|
||||
try:
|
||||
target.resolve().relative_to(config.settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return f"只能使用在本页上传或从生成结果选入的{_LIST_NOUNS[kind]}"
|
||||
if not target.is_file():
|
||||
return "文件已不存在,请重新上传"
|
||||
return None
|
||||
|
||||
|
||||
def _image_file_problem(path: str) -> str | None:
|
||||
return _media_file_problem(path, "image_list")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -121,7 +122,11 @@ CREATE TABLE IF NOT EXISTS workflows (
|
||||
description TEXT,
|
||||
prompt_profile TEXT NOT NULL DEFAULT 'prose',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
usage TEXT,
|
||||
usage_source TEXT,
|
||||
output_map TEXT,
|
||||
output_map_source TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
@@ -306,6 +311,36 @@ CREATE TABLE IF NOT EXISTS project_stages (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_stages_project ON project_stages(project_id);
|
||||
|
||||
-- B4: immutable source identity, editable draft, optimistic concurrency.
|
||||
CREATE TABLE IF NOT EXISTS agent_deliverables (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
source_message_id INTEGER NOT NULL,
|
||||
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
|
||||
stage_key TEXT NOT NULL CHECK(stage_key IN ('script','style','storyboard','assets','shot')),
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(session_id, source_message_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS agent_deliverable_versions (
|
||||
deliverable_id INTEGER NOT NULL REFERENCES agent_deliverables(id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(deliverable_id, revision)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS result_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_key TEXT NOT NULL,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
stage_key TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(source_key, project_id, stage_key)
|
||||
);
|
||||
|
||||
-- Audit trail of agent runs inside the flow (HEL-614): instruction in,
|
||||
-- normalized JSON artifact out, or a Chinese-readable failure reason.
|
||||
CREATE TABLE IF NOT EXISTS stage_runs (
|
||||
@@ -338,6 +373,111 @@ def _sqlite_connect(path: Path) -> sqlite3.Connection:
|
||||
return sqlite3.connect(path)
|
||||
|
||||
|
||||
def _backfill_workflow_usage(conn: sqlite3.Connection) -> None:
|
||||
"""HEL-636: settle `workflows.usage` for rows that still have none.
|
||||
|
||||
Determinate graphs get ``usage='auto'``; indeterminate ones keep NULL
|
||||
and are disabled (they must be confirmed on the connections page before
|
||||
they can generate). Rows the user already confirmed (usage_source='user')
|
||||
never pass through here again — their usage is set, so the WHERE clause
|
||||
skips them. Old jobs / history / `kind` semantics are untouched.
|
||||
"""
|
||||
from calliope.comfyui.parser import classify_usage
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, workflow_json FROM workflows WHERE usage IS NULL OR usage = ''"
|
||||
).fetchall()
|
||||
for row in rows: # plain connect — positional access
|
||||
try:
|
||||
workflow = json.loads(row[1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
workflow = None
|
||||
judgment = classify_usage(workflow) if isinstance(workflow, dict) else {}
|
||||
if judgment.get("usage"):
|
||||
conn.execute(
|
||||
"UPDATE workflows SET usage = ?, usage_source = 'auto' WHERE id = ?",
|
||||
(judgment["usage"], row[0]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE workflows SET is_enabled = 0 WHERE id = ? AND usage IS NULL",
|
||||
(row[0],),
|
||||
)
|
||||
|
||||
|
||||
def _backfill_workflow_output_map(conn: sqlite3.Connection) -> None:
|
||||
"""HEL-638: settle the output-node mapping for existing rows.
|
||||
|
||||
Rows the user already mapped (output_map_source='user') are never
|
||||
touched. Determinate graphs get the trusted ``auto`` map. Rows whose
|
||||
graph now contains unknown custom output candidates get no auto map and
|
||||
are parked disabled — the usage decision survives, but until the user
|
||||
designates the output nodes the workflow must not generate.
|
||||
"""
|
||||
from calliope.comfyui.output_map import auto_output_map, needs_user_output_map
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, workflow_json FROM workflows"
|
||||
" WHERE output_map_source IS NULL OR output_map_source != 'user'"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
try:
|
||||
workflow = json.loads(row[1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
workflow = None
|
||||
if not isinstance(workflow, dict):
|
||||
continue
|
||||
if needs_user_output_map(workflow):
|
||||
conn.execute(
|
||||
"UPDATE workflows SET output_map = NULL, output_map_source = NULL,"
|
||||
" is_enabled = 0 WHERE id = ?",
|
||||
(row[0],),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE workflows SET output_map = ?, output_map_source = 'auto'"
|
||||
" WHERE id = ?",
|
||||
(json.dumps(auto_output_map(workflow)), row[0]),
|
||||
)
|
||||
|
||||
|
||||
# HEL-671: the user's H3 import went in under a first-frame-only name
|
||||
# (“H3图生视频·首帧…”) although its graph is the FL2VA node that also takes
|
||||
# a last frame. A row is renamed only when BOTH hold: its graph carries a
|
||||
# wired MiniMaxH3ImageToVideo node, and its name still reads like a
|
||||
# first-frame-only workflow — a row the user renamed, or any other graph,
|
||||
# is left alone. Idempotent: the new name no longer matches.
|
||||
_FIRST_FRAME_NAME_RE = re.compile(r"图生视频\s*[·\-—~]?\s*首帧")
|
||||
_FL2VA_DESCRIPTION = "首帧必填,尾帧可选:只传首帧为图生视频,同时传尾帧为首尾帧生视频"
|
||||
|
||||
|
||||
def _rename_workflows(conn: sqlite3.Connection) -> None:
|
||||
for wid, name, wf_json in conn.execute(
|
||||
"SELECT id, name, workflow_json FROM workflows"
|
||||
).fetchall():
|
||||
if not name or "尾帧" in name or not _FIRST_FRAME_NAME_RE.search(name):
|
||||
continue
|
||||
try:
|
||||
workflow = json.loads(wf_json) if wf_json else None
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if not isinstance(workflow, dict):
|
||||
continue
|
||||
fl2va = any(
|
||||
isinstance(node, dict)
|
||||
and str(node.get("class_type") or "") == "MiniMaxH3ImageToVideo"
|
||||
and isinstance((node.get("inputs") or {}).get("first_frame"), list)
|
||||
for node in workflow.values()
|
||||
)
|
||||
if not fl2va:
|
||||
continue
|
||||
conn.execute(
|
||||
"UPDATE workflows SET name = ?,"
|
||||
" description = COALESCE(NULLIF(description, ''), ?) WHERE id = ?",
|
||||
(_FIRST_FRAME_NAME_RE.sub("首尾帧生视频", name), _FL2VA_DESCRIPTION, wid),
|
||||
)
|
||||
|
||||
|
||||
async def migrate_db(db_path: Path) -> None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
@@ -383,6 +523,45 @@ async def migrate_db(db_path: Path) -> None:
|
||||
# comfyui_endpoints id). Dangling ids are tolerated — enqueue falls
|
||||
# back to the active connection.
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN endpoint_id TEXT")
|
||||
# HEL-636: five-way usage (text2image…ref2video). NULL = pending human
|
||||
# confirmation; `kind` keeps meaning output media for history/paths.
|
||||
if "usage" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN usage TEXT")
|
||||
if "usage_source" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN usage_source TEXT")
|
||||
# HEL-638: explicit output-node designation for unknown custom output
|
||||
# nodes ({nodeId, kind} JSON + 'user'|'auto'). Idempotent backfill
|
||||
# below parks unmapped unknown-output rows (disabled) like usage does.
|
||||
if "output_map" not in cols:
|
||||
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)
|
||||
_backfill_workflow_output_map(conn)
|
||||
_rename_workflows(conn)
|
||||
scene_cols = {r[1] for r in conn.execute("PRAGMA table_info(scenes)").fetchall()}
|
||||
if "location_id" not in scene_cols:
|
||||
conn.execute("ALTER TABLE scenes ADD COLUMN location_id INTEGER")
|
||||
@@ -429,10 +608,22 @@ async def migrate_db(db_path: Path) -> None:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN comfy_prompt_id TEXT")
|
||||
if "phase" not in job_cols:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN phase TEXT")
|
||||
# HEL-643: where a job came from — 'user' (every normal generation) or
|
||||
# 'workflow_test' (L3 试运行). Existing rows default to 'user', semantics
|
||||
# unchanged; test jobs get isolated artifacts and no entity write-back.
|
||||
if "origin" not in job_cols:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'user'")
|
||||
# Build Scene's per-scene sessions: origin stamps WHERE a blind session was
|
||||
# born ('chat' = user-created in AI Canvas, 'scene' = auto-created by the
|
||||
# Build Scene rail). The session list excludes origin='scene' so scene
|
||||
# sessions don't pollute the AI Canvas sidebar; Build Scene filters on it.
|
||||
b4_cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_sessions)")}
|
||||
if "task_summary" not in b4_cols:
|
||||
conn.execute("ALTER TABLE agent_sessions ADD COLUMN task_summary TEXT NOT NULL DEFAULT ''")
|
||||
if "task_status" not in b4_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE agent_sessions ADD COLUMN task_status TEXT NOT NULL DEFAULT 'ready'"
|
||||
)
|
||||
session_cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_sessions)").fetchall()}
|
||||
if "origin" not in session_cols:
|
||||
conn.execute("ALTER TABLE agent_sessions ADD COLUMN origin TEXT NOT NULL DEFAULT 'chat'")
|
||||
|
||||
@@ -20,19 +20,24 @@ from calliope.routers import (
|
||||
agent,
|
||||
assets,
|
||||
canvas,
|
||||
comfyui as comfy,
|
||||
events,
|
||||
jobs,
|
||||
library,
|
||||
playground,
|
||||
projects,
|
||||
results,
|
||||
scenes,
|
||||
settings as settings_router,
|
||||
shots,
|
||||
story,
|
||||
workbench,
|
||||
workflows,
|
||||
)
|
||||
from calliope.routers import (
|
||||
comfyui as comfy,
|
||||
)
|
||||
from calliope.routers import (
|
||||
settings as settings_router,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -141,6 +146,7 @@ def create_app(static_dir: Path | None = None) -> FastAPI:
|
||||
app.include_router(library.router, prefix="/api/library", tags=["library"])
|
||||
app.include_router(canvas.router, prefix="/api/canvas", tags=["canvas"])
|
||||
app.include_router(shots.router, prefix="/api/shots", tags=["shots"])
|
||||
app.include_router(results.router, prefix="/api/results", tags=["results"])
|
||||
app.include_router(agent.router, prefix="/api/agent", tags=["agent"])
|
||||
app.include_router(workbench.router, prefix="/api/workbench", tags=["workbench"])
|
||||
app.include_router(events.router, prefix="/api/events", tags=["events"])
|
||||
|
||||
@@ -198,14 +198,35 @@ class ExpandClipsRequest(BaseModel):
|
||||
clip_cap: int | None = None
|
||||
|
||||
|
||||
WorkflowUsageKind = Literal[
|
||||
"text2image",
|
||||
"image2image",
|
||||
"text2video",
|
||||
"image2video",
|
||||
"ref2video",
|
||||
]
|
||||
|
||||
|
||||
class WorkflowCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1)
|
||||
kind: Literal["image", "video"] = "image"
|
||||
kind: Literal["image", "video"] | None = None
|
||||
workflow_json: dict[str, Any]
|
||||
description: str | None = None
|
||||
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
|
||||
# Optional binding to a ComfyUI connection id (settings.comfyui_endpoints).
|
||||
endpoint_id: str | None = None
|
||||
# HEL-636: five-way usage. Given by the import confirm page (possibly a
|
||||
# user correction of the auto judgment); when absent it is classified
|
||||
# from the graph, and indeterminate workflows stay pending (disabled).
|
||||
usage: WorkflowUsageKind | None = None
|
||||
usage_source: Literal["auto", "user"] | None = None
|
||||
# Schema corrected on the confirm page (manual role overrides included).
|
||||
input_schema: list[dict[str, Any]] | None = None
|
||||
# HEL-638: explicit output-node designation for unknown custom output
|
||||
# nodes — [{nodeId, kind: image|video}]. Required before a workflow
|
||||
# with unknown outputs can be enabled or generated.
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
output_map_source: Literal["auto", "user"] | None = None
|
||||
|
||||
|
||||
class WorkflowUpdate(BaseModel):
|
||||
@@ -215,10 +236,83 @@ class WorkflowUpdate(BaseModel):
|
||||
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
|
||||
is_enabled: bool | None = None
|
||||
endpoint_id: str | None = None
|
||||
usage: WorkflowUsageKind | None = None
|
||||
usage_source: Literal["auto", "user"] | None = None
|
||||
input_schema: list[dict[str, Any]] | None = None
|
||||
# HEL-638: set/replace the user output mapping; an explicit empty list
|
||||
# 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):
|
||||
workflow_json: dict[str, Any]
|
||||
# HEL-643: optional target connection — given, the analyze response also
|
||||
# carries an L2 connection check (node/model availability via /object_info).
|
||||
endpoint_id: str | None = None
|
||||
# Bypass the /object_info cache (user re-checks after installing nodes).
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class WorkflowCheckRequest(BaseModel):
|
||||
"""L2 re-check of a saved workflow against a live ComfyUI (HEL-643)."""
|
||||
endpoint_id: str | None = None
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class WorkflowTestRequest(BaseModel):
|
||||
"""Launch one real test run of a workflow (HEL-643 L3)."""
|
||||
endpoint_id: str | None = None
|
||||
input_values: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class JobCreate(BaseModel):
|
||||
|
||||
@@ -36,17 +36,25 @@ class QueueManager:
|
||||
scene_id: int | None = None,
|
||||
clip_id: int | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
origin: str = "user",
|
||||
) -> dict[str, Any]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
payload = self._stamp_endpoint(conn, workflow_id, dict(payload or {}))
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id, status, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?)
|
||||
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id,
|
||||
status, payload_json, origin)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||
""",
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload)),
|
||||
(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)
|
||||
|
||||
@@ -13,6 +13,7 @@ import httpx
|
||||
from calliope import config
|
||||
from calliope.comfyui.client import ComfyUIClient
|
||||
from calliope.comfyui.dry_run import write_placeholder_mp4, write_placeholder_png
|
||||
from calliope.comfyui.lint import blocking_problem
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.roles import input_has_role
|
||||
@@ -20,6 +21,11 @@ from calliope.db import get_db
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.export.runner import run_export
|
||||
from calliope.queue.manager import queue_manager
|
||||
from calliope.queue.workflow_test import (
|
||||
TEST_ORIGIN,
|
||||
prune_workflow_tests,
|
||||
workflow_test_dir,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("calliope.worker")
|
||||
|
||||
@@ -163,6 +169,16 @@ class QueueWorker:
|
||||
"message": f"{self._job_label(job)} failed",
|
||||
},
|
||||
)
|
||||
if str(job.get("origin") or "") == TEST_ORIGIN and job.get("workflow_id"):
|
||||
# Retention: keep only the newest records (and their
|
||||
# artifacts) per workflow (HEL-643).
|
||||
try:
|
||||
prune_workflow_tests(int(job["workflow_id"]))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Pruning workflow-test records for workflow %s failed",
|
||||
job["workflow_id"],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Worker loop error")
|
||||
await asyncio.sleep(config.settings.queue_poll_interval_sec)
|
||||
@@ -172,6 +188,7 @@ class QueueWorker:
|
||||
payload = json.loads(job["payload_json"] or "{}")
|
||||
project_id = job["project_id"]
|
||||
kind = job["kind"]
|
||||
is_test = str(job.get("origin") or "") == TEST_ORIGIN
|
||||
use_dry = bool(config.settings.dry_run)
|
||||
|
||||
if kind == "export":
|
||||
@@ -182,6 +199,13 @@ class QueueWorker:
|
||||
base_url, headers, endpoint_label = self._resolve_endpoint(payload)
|
||||
client = ComfyUIClient(base_url, headers=headers)
|
||||
try:
|
||||
# A workflow test must be a REAL run — a dry-run placeholder that
|
||||
# passes as "测试通过" is exactly the false positive HEL-643 bans.
|
||||
if use_dry and is_test:
|
||||
raise RuntimeError(
|
||||
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
|
||||
"请到「设置」关闭 Dry-run 后重新测试"
|
||||
)
|
||||
if use_dry:
|
||||
queue_manager.set_phase(job["id"], "dry_run")
|
||||
return await self._dry_run(job, payload)
|
||||
@@ -195,11 +219,24 @@ class QueueWorker:
|
||||
)
|
||||
|
||||
workflow_id = job.get("workflow_id") or payload.get("workflow_id")
|
||||
workflow = self._load_workflow(workflow_id)
|
||||
if not workflow:
|
||||
workflow_row = self._load_workflow_row(workflow_id)
|
||||
if not workflow_row:
|
||||
raise RuntimeError(
|
||||
"找不到该任务使用的工作流(可能已被删除),请重新选择工作流后再生成"
|
||||
)
|
||||
workflow = workflow_row["workflow_json"]
|
||||
broken = blocking_problem(workflow)
|
||||
if broken:
|
||||
raise RuntimeError(broken)
|
||||
|
||||
# HEL-638: harvest only the confirmed output nodes. A workflow
|
||||
# with unknown custom outputs and no user mapping must not run.
|
||||
node_kinds = self._output_node_kinds(workflow_row)
|
||||
if node_kinds is None:
|
||||
raise RuntimeError(
|
||||
"该工作流含无法识别的自定义输出节点,尚未指定输出节点及媒体类型,"
|
||||
"请到「ComfyUI 连接」页确认后再生成"
|
||||
)
|
||||
|
||||
input_values = payload.get("input_values") or {}
|
||||
if payload.get("continue_source") and kind == "video":
|
||||
@@ -236,16 +273,39 @@ class QueueWorker:
|
||||
f"ComfyUI 执行出错(任务 ID {prompt_id}):{_comfy_messages(messages)}"
|
||||
)
|
||||
|
||||
outputs_meta = client.extract_outputs(history)
|
||||
outputs_meta = client.extract_outputs(history, node_kinds=node_kinds)
|
||||
saved = [m for m in outputs_meta if m.get("type") == "output"]
|
||||
outputs_meta = saved or outputs_meta
|
||||
if not outputs_meta:
|
||||
checked = "、".join(sorted(node_kinds))
|
||||
raise RuntimeError(
|
||||
f"ComfyUI 显示任务已结束(任务 ID {prompt_id}),但没有返回任何图片或视频,"
|
||||
"不算生成完成。请确认工作流带有保存/预览节点,并到 ComfyUI 历史中核对该任务。"
|
||||
"不算生成完成。已按输出映射检查节点:" f"{checked};"
|
||||
"请到 ComfyUI 历史中核对该任务,并确认所指定的输出节点正确。"
|
||||
)
|
||||
mismatch = next(
|
||||
(
|
||||
m
|
||||
for m in outputs_meta
|
||||
if m.get("media_kind") and m["media_kind"] != node_kinds.get(m["node_id"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if mismatch:
|
||||
want = node_kinds.get(mismatch["node_id"])
|
||||
raise RuntimeError(
|
||||
f"输出节点 {mismatch['node_id']} 的媒体类型与映射不符"
|
||||
f"(映射为{'视频' if want == 'video' else '图片'},"
|
||||
f"实际返回 {mismatch['filename']}),任务不算生成完成。"
|
||||
"请到「ComfyUI 连接」页修正该节点的媒体类型。"
|
||||
)
|
||||
queue_manager.set_phase(job["id"], "saving")
|
||||
dest_dir = config.settings.assets_dir / str(project_id) / kind
|
||||
if is_test:
|
||||
# Test artifacts live in a scratch dir, isolated from project
|
||||
# media and never written back to entities (HEL-643).
|
||||
dest_dir = workflow_test_dir(int(workflow_id))
|
||||
else:
|
||||
dest_dir = config.settings.assets_dir / str(project_id) / kind
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
paths: list[str] = []
|
||||
for meta in outputs_meta:
|
||||
@@ -259,7 +319,8 @@ class QueueWorker:
|
||||
)
|
||||
paths.append(str(dest))
|
||||
|
||||
self._apply_outputs_to_entities(job, payload, paths)
|
||||
if not is_test:
|
||||
self._apply_outputs_to_entities(job, payload, paths)
|
||||
queue_manager.set_phase(job["id"], "finished")
|
||||
return paths
|
||||
finally:
|
||||
@@ -472,20 +533,45 @@ class QueueWorker:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _load_workflow(self, workflow_id: int | None) -> dict[str, Any] | None:
|
||||
def _load_workflow_row(self, workflow_id: int | None) -> dict[str, Any] | None:
|
||||
if not workflow_id:
|
||||
return None
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT workflow_json FROM workflows WHERE id = ?", (workflow_id,)
|
||||
"SELECT workflow_json, output_map, output_map_source FROM workflows"
|
||||
" WHERE id = ?",
|
||||
(workflow_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return json.loads(row["workflow_json"])
|
||||
return {
|
||||
"workflow_json": json.loads(row["workflow_json"]),
|
||||
"output_map": row["output_map"],
|
||||
"output_map_source": row["output_map_source"],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _output_node_kinds(workflow_row: dict[str, Any]) -> dict[str, str] | None:
|
||||
"""Confirmed {nodeId: image|video} for this workflow, or None when
|
||||
the outputs are not confirmed (unknown nodes, no user mapping)."""
|
||||
from calliope.comfyui.output_map import mapping_problem, resolve_output_map
|
||||
|
||||
workflow = workflow_row["workflow_json"]
|
||||
problem = mapping_problem(
|
||||
workflow, workflow_row.get("output_map"), workflow_row.get("output_map_source")
|
||||
)
|
||||
if problem:
|
||||
return None
|
||||
entries, _source = resolve_output_map(
|
||||
workflow_row.get("output_map"),
|
||||
workflow_row.get("output_map_source"),
|
||||
workflow,
|
||||
)
|
||||
return {str(e["nodeId"]): str(e["kind"]) for e in entries} or None
|
||||
|
||||
def _job_label(self, job: dict[str, Any]) -> str:
|
||||
payload = json.loads(job.get("payload_json") or "{}")
|
||||
kind = job.get("kind") or "job"
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Workflow-test job helpers (HEL-643).
|
||||
|
||||
A test job (jobs.origin = 'workflow_test') runs the FULL real pipeline —
|
||||
queue → ComfyUI → outputs — but its artifacts are isolated from project
|
||||
entities: files land under ``assets/_workflow_test/{workflow_id}/`` and are
|
||||
never written back to characters/locations/scenes/clips.
|
||||
|
||||
This module owns the artifact directory, the retention policy (keep the
|
||||
newest N records per workflow) and the purge paths used by "清除测试记录"
|
||||
and workflow deletion.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from calliope import config
|
||||
from calliope.db import get_db
|
||||
|
||||
logger = logging.getLogger("calliope.queue")
|
||||
|
||||
TEST_ORIGIN = "workflow_test"
|
||||
TEST_SOURCE = "workflow_test"
|
||||
# Records (and artifacts) kept per workflow before older ones are pruned.
|
||||
TEST_KEEP = 5
|
||||
|
||||
|
||||
def workflow_test_dir(workflow_id: int) -> Path:
|
||||
return config.settings.assets_dir / "_workflow_test" / str(workflow_id)
|
||||
|
||||
|
||||
def _safe_unlink(path_str: str) -> bool:
|
||||
"""Delete a file if it resolves under assets_dir (missing = already gone)."""
|
||||
try:
|
||||
target = Path(path_str).resolve()
|
||||
target.relative_to(config.settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
if not target.is_file():
|
||||
return False
|
||||
try:
|
||||
target.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _unlink_job_outputs(job: dict[str, Any]) -> None:
|
||||
try:
|
||||
paths = json.loads(job.get("output_paths_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
if not isinstance(paths, list):
|
||||
return
|
||||
for p in paths:
|
||||
if isinstance(p, str) and p.strip():
|
||||
_safe_unlink(p)
|
||||
|
||||
|
||||
def _remove_test_dir_if_empty(workflow_id: int) -> None:
|
||||
d = workflow_test_dir(workflow_id)
|
||||
try:
|
||||
if d.is_dir() and not any(d.iterdir()):
|
||||
d.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _test_job_public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(job)
|
||||
out["payload"] = json.loads(job.get("payload_json") or "{}")
|
||||
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
|
||||
out.pop("payload_json", None)
|
||||
out.pop("output_paths_json", None)
|
||||
return out
|
||||
|
||||
|
||||
def list_test_jobs(workflow_id: int, limit: int = 20) -> list[dict[str, Any]]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" ORDER BY id DESC LIMIT ?",
|
||||
(workflow_id, TEST_ORIGIN, limit),
|
||||
).fetchall()
|
||||
return [_test_job_public(dict(r)) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def find_active_test_job(workflow_id: int, endpoint_id: str | None) -> dict[str, Any] | None:
|
||||
"""The unfinished test job for (workflow, endpoint), if one exists.
|
||||
|
||||
Idempotency guard for the "测试工作流" button: a second click re-uses the
|
||||
in-flight job instead of enqueuing a duplicate.
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" AND status IN ('pending', 'running') ORDER BY id DESC",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
for r in rows:
|
||||
job = dict(r)
|
||||
try:
|
||||
payload = json.loads(job.get("payload_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
payload = {}
|
||||
snap = payload.get("endpoint") or {}
|
||||
if not isinstance(snap, dict):
|
||||
snap = {}
|
||||
if (snap.get("id") or None) == (endpoint_id or None):
|
||||
return _test_job_public(job)
|
||||
return None
|
||||
|
||||
|
||||
def prune_workflow_tests(workflow_id: int, keep: int = TEST_KEEP) -> int:
|
||||
"""Keep only the newest ``keep`` test records (older rows + files go)."""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" ORDER BY id DESC LIMIT -1 OFFSET ?",
|
||||
(workflow_id, TEST_ORIGIN, max(keep, 0)),
|
||||
).fetchall()
|
||||
removed = 0
|
||||
for r in rows:
|
||||
job = dict(r)
|
||||
if job.get("status") in ("pending", "running"):
|
||||
continue
|
||||
_unlink_job_outputs(job)
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (job["id"],))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_remove_test_dir_if_empty(workflow_id)
|
||||
if removed:
|
||||
logger.info("Pruned %s old workflow-test records for workflow %s", removed, workflow_id)
|
||||
return removed
|
||||
|
||||
|
||||
def purge_workflow_tests(workflow_id: int, *, allow_active: bool = False) -> int:
|
||||
"""Delete EVERY test record and artifact for a workflow.
|
||||
|
||||
Returns the removed row count; raises RuntimeError when an unfinished
|
||||
test job is still in flight (unless ``allow_active``).
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
if not allow_active:
|
||||
active = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" AND status IN ('pending', 'running')",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchone()["n"]
|
||||
if active:
|
||||
raise RuntimeError(
|
||||
f"该工作流还有 {active} 个进行中的测试任务,请先等待完成或取消,再清除测试记录"
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchall()
|
||||
removed = 0
|
||||
for r in rows:
|
||||
_unlink_job_outputs(dict(r))
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (r["id"],))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
shutil.rmtree(workflow_test_dir(workflow_id), ignore_errors=True)
|
||||
return removed
|
||||
@@ -97,7 +97,7 @@ class MemoryCreate(BaseModel):
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
content: str = ""
|
||||
mentions: list[MessageMention] = Field(default_factory=list, max_length=1)
|
||||
mentions: list[MessageMention] = Field(default_factory=list, max_length=5)
|
||||
attachments: list[MessageAttachment] = Field(default_factory=list, max_length=8)
|
||||
answer_to: int | None = None # question/asked seq this message answers (card click)
|
||||
|
||||
@@ -113,8 +113,10 @@ class MessageCreate(BaseModel):
|
||||
@field_validator("mentions")
|
||||
@classmethod
|
||||
def _one_workflow(cls, v: list[MessageMention]) -> list[MessageMention]:
|
||||
if len(v) > 1:
|
||||
if sum(m.type == "workflow" for m in v) > 1:
|
||||
raise ValueError("Only one @workflow mention per message")
|
||||
if sum(m.type == "skill" for m in v) > 4:
|
||||
raise ValueError("At most four skill mentions per message")
|
||||
return v
|
||||
|
||||
|
||||
|
||||
@@ -77,8 +77,11 @@ def _parsed_url(base_url: str) -> tuple[str, str]:
|
||||
url = (base_url or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="API 地址不能为空")
|
||||
if "://" not in url:
|
||||
url = f"http://{url}"
|
||||
# HEL-619: the server validates format as strictly as the UI already
|
||||
# does. A schemeless garbled string used to be silently prefixed with
|
||||
# http:// and then reported as a DNS failure; it is a 400 format error.
|
||||
if "://" not in url or any(ch.isspace() for ch in url):
|
||||
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
|
||||
@@ -135,7 +138,12 @@ async def list_endpoints() -> dict[str, Any]:
|
||||
|
||||
@router.post("/endpoints")
|
||||
async def create_endpoint(payload: EndpointIn) -> dict[str, Any]:
|
||||
base_url, _host = _parsed_url(payload.base_url)
|
||||
base_url, host = _parsed_url(payload.base_url)
|
||||
# HEL-619: the same SSRF guard as the connectivity test applies on
|
||||
# save — otherwise a blocked address can be stored and later probed by
|
||||
# the worker path. Unresolvable hosts still pass (resolution happens
|
||||
# again at probe time).
|
||||
_assert_not_ssrf(host)
|
||||
incoming = [ep for ep in settings.comfyui_endpoints or []]
|
||||
incoming.append(payload.model_dump() | {"base_url": base_url})
|
||||
try:
|
||||
@@ -151,7 +159,8 @@ async def update_endpoint(endpoint_id: str, payload: EndpointPatch) -> dict[str,
|
||||
_stored_endpoint(endpoint_id)
|
||||
base_url = payload.base_url
|
||||
if base_url is not None:
|
||||
base_url, _host = _parsed_url(base_url)
|
||||
base_url, host = _parsed_url(base_url)
|
||||
_assert_not_ssrf(host) # same save-time guard as create (HEL-619)
|
||||
patch = payload.model_dump(exclude_unset=True)
|
||||
if base_url is not None:
|
||||
patch["base_url"] = base_url
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Standalone ComfyUI playground — generate freely, attach favorites to projects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -13,6 +14,8 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from calliope import config
|
||||
from calliope.agent.prompts import item_reference_prompt
|
||||
from calliope.comfyui.lint import blocking_problem
|
||||
from calliope.comfyui.output_map import mapping_problem
|
||||
from calliope.comfyui.parser import effective_input_schema
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.db import get_db, row_to_dict
|
||||
@@ -29,6 +32,23 @@ PLAYGROUND_TITLE = "Playground Scratch"
|
||||
# page can label origins ("独立生图" vs canvas Playground) without guessing.
|
||||
PlaygroundSource = Literal["playground", "studio-image", "studio-video"]
|
||||
|
||||
# HEL-684: a desk visit opened from a five-stage workbench row may carry
|
||||
# that row as optional context. Only real, server-verified project/stage/
|
||||
# source info is accepted — the source row must still exist in the stage
|
||||
# content, and the desk kind must match what that stage renders.
|
||||
WorkbenchStage = Literal["assets", "shot"]
|
||||
WORKBENCH_KIND_BY_STAGE: dict[str, str] = {"assets": "image", "shot": "video"}
|
||||
WORKBENCH_ROW_KEY_BY_STAGE: dict[str, str] = {"assets": "assets", "shot": "shots"}
|
||||
|
||||
|
||||
class PlaygroundWorkbenchContext(BaseModel):
|
||||
project_id: int
|
||||
stage: WorkbenchStage
|
||||
source_kind: Literal["asset", "shot"]
|
||||
source_id: str = Field(min_length=1)
|
||||
# Display-only echo of the DeskContext label (e.g. 镜头 S1).
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class PlaygroundGenerate(BaseModel):
|
||||
workflow_id: int
|
||||
@@ -37,6 +57,13 @@ class PlaygroundGenerate(BaseModel):
|
||||
# then the active one (resolved in queue_manager.enqueue).
|
||||
endpoint_id: str | None = None
|
||||
source: PlaygroundSource = "playground"
|
||||
# Optional real workbench source — validated, then stamped on the job
|
||||
# payload so a finished take can be flowed back (attach-workbench).
|
||||
context: PlaygroundWorkbenchContext | None = None
|
||||
|
||||
|
||||
class PlaygroundAttachWorkbench(BaseModel):
|
||||
job_id: int
|
||||
|
||||
|
||||
class PlaygroundAttach(BaseModel):
|
||||
@@ -81,6 +108,72 @@ def ensure_playground_project(conn) -> int:
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# HEL-684: workbench source context — validate against real rows, never
|
||||
# guess. Both generate-time (record on the job payload) and attach-time
|
||||
# (flow a finished take back) go through the same checks.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _workbench_target_row(conn, ctx: PlaygroundWorkbenchContext) -> dict[str, Any] | None:
|
||||
"""The real asset/shot row this context points at, or None."""
|
||||
stage = conn.execute(
|
||||
"SELECT content_json FROM project_stages WHERE project_id = ? AND stage_key = ?",
|
||||
(ctx.project_id, ctx.stage),
|
||||
).fetchone()
|
||||
if not stage or not stage["content_json"]:
|
||||
return None
|
||||
try:
|
||||
content = json.loads(stage["content_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
rows = content.get(WORKBENCH_ROW_KEY_BY_STAGE[ctx.stage])
|
||||
if not isinstance(rows, list):
|
||||
return None
|
||||
for row in rows:
|
||||
if isinstance(row, dict) and str(row.get("id") or "") == ctx.source_id:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _validate_workbench_context(conn, ctx: PlaygroundWorkbenchContext, kind: str) -> dict[str, Any]:
|
||||
"""Reject anything that is not a real, current project/stage/source.
|
||||
|
||||
Returns the payload stamp for the job. Raises on any mismatch so no
|
||||
guessed or stale source is ever recorded.
|
||||
"""
|
||||
expected_kind = WORKBENCH_KIND_BY_STAGE[ctx.stage]
|
||||
expected_source_kind = "asset" if ctx.stage == "assets" else "shot"
|
||||
if ctx.source_kind != expected_source_kind or kind != expected_kind:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"来源上下文与生成类型不符:「{ctx.stage}」阶段需要{expected_kind}生成。"
|
||||
"可关闭来源提示后独立生成。"
|
||||
),
|
||||
)
|
||||
project = conn.execute(
|
||||
"SELECT id, status FROM projects WHERE id = ?", (ctx.project_id,)
|
||||
).fetchone()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="来源项目不存在,无法记录回流上下文")
|
||||
if project["status"] == PLAYGROUND_STATUS:
|
||||
raise HTTPException(status_code=400, detail="不能以 playground 隐藏项目作为回流来源")
|
||||
if _workbench_target_row(conn, ctx) is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"来源"
|
||||
f"{'资产' if ctx.source_kind == 'asset' else '镜头'}行已不存在"
|
||||
"(阶段内容可能已重新生成)。可关闭来源提示后独立生成。"
|
||||
),
|
||||
)
|
||||
stamp: dict[str, Any] = ctx.model_dump()
|
||||
if not (stamp.get("label") or "").strip():
|
||||
stamp["label"] = None
|
||||
return stamp
|
||||
|
||||
|
||||
def _assert_path_in_assets(path: str) -> Path:
|
||||
assets_root = config.settings.assets_dir.resolve()
|
||||
target = Path(path).resolve()
|
||||
@@ -193,9 +286,36 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
pid = ensure_playground_project(conn)
|
||||
# HEL-684: which takes have already been flowed back to a workbench
|
||||
# row (per source project) — the desk shows an honest 已回流 state
|
||||
# that survives reloads instead of guessing from client memory.
|
||||
links_by_project: dict[int, dict[int, dict[str, Any]]] = {}
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT project_id FROM jobs"
|
||||
" WHERE payload_json LIKE '%\"from_playground_job\"%'"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
proj_id = int(r["project_id"])
|
||||
links_by_project[proj_id] = _workbench_link_rows(conn, proj_id)
|
||||
finally:
|
||||
conn.close()
|
||||
return [_job_public(j) for j in queue_manager.list_jobs(pid, None, limit)]
|
||||
# Workflow-test jobs (HEL-643) ride the same scratch project but must not
|
||||
# surface in the studio windows' history — they carry their own dialog.
|
||||
jobs = [
|
||||
j
|
||||
for j in queue_manager.list_jobs(pid, None, limit)
|
||||
if str(j.get("origin") or "user") != "workflow_test"
|
||||
]
|
||||
out = []
|
||||
for j in jobs:
|
||||
pub = _job_public(j)
|
||||
stamp = (pub.get("payload") or {}).get("workbench")
|
||||
if isinstance(stamp, dict) and isinstance(stamp.get("project_id"), int):
|
||||
link = links_by_project.get(int(stamp["project_id"]), {}).get(int(pub["id"]))
|
||||
if link and link.get("target_id") == stamp.get("source_id"):
|
||||
pub["workbench_attach"] = link
|
||||
out.append(pub)
|
||||
return out
|
||||
|
||||
|
||||
@router.delete("/jobs/{job_id}")
|
||||
@@ -230,6 +350,21 @@ async def delete_playground_job(job_id: int) -> dict[str, Any]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
_clear_entity_refs(conn, [p for p in paths if isinstance(p, str)])
|
||||
# HEL-684: the output files are about to vanish — drop the workbench
|
||||
# link rows created from this take so no project row points at
|
||||
# missing media (mirrors the entity-ref clearing above).
|
||||
removed_links: list[int] = []
|
||||
link_rows = conn.execute(
|
||||
"SELECT id, payload_json FROM jobs WHERE payload_json LIKE '%\"from_playground_job\"%'"
|
||||
).fetchall()
|
||||
for r in link_rows:
|
||||
try:
|
||||
p = json.loads(r["payload_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if p.get("from_playground_job") == job_id:
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (int(r["id"]),))
|
||||
removed_links.append(int(r["id"]))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -252,6 +387,7 @@ async def delete_playground_job(job_id: int) -> dict[str, Any]:
|
||||
"job_id": job_id,
|
||||
"deleted_files": deleted_files,
|
||||
"missing_files": missing_files,
|
||||
"removed_workbench_links": removed_links,
|
||||
}
|
||||
|
||||
|
||||
@@ -334,7 +470,8 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
try:
|
||||
pid = ensure_playground_project(conn)
|
||||
wf = conn.execute(
|
||||
"SELECT id, kind, is_enabled, workflow_json, input_schema FROM workflows WHERE id = ?",
|
||||
"SELECT id, kind, is_enabled, workflow_json, input_schema, output_map,"
|
||||
" output_map_source FROM workflows WHERE id = ?",
|
||||
(payload.workflow_id,),
|
||||
).fetchone()
|
||||
if not wf:
|
||||
@@ -346,13 +483,30 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
status_code=400, detail="该工作流已停用,请到「ComfyUI 连接」页启用后再生成"
|
||||
)
|
||||
kind = wf["kind"]
|
||||
workflow_json = json.loads(wf["workflow_json"])
|
||||
# HEL-638: a workflow with unknown custom output nodes must have a
|
||||
# confirmed output mapping before anything is queued.
|
||||
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [],
|
||||
json.loads(wf["workflow_json"]),
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
|
||||
)
|
||||
# HEL-684: an optional workbench source is only recorded when it is
|
||||
# real and current — the job itself still runs on the playground
|
||||
# scratch project so the desk feed, SSE and tasks stay unchanged.
|
||||
workbench_stamp = (
|
||||
_validate_workbench_context(conn, payload.context, kind)
|
||||
if payload.context is not None
|
||||
else None
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
broken = blocking_problem(workflow_json)
|
||||
if broken:
|
||||
raise HTTPException(status_code=400, detail=broken)
|
||||
|
||||
input_values, problems = validate_input_values(schema, payload.input_values)
|
||||
if problems:
|
||||
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
|
||||
@@ -366,6 +520,7 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
"input_values": input_values,
|
||||
"source": payload.source,
|
||||
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
|
||||
**({"workbench": workbench_stamp} if workbench_stamp else {}),
|
||||
},
|
||||
)
|
||||
await event_bus.publish(
|
||||
@@ -476,3 +631,167 @@ async def attach_to_project(payload: PlaygroundAttach) -> dict[str, Any]:
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _workbench_link_rows(conn, project_id: int) -> dict[int, dict[str, Any]]:
|
||||
"""playground job id → its flow-back link row on this project.
|
||||
|
||||
Link rows are done jobs with payload.source='workbench' and a
|
||||
from_playground_job stamp — the exact shape _workbench_job_outputs
|
||||
already enriches the workbench rows from, so a flowed-back take shows
|
||||
up on its source row through the existing display path.
|
||||
"""
|
||||
out: dict[int, dict[str, Any]] = {}
|
||||
rows = conn.execute(
|
||||
"SELECT id, payload_json FROM jobs WHERE project_id = ? ORDER BY id ASC",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
try:
|
||||
p = json.loads(r["payload_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if p.get("source") != "workbench":
|
||||
continue
|
||||
src = p.get("from_playground_job")
|
||||
if isinstance(src, int):
|
||||
out[src] = {
|
||||
"job_id": int(r["id"]),
|
||||
"stage": p.get("stage_key"),
|
||||
"target_id": p.get("target_id"),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/attach-workbench")
|
||||
async def attach_workbench(payload: PlaygroundAttachWorkbench) -> dict[str, Any]:
|
||||
"""Flow a finished desk take back to its workbench source row (HEL-684).
|
||||
|
||||
The source is whatever real context the generate request carried —
|
||||
nothing is guessed here. Re-attaching is idempotent: the existing link
|
||||
row is returned, no duplicate is created, nothing is overwritten.
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
pid = ensure_playground_project(conn)
|
||||
job = queue_manager.get_job(payload.job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if int(job["project_id"]) != int(pid):
|
||||
raise HTTPException(status_code=403, detail="Not a playground job")
|
||||
|
||||
try:
|
||||
job_payload = json.loads(job.get("payload_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
job_payload = {}
|
||||
stamp = job_payload.get("workbench")
|
||||
if not isinstance(stamp, dict) or not stamp.get("source_id"):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="该记录没有项目来源上下文,无法回流(独立生成不回流)"
|
||||
)
|
||||
if job["status"] != "done":
|
||||
raise HTTPException(status_code=400, detail="生成完成后才能回流")
|
||||
outputs = json.loads(job.get("output_paths_json") or "[]")
|
||||
if not isinstance(outputs, list) or not outputs:
|
||||
raise HTTPException(status_code=400, detail="该记录没有产物文件,无法回流")
|
||||
|
||||
try:
|
||||
ctx = PlaygroundWorkbenchContext(
|
||||
project_id=int(stamp["project_id"]),
|
||||
stage=stamp.get("stage"),
|
||||
source_kind=stamp.get("source_kind"),
|
||||
source_id=str(stamp["source_id"]),
|
||||
label=stamp.get("label"),
|
||||
)
|
||||
except Exception as exc: # pydantic ValidationError / missing keys
|
||||
raise HTTPException(
|
||||
status_code=400, detail="该记录的项目来源上下文无效,无法回流"
|
||||
) from exc
|
||||
# Re-validate against the live stage content — the row may have been
|
||||
# regenerated since the take was created; refuse rather than mis-write.
|
||||
if _workbench_target_row(conn, ctx) is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"来源"
|
||||
f"{'资产' if ctx.source_kind == 'asset' else '镜头'}行已不存在"
|
||||
"(阶段内容可能已重新生成),未回流。"
|
||||
),
|
||||
)
|
||||
|
||||
links = _workbench_link_rows(conn, ctx.project_id)
|
||||
existing = links.get(int(job["id"]))
|
||||
if existing and existing.get("target_id") == ctx.source_id:
|
||||
return {
|
||||
"ok": True,
|
||||
"already_attached": True,
|
||||
"job_id": int(job["id"]),
|
||||
"attached_job_id": existing["job_id"],
|
||||
"project_id": ctx.project_id,
|
||||
"stage": ctx.stage,
|
||||
"target_id": ctx.source_id,
|
||||
}
|
||||
|
||||
link_payload = {
|
||||
"source": "workbench",
|
||||
"stage_key": ctx.stage,
|
||||
"target_id": ctx.source_id,
|
||||
"from_playground_job": int(job["id"]),
|
||||
"input_values": job_payload.get("input_values") or {},
|
||||
}
|
||||
if ctx.label:
|
||||
link_payload["label"] = ctx.label
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO jobs (project_id, kind, workflow_id, status, payload_json,
|
||||
output_paths_json, created_at, started_at, completed_at, origin)
|
||||
VALUES (?, ?, ?, 'done', ?, ?,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'user')
|
||||
""",
|
||||
(
|
||||
ctx.project_id,
|
||||
job["kind"],
|
||||
job["workflow_id"],
|
||||
json.dumps(link_payload, ensure_ascii=False),
|
||||
json.dumps([str(p) for p in outputs if isinstance(p, str)]),
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE projects SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(ctx.project_id,),
|
||||
)
|
||||
conn.commit()
|
||||
link_id = int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
await event_bus.publish(
|
||||
"job.completed",
|
||||
{
|
||||
"job_id": link_id,
|
||||
"kind": job["kind"],
|
||||
"outputs": outputs,
|
||||
"project_id": ctx.project_id,
|
||||
"source": "workbench",
|
||||
"message": "生成台结果已回流到项目",
|
||||
},
|
||||
)
|
||||
await event_bus.publish(
|
||||
"asset.ready",
|
||||
{
|
||||
"job_id": link_id,
|
||||
"path": outputs[0] if isinstance(outputs[0], str) else str(outputs[0]),
|
||||
"project_id": ctx.project_id,
|
||||
"target": "workbench",
|
||||
"source": "playground_attach",
|
||||
},
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"already_attached": False,
|
||||
"job_id": int(job["id"]),
|
||||
"attached_job_id": link_id,
|
||||
"project_id": ctx.project_id,
|
||||
"stage": ctx.stage,
|
||||
"target_id": ctx.source_id,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Editable task deliverables and explicit, idempotent result → stage drafts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, row_to_dict
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.routers.workbench import StageKey
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DraftPatch(BaseModel):
|
||||
revision: int = Field(ge=1)
|
||||
content: str = Field(min_length=1, max_length=200_000)
|
||||
project_id: int | None = None
|
||||
stage_key: StageKey
|
||||
|
||||
|
||||
class StoreResult(BaseModel):
|
||||
project_id: int = Field(gt=0)
|
||||
stage_key: StageKey
|
||||
deliverable_id: int | None = Field(default=None, gt=0)
|
||||
revision: int | None = Field(default=None, gt=0)
|
||||
job_id: int | None = Field(default=None, gt=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def one_source(self):
|
||||
if (self.deliverable_id is None) == (self.job_id is None):
|
||||
raise ValueError("Choose exactly one result source")
|
||||
if self.deliverable_id is not None and self.revision is None:
|
||||
raise ValueError("Deliverable revision is required")
|
||||
return self
|
||||
|
||||
|
||||
def _public(row) -> dict[str, Any]:
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/deliverables")
|
||||
def list_deliverables(session_id: int | None = None) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
sql = "SELECT * FROM agent_deliverables"
|
||||
args = ()
|
||||
if session_id is not None:
|
||||
sql += " WHERE session_id=?"
|
||||
args = (session_id,)
|
||||
return [_public(r) for r in conn.execute(sql + " ORDER BY id DESC", args)]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/deliverables/{draft_id}/versions")
|
||||
def list_versions(draft_id: int) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
if not conn.execute("SELECT id FROM agent_deliverables WHERE id=?", (draft_id,)).fetchone():
|
||||
raise HTTPException(404, "成果不存在")
|
||||
return [
|
||||
_public(row)
|
||||
for row in conn.execute(
|
||||
"SELECT * FROM agent_deliverable_versions WHERE deliverable_id=? "
|
||||
"ORDER BY revision DESC",
|
||||
(draft_id,),
|
||||
)
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.patch("/deliverables/{draft_id}")
|
||||
def patch_deliverable(draft_id: int, payload: DraftPatch) -> dict[str, Any]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute("SELECT * FROM agent_deliverables WHERE id=?", (draft_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "成果不存在")
|
||||
if payload.project_id is not None:
|
||||
from calliope.routers.workbench import _get_project
|
||||
|
||||
_get_project(conn, payload.project_id)
|
||||
if row["revision"] != payload.revision:
|
||||
raise HTTPException(409, "草稿已更新,请刷新后重试")
|
||||
# A linked session cannot silently move its work into another project.
|
||||
session = conn.execute(
|
||||
"SELECT project_id FROM agent_sessions WHERE id=?", (row["session_id"],)
|
||||
).fetchone()
|
||||
if session["project_id"] is not None and payload.project_id != session["project_id"]:
|
||||
raise HTTPException(409, "成果归属与原会话项目不一致")
|
||||
if not payload.content.strip():
|
||||
raise HTTPException(422, "成果内容不能为空")
|
||||
conn.execute(
|
||||
"INSERT INTO agent_deliverable_versions(deliverable_id,revision,content) "
|
||||
"VALUES (?,?,?)",
|
||||
(draft_id, row["revision"], row["content"]),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE agent_deliverables SET content=?,project_id=?,stage_key=?,revision=revision+1,"
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(payload.content, payload.project_id, payload.stage_key, draft_id),
|
||||
)
|
||||
conn.commit()
|
||||
return _public(
|
||||
conn.execute("SELECT * FROM agent_deliverables WHERE id=?", (draft_id,)).fetchone()
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/store")
|
||||
async def store_result(payload: StoreResult) -> dict[str, Any]:
|
||||
from calliope.agent.llm import extract_json
|
||||
from calliope.routers.playground import _assert_path_in_assets
|
||||
from calliope.routers.workbench import (
|
||||
NORMALIZERS,
|
||||
StageContentError,
|
||||
_get_project,
|
||||
_stage_or_404,
|
||||
_stage_public,
|
||||
)
|
||||
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
_get_project(conn, payload.project_id)
|
||||
stage = _stage_or_404(conn, payload.project_id, payload.stage_key)
|
||||
draft = None
|
||||
if payload.deliverable_id is not None:
|
||||
draft = conn.execute(
|
||||
"SELECT * FROM agent_deliverables WHERE id=?", (payload.deliverable_id,)
|
||||
).fetchone()
|
||||
if not draft:
|
||||
raise HTTPException(404, "成果不存在")
|
||||
if draft["revision"] != payload.revision:
|
||||
raise HTTPException(409, "草稿版本已变化,请先保存并刷新")
|
||||
if draft["project_id"] != payload.project_id or draft["stage_key"] != payload.stage_key:
|
||||
raise HTTPException(409, "请先保存成果的项目与阶段归属")
|
||||
session = conn.execute(
|
||||
"SELECT project_id FROM agent_sessions WHERE id=?", (draft["session_id"],)
|
||||
).fetchone()
|
||||
if session["project_id"] is not None and session["project_id"] != payload.project_id:
|
||||
raise HTTPException(409, "会话项目已变化,请重新核对成果归属")
|
||||
source_key = f"deliverable:{draft['id']}:{draft['revision']}"
|
||||
result = {
|
||||
"source_key": source_key,
|
||||
"title": draft["title"],
|
||||
"text": draft["content"],
|
||||
"session_id": draft["session_id"],
|
||||
}
|
||||
else:
|
||||
job = conn.execute("SELECT * FROM jobs WHERE id=?", (payload.job_id,)).fetchone()
|
||||
if not job:
|
||||
raise HTTPException(404, "生成结果不存在")
|
||||
project = conn.execute(
|
||||
"SELECT status FROM projects WHERE id=?", (job["project_id"],)
|
||||
).fetchone()
|
||||
if job["project_id"] != payload.project_id and (
|
||||
not project or project["status"] != "system"
|
||||
):
|
||||
raise HTTPException(409, "此结果属于其他项目")
|
||||
if job["status"] != "done" or job["kind"] not in ("image", "video"):
|
||||
raise HTTPException(409, "仅可存入已完成的图片或视频")
|
||||
paths = json.loads(job["output_paths_json"] or "[]")
|
||||
if (
|
||||
not paths
|
||||
or not isinstance(paths, list)
|
||||
or not all(isinstance(p, str) for p in paths)
|
||||
):
|
||||
raise HTTPException(422, "结果没有产物")
|
||||
for path in paths:
|
||||
if not _assert_path_in_assets(path).is_file():
|
||||
raise HTTPException(410, "产物文件已不存在")
|
||||
source_key = f"job:{job['id']}"
|
||||
result = {
|
||||
"source_key": source_key,
|
||||
"title": f"生成结果 #{job['id']}",
|
||||
"kind": job["kind"],
|
||||
"paths": paths,
|
||||
"job_id": job["id"],
|
||||
}
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM result_attachments WHERE source_key=? AND project_id=? AND stage_key=?",
|
||||
(source_key, payload.project_id, payload.stage_key),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return {"ok": True, "already_attached": True, "stage": _stage_public(stage)}
|
||||
if stage["status"] == "running":
|
||||
raise HTTPException(409, "本阶段正在生成,请稍后再存入")
|
||||
content = json.loads(stage["content_json"] or "{}")
|
||||
if draft:
|
||||
text = draft["content"].strip()
|
||||
# Valid structured artifacts replace only the working draft.
|
||||
# Confirmed snapshots stay intact.
|
||||
parsed = None
|
||||
try:
|
||||
parsed = extract_json(text)
|
||||
except ValueError:
|
||||
if payload.stage_key == "script":
|
||||
parsed = {"text": text}
|
||||
if isinstance(parsed, dict):
|
||||
try:
|
||||
normalized = NORMALIZERS[payload.stage_key](parsed)
|
||||
normalized["incoming_results"] = content.get("incoming_results", [])
|
||||
content = normalized
|
||||
except StageContentError as exc:
|
||||
raise HTTPException(422, str(exc)) from exc
|
||||
incoming = list(content.get("incoming_results") or [])
|
||||
incoming.append(result)
|
||||
content["incoming_results"] = incoming
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET content_json=?,status='awaiting_confirm',draft_edited=1,"
|
||||
"error=NULL,updated_at=CURRENT_TIMESTAMP WHERE project_id=? AND stage_key=?",
|
||||
(json.dumps(content, ensure_ascii=False), payload.project_id, payload.stage_key),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO result_attachments(source_key,project_id,stage_key) VALUES (?,?,?)",
|
||||
(source_key, payload.project_id, payload.stage_key),
|
||||
)
|
||||
if draft:
|
||||
conn.execute(
|
||||
"UPDATE agent_sessions SET task_status='returned' WHERE id=?",
|
||||
(draft["session_id"],),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id=?", (payload.project_id,)
|
||||
)
|
||||
conn.commit()
|
||||
row = _stage_or_404(conn, payload.project_id, payload.stage_key)
|
||||
out = {"ok": True, "already_attached": False, "stage": _stage_public(row)}
|
||||
finally:
|
||||
conn.close()
|
||||
await event_bus.publish(
|
||||
"workbench.stage.updated",
|
||||
{"project_id": payload.project_id, "stage_key": payload.stage_key},
|
||||
)
|
||||
return out
|
||||
@@ -107,7 +107,11 @@ async def update_settings(payload: SettingsUpdate) -> dict[str, Any]:
|
||||
if assignments_in is not None:
|
||||
settings.ensure_llm_profiles()
|
||||
ids = {p["id"] for p in settings.llm_profiles}
|
||||
cleaned: dict[str, str | None] = {}
|
||||
# HEL-619: per-role INCREMENTAL update. Rapid dropdown switches used
|
||||
# to send whole cached snapshots that fully overwrote the map, so a
|
||||
# stale snapshot could wipe a newer choice. Roles absent from the
|
||||
# payload keep their stored value; a role sent as null is cleared.
|
||||
cleaned: dict[str, str | None] = dict(settings.agent_llm_assignments or {})
|
||||
for role, pid in assignments_in.items():
|
||||
if role not in AGENT_LLM_ROLES:
|
||||
continue
|
||||
|
||||
@@ -45,6 +45,10 @@ class StagePatch(BaseModel):
|
||||
chosen_style_id: str | None = None
|
||||
|
||||
|
||||
class StageNotePatch(BaseModel):
|
||||
text: str = Field(max_length=200_000)
|
||||
|
||||
|
||||
class StageRunRequest(BaseModel):
|
||||
instruction: str | None = Field(default=None, max_length=8000)
|
||||
|
||||
@@ -292,10 +296,13 @@ def _load_stage(conn, project_id: int, stage_key: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _stage_public(row: dict[str, Any]) -> dict[str, Any]:
|
||||
content = json.loads(row["content_json"]) if row["content_json"] else None
|
||||
return {
|
||||
"stage_key": row["stage_key"],
|
||||
"status": row["status"],
|
||||
"content": json.loads(row["content_json"]) if row["content_json"] else None,
|
||||
"content": content,
|
||||
"assistant_note": (content or {}).get("assistant_note", ""),
|
||||
"incoming_results": (content or {}).get("incoming_results", []),
|
||||
"confirmed_content": (
|
||||
json.loads(row["confirmed_content_json"]) if row["confirmed_content_json"] else None
|
||||
),
|
||||
@@ -571,6 +578,10 @@ async def _execute_run(project_id: int, stage_key: str, run_id: int, instruction
|
||||
|
||||
conn = get_db(db)
|
||||
try:
|
||||
latest_stage = _load_stage(conn, project_id, stage_key)
|
||||
previous = json.loads(latest_stage["content_json"]) if latest_stage["content_json"] else {}
|
||||
if previous.get("assistant_note"):
|
||||
content["assistant_note"] = previous["assistant_note"]
|
||||
conn.execute(
|
||||
"UPDATE stage_runs SET status='done', output_json=?, completed_at=CURRENT_TIMESTAMP "
|
||||
"WHERE id = ?",
|
||||
@@ -681,6 +692,11 @@ async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> d
|
||||
content = NORMALIZERS[stage_key](payload.content)
|
||||
except StageContentError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
old_content = json.loads(stage["content_json"]) if stage["content_json"] else {}
|
||||
if old_content.get("incoming_results"):
|
||||
content["incoming_results"] = old_content["incoming_results"]
|
||||
if old_content.get("assistant_note"):
|
||||
content["assistant_note"] = old_content["assistant_note"]
|
||||
if content == (json.loads(stage["content_json"]) if stage["content_json"] else None):
|
||||
return {"ok": True, "stage": _stage_public({**stage, "content_json": json.dumps(content, ensure_ascii=False)})}
|
||||
# Editing a confirmed stage produces a new draft awaiting re-confirm;
|
||||
@@ -715,6 +731,32 @@ async def patch_stage(project_id: int, stage_key: str, payload: StagePatch) -> d
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.patch("/projects/{project_id}/stages/{stage_key}/note")
|
||||
async def patch_stage_note(project_id: int, stage_key: str, payload: StageNotePatch) -> dict[str, Any]:
|
||||
"""Keep free-text advice separate from validated stage artifacts and snapshots."""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
_get_project(conn, project_id)
|
||||
stage = _stage_or_404(conn, project_id, stage_key)
|
||||
if stage["status"] == "running":
|
||||
raise HTTPException(status_code=409, detail="本阶段正在生成中,请稍候再保存建议。")
|
||||
content = json.loads(stage["content_json"]) if stage["content_json"] else {}
|
||||
if payload.text.strip():
|
||||
content["assistant_note"] = payload.text.strip()
|
||||
else:
|
||||
content.pop("assistant_note", None)
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET content_json=?, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE project_id=? AND stage_key=?",
|
||||
(json.dumps(content, ensure_ascii=False) if content else None, project_id, stage_key),
|
||||
)
|
||||
conn.execute("UPDATE projects SET updated_at=CURRENT_TIMESTAMP WHERE id=?", (project_id,))
|
||||
conn.commit()
|
||||
return {"ok": True, "stage": _stage_public(_load_stage(conn, project_id, stage_key))}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/stages/{stage_key}/run")
|
||||
async def run_stage(project_id: int, stage_key: str, payload: StageRunRequest | None = None) -> dict[str, Any]:
|
||||
instruction = (payload.instruction if payload else None) or None
|
||||
@@ -777,7 +819,13 @@ async def confirm_stage(
|
||||
raise HTTPException(status_code=409, detail="本阶段正在生成中,请等待完成。")
|
||||
if not stage["content_json"]:
|
||||
raise HTTPException(status_code=400, detail="本阶段还没有内容,请先生成或手动填写。")
|
||||
content = json.loads(stage["content_json"])
|
||||
try:
|
||||
content = NORMALIZERS[stage_key](json.loads(stage["content_json"]))
|
||||
except StageContentError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
raw_content = json.loads(stage["content_json"])
|
||||
if raw_content.get("incoming_results"):
|
||||
content["incoming_results"] = raw_content["incoming_results"]
|
||||
chosen = payload.chosen_style_id if payload else None
|
||||
if stage_key == "style":
|
||||
directions = content.get("directions") or []
|
||||
@@ -815,11 +863,15 @@ async def restore_stage(project_id: int, stage_key: str) -> dict[str, Any]:
|
||||
stage = _stage_or_404(conn, project_id, stage_key)
|
||||
if not stage["confirmed_content_json"]:
|
||||
raise HTTPException(status_code=400, detail="本阶段还没有已确认版本,无需恢复。")
|
||||
content = json.loads(stage["confirmed_content_json"])
|
||||
current = json.loads(stage["content_json"]) if stage["content_json"] else {}
|
||||
if current.get("assistant_note"):
|
||||
content["assistant_note"] = current["assistant_note"]
|
||||
conn.execute(
|
||||
"UPDATE project_stages SET content_json=confirmed_content_json, "
|
||||
"UPDATE project_stages SET content_json=?, "
|
||||
"status='confirmed', draft_edited=0, updated_at=CURRENT_TIMESTAMP "
|
||||
"WHERE project_id = ? AND stage_key = ?",
|
||||
(project_id, stage_key),
|
||||
(json.dumps(content, ensure_ascii=False), project_id, stage_key),
|
||||
)
|
||||
conn.commit()
|
||||
row = _load_stage(conn, project_id, stage_key)
|
||||
|
||||
@@ -5,46 +5,173 @@ from typing import Any
|
||||
|
||||
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,
|
||||
mapping_problem,
|
||||
needs_user_output_map,
|
||||
validate_output_map,
|
||||
)
|
||||
from calliope.comfyui.parser import (
|
||||
classify_usage,
|
||||
effective_input_schema,
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
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
|
||||
from calliope.models.schemas import WorkflowAnalyze, WorkflowCreate, WorkflowUpdate
|
||||
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
|
||||
from calliope.queue.workflow_test import (
|
||||
TEST_SOURCE,
|
||||
find_active_test_job,
|
||||
list_test_jobs,
|
||||
purge_workflow_tests,
|
||||
)
|
||||
|
||||
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)
|
||||
data["workflow_json"] = json.loads(data["workflow_json"])
|
||||
stored = json.loads(data["input_schema"]) if data.get("input_schema") else []
|
||||
data["input_schema"] = effective_input_schema(stored, data["workflow_json"])
|
||||
data["output_schema"] = json.loads(data["output_schema"]) if data.get("output_schema") else []
|
||||
# Outputs are pure graph derivation (no manual overrides) — always fresh,
|
||||
# so parser upgrades reach old rows without a re-import.
|
||||
data["output_schema"] = parse_dynamic_outputs(data["workflow_json"])
|
||||
data["is_enabled"] = bool(data.get("is_enabled"))
|
||||
data["warnings"] = workflow_warnings(data["workflow_json"])
|
||||
raw_map = data.get("output_map")
|
||||
data["output_map"] = load_output_map(raw_map)
|
||||
# Pending flag: unknown output candidates the user has not mapped yet.
|
||||
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]:
|
||||
"""L2 preflight (HEL-643): workflow vs the target ComfyUI's /object_info.
|
||||
|
||||
An unreachable connection degrades to ``reachable: False`` with an honest
|
||||
「无法核查」 message — it never blocks and never pretends to pass.
|
||||
"""
|
||||
endpoint = settings.resolve_comfyui_endpoint(endpoint_id)
|
||||
if endpoint is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"endpoint_id": endpoint_id,
|
||||
"endpoint_name": None,
|
||||
"findings": [],
|
||||
"message": "没有可用的 ComfyUI 连接,无法核查节点与模型",
|
||||
}
|
||||
base = {
|
||||
"endpoint_id": endpoint.get("id"),
|
||||
"endpoint_name": endpoint.get("name"),
|
||||
"base_url": endpoint.get("base_url"),
|
||||
}
|
||||
client = client_for(endpoint)
|
||||
try:
|
||||
object_info = await client.object_info(force_refresh=refresh)
|
||||
except Exception as exc:
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"findings": [],
|
||||
"message": f"无法核查(连不上该 ComfyUI):{type(exc).__name__}",
|
||||
}
|
||||
finally:
|
||||
await client.close()
|
||||
findings = check_workflow_against(object_info, workflow_json)
|
||||
ok = not any(f.get("level") == "error" for f in findings)
|
||||
return {**base, "ok": ok, "reachable": True, "findings": findings}
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
|
||||
inputs = parse_dynamic_inputs(payload.workflow_json)
|
||||
outputs = parse_dynamic_outputs(payload.workflow_json)
|
||||
return {
|
||||
judgment = classify_usage(payload.workflow_json)
|
||||
result: dict[str, Any] = {
|
||||
"inputs": inputs,
|
||||
"outputs": outputs,
|
||||
"suggested_profile": detect_prompt_profile(payload.workflow_json),
|
||||
"warnings": workflow_warnings(payload.workflow_json),
|
||||
"usage": judgment["usage"],
|
||||
"usage_ambiguous": judgment["ambiguous"],
|
||||
"needs_mapping": judgment["needs_mapping"],
|
||||
"usage_problem": judgment["problem"],
|
||||
"usage_evidence": judgment["evidence"],
|
||||
"usable_usage": judgment["usable_usage"],
|
||||
}
|
||||
# L2 (optional): with a target connection the analyze response also
|
||||
# reports which nodes/models that ComfyUI actually has (HEL-643).
|
||||
if payload.endpoint_id:
|
||||
result["connection_check"] = await _connection_check(
|
||||
payload.endpoint_id, payload.workflow_json, payload.refresh
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _stored_schema(payload_schema: list[dict[str, Any]] | None, workflow: dict[str, Any]) -> str:
|
||||
"""Schema to persist: the confirm page's corrected copy, else the auto one."""
|
||||
if payload_schema and all(isinstance(e, dict) for e in payload_schema):
|
||||
return json.dumps(payload_schema)
|
||||
return json.dumps(parse_dynamic_inputs(workflow))
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/reanalyze")
|
||||
async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
"""Recompute input/output schemas from the stored workflow JSON.
|
||||
"""Recompute usage and input/output schemas from the stored workflow JSON.
|
||||
|
||||
Workflows imported before a parser upgrade keep stale (often empty)
|
||||
schemas — this refreshes them without re-importing.
|
||||
schemas — this refreshes them without re-importing. A usage or output
|
||||
mapping the user set by hand is never overwritten; only ``auto`` (and
|
||||
pending NULL) rows get the new judgment.
|
||||
"""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
@@ -53,11 +180,40 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
data = row_to_dict(row)
|
||||
workflow = json.loads(data["workflow_json"])
|
||||
inputs = parse_dynamic_inputs(workflow)
|
||||
judgment = classify_usage(workflow)
|
||||
usage = data["usage"]
|
||||
usage_source = data["usage_source"]
|
||||
if data["usage_source"] != "user" and judgment["usage"]:
|
||||
usage = judgment["usage"]
|
||||
usage_source = "auto"
|
||||
stored = json.loads(data["input_schema"]) if data["input_schema"] else []
|
||||
schema = effective_input_schema(stored, workflow)
|
||||
outputs = parse_dynamic_outputs(workflow)
|
||||
if data["output_map_source"] == "user":
|
||||
output_map, output_map_source = data["output_map"], "user"
|
||||
elif needs_user_output_map(workflow):
|
||||
# Unknown output candidates: no auto map, and the row must not
|
||||
# generate until the user designates the output nodes.
|
||||
output_map, output_map_source = None, None
|
||||
else:
|
||||
output_map, output_map_source = json.dumps(auto_output_map(workflow)), "auto"
|
||||
is_enabled = data["is_enabled"]
|
||||
if output_map_source is None or usage is None:
|
||||
is_enabled = 0
|
||||
conn.execute(
|
||||
"UPDATE workflows SET input_schema = ?, output_schema = ? WHERE id = ?",
|
||||
(json.dumps(inputs), json.dumps(outputs), workflow_id),
|
||||
"UPDATE workflows SET input_schema = ?, output_schema = ?,"
|
||||
" usage = ?, usage_source = ?, output_map = ?, output_map_source = ?,"
|
||||
" is_enabled = ? WHERE id = ?",
|
||||
(
|
||||
json.dumps(schema),
|
||||
json.dumps(outputs),
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
is_enabled,
|
||||
workflow_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
fresh = conn.execute(
|
||||
@@ -68,16 +224,86 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _last_test_summary(job: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Slim "上次测试" record for a workflow row (queried live, never stored)."""
|
||||
try:
|
||||
outputs = json.loads(job.get("output_paths_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
outputs = []
|
||||
return {
|
||||
"id": job["id"],
|
||||
"status": job.get("status"),
|
||||
"phase": job.get("phase"),
|
||||
"error": job.get("error"),
|
||||
"created_at": job.get("created_at"),
|
||||
"completed_at": job.get("completed_at"),
|
||||
"output_count": len(outputs) if isinstance(outputs, list) else 0,
|
||||
}
|
||||
|
||||
|
||||
@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()
|
||||
return [_serialize_workflow(r) for r in rows]
|
||||
out = [_serialize_workflow(r) for r in rows]
|
||||
# 上次测试 state is read live from the newest test job per workflow —
|
||||
# no cached column that could go stale (HEL-643).
|
||||
latest: dict[int, dict[str, Any]] = {}
|
||||
for jr in conn.execute(
|
||||
"SELECT * FROM jobs WHERE origin = 'workflow_test' AND id IN"
|
||||
" (SELECT MAX(id) FROM jobs WHERE origin = 'workflow_test' GROUP BY workflow_id)"
|
||||
).fetchall():
|
||||
latest[int(jr["workflow_id"])] = _last_test_summary(dict(jr))
|
||||
for data in out:
|
||||
data["last_test"] = latest.get(int(data["id"]))
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/check")
|
||||
async def check_workflow(workflow_id: int, payload: WorkflowCheckRequest) -> dict[str, Any]:
|
||||
"""L2 re-check of a saved workflow against its (or a chosen) connection.
|
||||
|
||||
The user installs missing nodes/models in ComfyUI, comes back here and
|
||||
re-checks — no re-import needed.
|
||||
"""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT workflow_json, endpoint_id FROM workflows WHERE id = ?", (workflow_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
endpoint_id = payload.endpoint_id or row["endpoint_id"]
|
||||
return await _connection_check(endpoint_id, json.loads(row["workflow_json"]), payload.refresh)
|
||||
|
||||
|
||||
@router.post("/dedupe")
|
||||
async def dedupe_workflows() -> dict[str, Any]:
|
||||
"""Delete byte-identical duplicate workflows, keeping the lowest id.
|
||||
@@ -115,38 +341,77 @@ async def dedupe_workflows() -> dict[str, Any]:
|
||||
|
||||
@router.post("")
|
||||
async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
|
||||
inputs = parse_dynamic_inputs(payload.workflow_json)
|
||||
outputs = parse_dynamic_outputs(payload.workflow_json)
|
||||
judgment = classify_usage(payload.workflow_json)
|
||||
usage = payload.usage or judgment["usage"]
|
||||
usage_source = "user" if payload.usage else "auto"
|
||||
kind = payload.kind or usage_to_kind(usage)
|
||||
if kind is None:
|
||||
# Indeterminate and nothing chosen: classify_output hint keeps old
|
||||
# callers working while the workflow stays pending confirmation.
|
||||
out_kinds = {
|
||||
o.get("kind")
|
||||
for o in parse_dynamic_outputs(payload.workflow_json)
|
||||
if o.get("kind") in ("image", "video")
|
||||
}
|
||||
kind = "video" if out_kinds == {"video"} else "image"
|
||||
profile = payload.prompt_profile or detect_prompt_profile(payload.workflow_json)
|
||||
# HEL-638: an explicit user output map wins; otherwise the trusted auto
|
||||
# map. Either way a graph with unknown output candidates is unusable
|
||||
# until the user designates the output nodes.
|
||||
if payload.output_map:
|
||||
problems = validate_output_map(payload.workflow_json, payload.output_map)
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
output_map = json.dumps(payload.output_map)
|
||||
output_map_source = payload.output_map_source or "user"
|
||||
else:
|
||||
auto_map = auto_output_map(payload.workflow_json)
|
||||
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(payload.workflow_json) or bool(payload.output_map)
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
# Byte-identical re-imports are the main way the library fills up with
|
||||
# same-named clones (the "@" typeahead then shows 20 identical rows).
|
||||
existing = conn.execute(
|
||||
"SELECT id, name FROM workflows WHERE name = ? AND kind = ? AND workflow_json = ?",
|
||||
(payload.name, payload.kind, json.dumps(payload.workflow_json)),
|
||||
(payload.name, kind, json.dumps(payload.workflow_json)),
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"An identical workflow already exists (id {existing['id']}, “{existing['name']}”).",
|
||||
detail=(
|
||||
f"An identical workflow already exists (id {existing['id']},"
|
||||
f" “{existing['name']}”)."
|
||||
),
|
||||
)
|
||||
# Unconfirmed workflows (ambiguous usage / unknown outputs without a
|
||||
# user mapping) are stored but disabled: they must not reach the
|
||||
# generate pages until the user confirms usage AND, for unknown
|
||||
# output nodes, designates the outputs on the connections page.
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
endpoint_id, is_enabled, usage, usage_source,
|
||||
output_map, output_map_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
payload.name,
|
||||
payload.kind,
|
||||
kind,
|
||||
json.dumps(payload.workflow_json),
|
||||
json.dumps(inputs),
|
||||
json.dumps(outputs),
|
||||
_stored_schema(payload.input_schema, payload.workflow_json),
|
||||
json.dumps(parse_dynamic_outputs(payload.workflow_json)),
|
||||
payload.description,
|
||||
profile,
|
||||
payload.endpoint_id,
|
||||
is_enabled,
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -178,9 +443,60 @@ 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
|
||||
# endpoint_id: explicit null clears the binding; other Nones are skips.
|
||||
explicit_null = {"endpoint_id"} & {k for k, v in data.items() if v is None}
|
||||
# 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
|
||||
# resets the row to pending (disabled).
|
||||
if "output_map" in data:
|
||||
if data["output_map"]:
|
||||
problems = validate_output_map(workflow_json, data["output_map"])
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
data["output_map"] = json.dumps(data["output_map"])
|
||||
data.setdefault("output_map_source", "user")
|
||||
else:
|
||||
data["output_map"] = None
|
||||
data["output_map_source"] = None
|
||||
data["is_enabled"] = 0
|
||||
if data.get("usage"):
|
||||
# Confirming / correcting the usage is an explicit user decision:
|
||||
# kind (output media) follows the usage. Whether it re-enables is
|
||||
# decided by the gate below — with unknown output nodes the user
|
||||
# must also designate the output nodes (HEL-638).
|
||||
data.setdefault("usage_source", "user")
|
||||
data["kind"] = usage_to_kind(data["usage"]) or existing["kind"]
|
||||
if "input_schema" in data and data["input_schema"] is not None:
|
||||
if not all(isinstance(e, dict) for e in data["input_schema"]):
|
||||
raise HTTPException(status_code=422, detail="input_schema 必须是参数对象数组")
|
||||
data["input_schema"] = json.dumps(data["input_schema"])
|
||||
# endpoint_id: explicit null clears the binding; other Nones are skips
|
||||
# (an output_map reset keeps its explicit NULLs).
|
||||
explicit_null = {"endpoint_id", "output_map", "output_map_source"} & {
|
||||
k for k, v in data.items() if v is None
|
||||
}
|
||||
data = {k: v for k, v in data.items() if v is not None or k in explicit_null}
|
||||
|
||||
# Enable gate: usage must be settled and, when the graph carries
|
||||
# unknown custom output nodes, a user output mapping must exist.
|
||||
usage_ok = bool(data.get("usage") or existing["usage"])
|
||||
map_raw = data.get("output_map", existing["output_map"])
|
||||
map_source = data.get("output_map_source", existing["output_map_source"])
|
||||
map_problem = mapping_problem(workflow_json, map_raw, map_source)
|
||||
if data.get("is_enabled"):
|
||||
if not usage_ok:
|
||||
raise HTTPException(status_code=400, detail="请先选择工作流用途,再启用该工作流")
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
elif (data.get("usage") or data.get("output_map")) and "is_enabled" not in data:
|
||||
# A usage-confirm or output-map-confirm path: enabled only when
|
||||
# both decisions are settled.
|
||||
data["is_enabled"] = 1 if (usage_ok and not map_problem) else 0
|
||||
if data:
|
||||
fields = ", ".join(f"{k} = :{k}" for k in data)
|
||||
data["id"] = workflow_id
|
||||
@@ -192,14 +508,426 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
|
||||
conn.close()
|
||||
|
||||
|
||||
def _job_public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(job)
|
||||
out["payload"] = json.loads(job.get("payload_json") or "{}")
|
||||
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
|
||||
out.pop("payload_json", None)
|
||||
out.pop("output_paths_json", None)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/test")
|
||||
async def test_workflow(workflow_id: int, payload: WorkflowTestRequest) -> dict[str, Any]:
|
||||
"""Launch (or re-attach to) one REAL test run of a workflow (HEL-643 L3).
|
||||
|
||||
The job goes through the ordinary queue → ComfyUI → outputs pipeline;
|
||||
only its artifacts and bookkeeping are isolated (origin='workflow_test').
|
||||
A dry-run setting is refused outright — placeholder output must never
|
||||
count as a test pass. Repeat clicks reuse the in-flight job.
|
||||
"""
|
||||
from calliope import config
|
||||
|
||||
if config.settings.dry_run:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
|
||||
"请到「设置」关闭 Dry-run 后再测试"
|
||||
),
|
||||
)
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
wf = conn.execute(
|
||||
"SELECT id, kind, workflow_json, input_schema, output_map, output_map_source,"
|
||||
" endpoint_id FROM workflows WHERE id = ?",
|
||||
(workflow_id,),
|
||||
).fetchone()
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
workflow_json = json.loads(wf["workflow_json"])
|
||||
broken = blocking_problem(workflow_json)
|
||||
if broken:
|
||||
raise HTTPException(status_code=400, detail=broken)
|
||||
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
|
||||
)
|
||||
input_values, problems = validate_input_values(schema, payload.input_values)
|
||||
if problems:
|
||||
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
|
||||
|
||||
endpoint_id = payload.endpoint_id or wf["endpoint_id"] or None
|
||||
snapshot = endpoint_snapshot(endpoint_id)
|
||||
active = find_active_test_job(workflow_id, (snapshot or {}).get("id"))
|
||||
if active is not None:
|
||||
return {"ok": True, "job": _job_public(active), "reused": True}
|
||||
|
||||
from calliope.routers.playground import ensure_playground_project
|
||||
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
job = queue_manager.enqueue(
|
||||
project_id=pid,
|
||||
kind=wf["kind"],
|
||||
workflow_id=workflow_id,
|
||||
scene_id=None,
|
||||
payload={
|
||||
"input_values": input_values,
|
||||
"source": TEST_SOURCE,
|
||||
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
|
||||
},
|
||||
origin="workflow_test",
|
||||
)
|
||||
await event_bus.publish(
|
||||
"job.created",
|
||||
{"job_id": job["id"], "kind": job["kind"], "source": TEST_SOURCE,
|
||||
"project_id": job["project_id"], "workflow_test": True},
|
||||
)
|
||||
return {"ok": True, "job": _job_public(job), "reused": False}
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/tests")
|
||||
async def list_workflow_tests(workflow_id: int) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
return list_test_jobs(workflow_id)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/clear-tests")
|
||||
async def clear_workflow_tests(workflow_id: int) -> dict[str, Any]:
|
||||
"""Delete every test record and artifact for this workflow."""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
try:
|
||||
removed = purge_workflow_tests(workflow_id)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
await event_bus.publish("job.deleted", {"workflow_id": workflow_id, "source": TEST_SOURCE})
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}")
|
||||
async def delete_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
cur = conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
# HEL-619: refuse to delete while queued/running jobs still use the
|
||||
# workflow — deleting sets their workflow_id NULL and the job dies
|
||||
# with "工作流已被删除" only after the user waits for it.
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workflows WHERE id = ?", (workflow_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
pending = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM jobs"
|
||||
" WHERE workflow_id = ? AND status IN ('pending', 'running')",
|
||||
(workflow_id,),
|
||||
).fetchone()["n"]
|
||||
if pending:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"该工作流还有 {pending} 个排队或执行中的任务,"
|
||||
"请先等任务完成或取消任务,再删除该工作流"
|
||||
),
|
||||
)
|
||||
# Test records/artifacts belong to the workflow — cascade (HEL-643).
|
||||
# The 409 above guarantees none are in flight here.
|
||||
purge_workflow_tests(workflow_id, allow_active=True)
|
||||
conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
|
||||
conn.commit()
|
||||
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()
|
||||
|
||||
@@ -10,6 +10,10 @@ from fastapi.testclient import TestClient
|
||||
from calliope.db import migrate_db
|
||||
from calliope.main import create_app
|
||||
|
||||
# Shared fake-ComfyUI HTTP server (defined in test_studio_generation,
|
||||
# reused by the generation / status / unknown-output-mapping suites).
|
||||
from tests.test_studio_generation import fake_comfy # noqa: F401,E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"value": "PASTE YOUR PROMPT HERE. For image-to-video describe the motion from the first frame; the frame fixes the look. Use the MiniMax-H3 prompt contract (integrated_multimodal_description / overall_soundscape / non_diegetic_music)."
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "PROMPT (feeds every stage)"
|
||||
}
|
||||
},
|
||||
"92": {
|
||||
"inputs": {
|
||||
"filename_prefix": "video/04_i2v_fl2v_4step_sla",
|
||||
"format": "auto",
|
||||
"format.codec": "auto",
|
||||
"codec": "auto",
|
||||
"video": [
|
||||
"130",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveVideo",
|
||||
"_meta": {
|
||||
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
|
||||
}
|
||||
},
|
||||
"115": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "16:9 (Widescreen)",
|
||||
"megapixels": 0.7,
|
||||
"multiple": 32
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "ResolutionSelector"
|
||||
}
|
||||
},
|
||||
"116": {
|
||||
"inputs": {
|
||||
"expression": "a"
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"119": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"120": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"121": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"120",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAEDecodeAudio"
|
||||
}
|
||||
},
|
||||
"122": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAEDecode"
|
||||
}
|
||||
},
|
||||
"124": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 4,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"125": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"129",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"126",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"135",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"124",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"131",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"126": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"131",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "BasicGuider"
|
||||
}
|
||||
},
|
||||
"127": {
|
||||
"inputs": {
|
||||
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNETLoader"
|
||||
}
|
||||
},
|
||||
"128": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
|
||||
"type": "minimax",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "CLIPLoader"
|
||||
}
|
||||
},
|
||||
"129": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"130": {
|
||||
"inputs": {
|
||||
"fps": 24,
|
||||
"bit_depth": 8,
|
||||
"color_space": "sRGB",
|
||||
"codec": "none",
|
||||
"images": [
|
||||
"122",
|
||||
0
|
||||
],
|
||||
"audio": [
|
||||
"121",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CreateVideo",
|
||||
"_meta": {
|
||||
"title": "CreateVideo"
|
||||
}
|
||||
},
|
||||
"131": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"width": [
|
||||
"115",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"115",
|
||||
1
|
||||
],
|
||||
"length": [
|
||||
"132",
|
||||
1
|
||||
],
|
||||
"clip": [
|
||||
"128",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
],
|
||||
"first_frame": [
|
||||
"901",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3ImageToVideo",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3ImageToVideo"
|
||||
}
|
||||
},
|
||||
"132": {
|
||||
"inputs": {
|
||||
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
|
||||
"values.a": [
|
||||
"133",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"133": {
|
||||
"inputs": {
|
||||
"value": 10
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "PrimitiveFloat"
|
||||
}
|
||||
},
|
||||
"135": {
|
||||
"inputs": {
|
||||
"sampler_name": "res_multistep"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"142": {
|
||||
"inputs": {
|
||||
"shift_video": 12,
|
||||
"shift_audio": 3,
|
||||
"model": [
|
||||
"900",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3SigmaShift",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3SigmaShift"
|
||||
}
|
||||
},
|
||||
"900": {
|
||||
"inputs": {
|
||||
"sparsity_ratio": 0.9,
|
||||
"block_size": "64",
|
||||
"min_seq_len": 8192,
|
||||
"dense_last_steps": 0,
|
||||
"protect_audio": true,
|
||||
"enabled": true,
|
||||
"dense_steps": "1",
|
||||
"dense_backend": "comfy_kitchen",
|
||||
"disable_fp16_accum": true,
|
||||
"stabilize_motion": false,
|
||||
"reference_protection": "Off",
|
||||
"tail_correction": false,
|
||||
"use_int8_qk": true,
|
||||
"engine": "comfy_kitchen",
|
||||
"model": [
|
||||
"127",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "H3SLAAttention",
|
||||
"_meta": {
|
||||
"title": "H3 SLA Attention 0.9/64 (their graph)"
|
||||
}
|
||||
},
|
||||
"901": {
|
||||
"inputs": {
|
||||
"image": "first_frame.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "FIRST FRAME (add a second LoadImage into last_frame for FL2V)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"value": "PASTE YOUR PROMPT HERE, in the reference contract:\nsubject_definitions:\n<Subject 1> is the person in <Picture 1>; their face, hair and clothing follow <Picture 1>.\n\nsummary:\n[reference generation] one continuous shot of <Subject 1> doing X, appearance taken from <Picture 1>.\n\nretention_analysis:\n<Subject 1> is fully preserved from <Picture 1>.\n\ndetailed_description:\n[Shot 1] ... camera, action, timing ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: N/A"
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "PROMPT (feeds every stage)"
|
||||
}
|
||||
},
|
||||
"92": {
|
||||
"inputs": {
|
||||
"filename_prefix": "video/05_ref2va_4step_sla",
|
||||
"format": "auto",
|
||||
"format.codec": "auto",
|
||||
"codec": "auto",
|
||||
"video": [
|
||||
"130",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveVideo",
|
||||
"_meta": {
|
||||
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
|
||||
}
|
||||
},
|
||||
"115": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "16:9 (Widescreen)",
|
||||
"megapixels": 0.7,
|
||||
"multiple": 32
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "ResolutionSelector"
|
||||
}
|
||||
},
|
||||
"116": {
|
||||
"inputs": {
|
||||
"expression": "a"
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"119": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"120": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"121": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"120",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAEDecodeAudio"
|
||||
}
|
||||
},
|
||||
"122": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAEDecode"
|
||||
}
|
||||
},
|
||||
"124": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 4,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"125": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"129",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"126",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"135",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"124",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"131",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"126": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"131",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "BasicGuider"
|
||||
}
|
||||
},
|
||||
"127": {
|
||||
"inputs": {
|
||||
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNETLoader"
|
||||
}
|
||||
},
|
||||
"128": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
|
||||
"type": "minimax",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "CLIPLoader"
|
||||
}
|
||||
},
|
||||
"129": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"130": {
|
||||
"inputs": {
|
||||
"fps": 24,
|
||||
"bit_depth": 8,
|
||||
"color_space": "sRGB",
|
||||
"codec": "none",
|
||||
"images": [
|
||||
"122",
|
||||
0
|
||||
],
|
||||
"audio": [
|
||||
"121",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CreateVideo",
|
||||
"_meta": {
|
||||
"title": "CreateVideo"
|
||||
}
|
||||
},
|
||||
"131": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"width": [
|
||||
"115",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"115",
|
||||
1
|
||||
],
|
||||
"length": [
|
||||
"132",
|
||||
1
|
||||
],
|
||||
"ref_image_size": "match",
|
||||
"clip": [
|
||||
"128",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
],
|
||||
"audio_vae": [
|
||||
"120",
|
||||
0
|
||||
],
|
||||
"ref_images.ref_image_0": [
|
||||
"901",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3ReferenceToVideo",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3ReferenceToVideo"
|
||||
}
|
||||
},
|
||||
"132": {
|
||||
"inputs": {
|
||||
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
|
||||
"values.a": [
|
||||
"133",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"133": {
|
||||
"inputs": {
|
||||
"value": 10
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "PrimitiveFloat"
|
||||
}
|
||||
},
|
||||
"135": {
|
||||
"inputs": {
|
||||
"sampler_name": "res_multistep"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"142": {
|
||||
"inputs": {
|
||||
"shift_video": 12,
|
||||
"shift_audio": 3,
|
||||
"model": [
|
||||
"900",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3SigmaShift",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3SigmaShift"
|
||||
}
|
||||
},
|
||||
"900": {
|
||||
"inputs": {
|
||||
"sparsity_ratio": 0.9,
|
||||
"block_size": "64",
|
||||
"min_seq_len": 8192,
|
||||
"dense_last_steps": 0,
|
||||
"protect_audio": true,
|
||||
"enabled": true,
|
||||
"dense_steps": "1",
|
||||
"dense_backend": "comfy_kitchen",
|
||||
"disable_fp16_accum": true,
|
||||
"stabilize_motion": false,
|
||||
"reference_protection": "Off",
|
||||
"tail_correction": false,
|
||||
"use_int8_qk": true,
|
||||
"engine": "comfy_kitchen",
|
||||
"model": [
|
||||
"127",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "H3SLAAttention",
|
||||
"_meta": {
|
||||
"title": "H3 SLA Attention 0.9/64 (their graph)"
|
||||
}
|
||||
},
|
||||
"901": {
|
||||
"inputs": {
|
||||
"image": "reference_1.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "REFERENCE IMAGE 1 (<Picture 1>); the node grows more image / video / audio slots"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"UNKNOWN": "qwen-image-2.1-Q4_K_M.gguf"
|
||||
},
|
||||
"_meta": {
|
||||
"title": "Unet Loader (GGUF)"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "加载CLIP"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "加载VAE"
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"inputs": {
|
||||
"prompt": "参考角色五官,重新生成图片:高细节皮肤质感",
|
||||
"negative_prompt": "模糊,低质量,文字乱码,变形",
|
||||
"resolution": 1024,
|
||||
"clip": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"images.image_1": [
|
||||
"10",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"_meta": {
|
||||
"title": "Text Encode Qwen Image 2.1"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": {
|
||||
"title": "空Latent图像"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"seed": 278835418249884,
|
||||
"steps": 25,
|
||||
"cfg": 1,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"positive": [
|
||||
"4",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"4",
|
||||
1
|
||||
],
|
||||
"latent_image": [
|
||||
"4",
|
||||
2
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "K采样器"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE解码"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"filename_prefix": "qwen_image",
|
||||
"images": [
|
||||
"7",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "保存图像"
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"UNKNOWN": "auto"
|
||||
},
|
||||
"_meta": {
|
||||
"title": "Patch Sage Attention KJ"
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"inputs": {
|
||||
"image": "3f24a075cd7b4118829be1b222ca1ab9.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "加载图像"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Workflow uniqueness is independent of the selected task skills."""
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from calliope.routers.agent import MessageCreate
|
||||
|
||||
|
||||
def workflow(n=1):
|
||||
return {"type": "workflow", "id": n, "name": f"workflow-{n}", "kind": "image"}
|
||||
|
||||
|
||||
def skill(n=1):
|
||||
return {"type": "skill", "name": f"skill-{n}"}
|
||||
|
||||
|
||||
def test_workflow_and_skills_can_be_sent_together():
|
||||
message = MessageCreate(content="Create an image", mentions=[workflow(), *[skill(i) for i in range(4)]])
|
||||
assert len(message.mentions) == 5
|
||||
assert message.mentions[0].id == 1
|
||||
|
||||
|
||||
def test_legacy_single_workflow_is_accepted():
|
||||
assert MessageCreate(mentions=[workflow()]).mentions[0].type == "workflow"
|
||||
|
||||
|
||||
def test_skills_without_workflow_are_accepted():
|
||||
assert len(MessageCreate(mentions=[skill(i) for i in range(4)]).mentions) == 4
|
||||
|
||||
|
||||
def test_second_workflow_is_still_rejected():
|
||||
with pytest.raises(ValidationError, match="Only one @workflow"):
|
||||
MessageCreate(mentions=[workflow(), skill(), workflow(2)])
|
||||
|
||||
|
||||
def test_fifth_skill_is_rejected():
|
||||
with pytest.raises(ValidationError, match="At most four skill"):
|
||||
MessageCreate(mentions=[skill(i) for i in range(5)])
|
||||
@@ -374,6 +374,61 @@ def test_comfy_test_requires_url(client):
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_schemeless_url_is_format_error_everywhere(client):
|
||||
"""HEL-619: a schemeless/garbled address is a 400 format error on every
|
||||
entry point (probe + save), not a silent http:// prefix that later
|
||||
surfaces as a DNS-classified probe failure."""
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
for url in ("192.168.0.1:8188", "http:192.168.0.1", "ht!tp://x", "http://a b.test/"):
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints/test", json={"base_url": url, "timeout_sec": 2}
|
||||
)
|
||||
assert r.status_code == 400, url
|
||||
assert "http://" in r.json()["detail"] or "https://" in r.json()["detail"]
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints", json={"name": "无协议", "base_url": url}
|
||||
)
|
||||
assert r.status_code == 400, url
|
||||
assert "无协议" not in [ep.get("name") for ep in settings.comfyui_endpoints or []]
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_save_endpoint_runs_ssrf_guard(client):
|
||||
"""HEL-619: the SSRF guard applies on save (create + edit), not only on
|
||||
the connectivity probe; loopback stays allowed."""
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
_clear_endpoints()
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "元数据", "base_url": "http://169.254.169.254/latest"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "禁止" in r.json()["detail"]
|
||||
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "本机", "base_url": "http://127.0.0.1:8188"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
eid = r.json()["endpoints"][-1]["id"]
|
||||
|
||||
r = client.patch(
|
||||
f"/api/comfyui/endpoints/{eid}",
|
||||
json={"base_url": "http://169.254.169.254/latest"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
# the stored endpoint is untouched
|
||||
stored = next(ep for ep in settings.comfyui_endpoints if ep["id"] == eid)
|
||||
assert stored["base_url"] == "http://127.0.0.1:8188"
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# workflow binding + job snapshots
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -381,10 +436,21 @@ def test_comfy_test_requires_url(client):
|
||||
|
||||
def _import_workflow(client, name="H3 视频", kind="video", endpoint_id=None):
|
||||
wf_json = {
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "model.safetensors"},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "placeholder prompt", "clip": ["4", 1]},
|
||||
},
|
||||
# HEL-636: a workflow needs a recognizable output node to classify a
|
||||
# usage and stay enabled; without it generation is refused.
|
||||
"9": {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {"images": ["6", 0]},
|
||||
"_meta": {"title": "Video Output (Output:video)"},
|
||||
},
|
||||
}
|
||||
payload = {"name": name, "kind": kind, "workflow_json": wf_json}
|
||||
if endpoint_id is not None:
|
||||
@@ -531,6 +597,41 @@ def test_queue_enqueue_stamps_all_paths(client):
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_workflow_delete_blocked_while_jobs_pending(client):
|
||||
"""HEL-619: deleting a workflow with pending/running jobs is refused
|
||||
with 409 instead of silently nulling the jobs' workflow_id."""
|
||||
from calliope.db import get_db
|
||||
from calliope.queue.manager import queue_manager
|
||||
|
||||
wf = _import_workflow(client).json()
|
||||
project = client.post(
|
||||
"/api/projects", json={"title": "删除保护测试", "idea": "x"}
|
||||
).json()
|
||||
# Pause the lifespan-started worker so the pending job STAYS pending.
|
||||
queue_manager.paused = True
|
||||
try:
|
||||
queue_manager.enqueue(
|
||||
project_id=project["id"], kind="video", workflow_id=wf["id"], payload={}
|
||||
)
|
||||
|
||||
r = client.delete(f"/api/workflows/{wf['id']}")
|
||||
assert r.status_code == 409
|
||||
assert "排队" in r.json()["detail"]
|
||||
finally:
|
||||
queue_manager.paused = False
|
||||
|
||||
# job finished → deletion allowed again
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute("UPDATE jobs SET status = 'done' WHERE project_id = ?", (project["id"],))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
r = client.delete(f"/api/workflows/{wf['id']}")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_settings_public_dict_masks_endpoint_tokens(client):
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
"""HEL-633: the three image-input workflows the user supplied — Qwen Image 2.1
|
||||
图片编辑, MiniMax H3 首帧图生视频 (04) and H3 参考图生视频 (05, 1–9 refs).
|
||||
|
||||
Slots come from real wiring, every picked image is uploaded to ComfyUI and
|
||||
lands in the node input it belongs to (in order, nothing stale), and a
|
||||
broken export is refused before anything is queued."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.parser import detect_untagged_inputs, parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.smart_fill import smart_fill_inputs
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import (
|
||||
PNG_1PX,
|
||||
_FakeComfyHandler,
|
||||
_import_workflow,
|
||||
_real_run,
|
||||
_wait_done,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
I2V = "h3_i2v_04_API.json" # 04_i2v_fl2v_4step_sla (1).json
|
||||
REF = "h3_ref2va_05_API.json" # 05_ref2va_4step_sla (1).json
|
||||
QWEN_EDIT = "qwen21_edit_sage_API.json" # qwen-image-2.1-图片编辑-sage加速.json
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
REF_KEY = "131@ref_images"
|
||||
LAST_KEY = "131@last_frame"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _by_node(inputs: list[dict]) -> dict[str, dict]:
|
||||
return {i["nodeId"]: i for i in inputs}
|
||||
|
||||
|
||||
def _ref_links(wf: dict, node_id: str = "131") -> list[tuple[str, str]]:
|
||||
"""[(input name, LoadImage image value), …] in slot order."""
|
||||
inputs = wf[node_id]["inputs"]
|
||||
names = sorted(
|
||||
(k for k in inputs if k.startswith("ref_images.ref_image_")),
|
||||
key=lambda k: int(k.rsplit("_", 1)[1]),
|
||||
)
|
||||
return [(k, wf[inputs[k][0]]["inputs"]["image"]) for k in names]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slot detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_i2v_exposes_required_first_frame_and_optional_last_frame():
|
||||
"""HEL-671: the 04 export is H3 FL2VA — `last_frame` is unwired but real."""
|
||||
inputs = parse_dynamic_inputs(_load(I2V))
|
||||
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
|
||||
assert media == [
|
||||
{
|
||||
"nodeId": "901",
|
||||
"label": "首帧",
|
||||
"role": "first_frame",
|
||||
"kind": "image",
|
||||
"defaultValue": None,
|
||||
"required": True,
|
||||
"auto": True,
|
||||
},
|
||||
{
|
||||
"nodeId": LAST_KEY,
|
||||
"label": "尾帧",
|
||||
"role": "last_frame",
|
||||
"kind": "image",
|
||||
"defaultValue": None,
|
||||
"required": False,
|
||||
"auto": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_wired_last_frame_is_its_own_slot_not_a_second_virtual_one():
|
||||
wf = _load(I2V)
|
||||
wf["950"] = {"class_type": "LoadImage", "inputs": {"image": "last.png"}}
|
||||
wf["131"]["inputs"]["last_frame"] = ["950", 0]
|
||||
media = [i for i in parse_dynamic_inputs(wf) if i["kind"] == "image"]
|
||||
assert [(i["nodeId"], i["role"], i["required"]) for i in media] == [
|
||||
("901", "first_frame", True),
|
||||
("950", "last_frame", True),
|
||||
]
|
||||
|
||||
|
||||
def test_first_frame_through_a_custom_loader_gets_no_last_frame_slot():
|
||||
# Repo example: VHS_LoadImagePath → resize → first_frame; no slot pair to mirror.
|
||||
wf = json.loads(
|
||||
(EXAMPLES / "minimax_h3-Turbo_Image2Video_20260813_API.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert not any(i.get("role") == "last_frame" for i in parse_dynamic_inputs(wf))
|
||||
|
||||
|
||||
def test_ref2v_exposes_one_group_of_one_to_nine_reference_images():
|
||||
inputs = parse_dynamic_inputs(_load(REF))
|
||||
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
|
||||
assert len(media) == 1
|
||||
group = media[0]
|
||||
assert group["nodeId"] == REF_KEY
|
||||
assert group["kind"] == "image_list"
|
||||
assert group["role"] == "reference_image"
|
||||
assert group["label"] == "参考图"
|
||||
assert (group["min"], group["max"], group["required"]) == (1, 9, True)
|
||||
# the parameters HEL-627 wired stay exposed
|
||||
by_node = _by_node(inputs)
|
||||
for key in ("1", "115@aspect_ratio", "115@megapixels", "124@steps", "124@denoise",
|
||||
"129@noise_seed", "133"):
|
||||
assert key in by_node, key
|
||||
assert not any(i["kind"] == "video" for i in inputs)
|
||||
|
||||
|
||||
def test_example_five_ref_workflow_folds_into_the_same_group():
|
||||
# The repo example is (Input:…)-tagged and keeps its tagged slots; stripped
|
||||
# of tags (as a plain ComfyUI export) its five loaders become one group.
|
||||
wf = json.loads((EXAMPLES / "video_minimax_h3_r2v_5ref_API.json").read_text(encoding="utf-8"))
|
||||
inputs = detect_untagged_inputs(wf)
|
||||
groups = [i for i in inputs if i["kind"] == "image_list"]
|
||||
assert [g["nodeId"] for g in groups] == ["178@ref_images"]
|
||||
# its five LoadImages are the group, not five separate slots
|
||||
assert not any(i["kind"] == "image" for i in inputs if i.get("role") != "mask")
|
||||
|
||||
|
||||
def test_qwen_edit_exposes_input_image_real_resolution_and_hides_orphan_latent():
|
||||
inputs = parse_dynamic_inputs(_load(QWEN_EDIT))
|
||||
by_node = _by_node(inputs)
|
||||
img = by_node["10"]
|
||||
assert (img["kind"], img["role"], img["label"]) == ("image", "image", "输入图")
|
||||
assert img["required"] is True
|
||||
res = by_node["4@resolution"]
|
||||
assert (res["role"], res["defaultValue"], res["min"], res["max"], res["step"]) == (
|
||||
"resolution", 1024, 0, 4096, 32,
|
||||
)
|
||||
assert by_node["4@prompt"]["role"] == "prompt"
|
||||
assert by_node["4@negative_prompt"]["role"] == "negative"
|
||||
for key in ("6@seed", "6@steps", "6@cfg", "6@denoise"):
|
||||
assert key in by_node
|
||||
# EmptyLatentImage #5 feeds nothing — its width/height would do nothing
|
||||
assert not any(i["nodeId"].startswith("5@") for i in inputs)
|
||||
|
||||
|
||||
def test_unwired_load_image_gets_no_slot():
|
||||
wf = _load(I2V)
|
||||
wf["950"] = {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "last.png"},
|
||||
"_meta": {"title": "LAST"},
|
||||
}
|
||||
assert "950" not in _by_node(parse_dynamic_inputs(wf))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Broken exports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_broken_qwen_export_is_flagged_and_blocked():
|
||||
warnings = workflow_warnings(_load(QWEN_EDIT))
|
||||
errors = {(w["code"], w["node_id"]) for w in warnings if w["level"] == "error"}
|
||||
assert errors == {
|
||||
("missing_class_type", "1"),
|
||||
("missing_class_type", "9"),
|
||||
("missing_model_link", "6"),
|
||||
}
|
||||
assert ("orphan_latent", "5") in {(w["code"], w["node_id"]) for w in warnings}
|
||||
msg = blocking_problem(_load(QWEN_EDIT))
|
||||
assert msg and "Unet Loader (GGUF)" in msg and "重新导出" in msg
|
||||
|
||||
|
||||
def test_h3_workflows_are_clean():
|
||||
assert workflow_warnings(_load(I2V)) == []
|
||||
assert workflow_warnings(_load(REF)) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patching 1 / 4 / 9 reference images
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 4, 9])
|
||||
def test_patch_wires_each_reference_image_in_order(count):
|
||||
base = _load(REF)
|
||||
paths = [f"/assets/uploads/ref-{i}.png" for i in range(1, count + 1)]
|
||||
wf = patch_workflow(base, {REF_KEY: paths})
|
||||
links = _ref_links(wf)
|
||||
assert links == [(f"ref_images.ref_image_{i}", p) for i, p in enumerate(paths)]
|
||||
# every loader is a real, distinct LoadImage feeding node 131
|
||||
loader_ids = [wf["131"]["inputs"][name][0] for name, _ in links]
|
||||
assert len(set(loader_ids)) == count
|
||||
assert all(wf[n]["class_type"] == "LoadImage" for n in loader_ids)
|
||||
assert loader_ids[0] == "901"
|
||||
# the base workflow is untouched
|
||||
assert _ref_links(base) == [("ref_images.ref_image_0", "reference_1.png")]
|
||||
|
||||
|
||||
def test_patch_shrinking_removes_stale_slots_and_loaders():
|
||||
nine = patch_workflow(_load(REF), {REF_KEY: [f"/u/{i}.png" for i in range(9)]})
|
||||
two = patch_workflow(nine, {REF_KEY: ["/u/b.png", "/u/a.png"]})
|
||||
assert _ref_links(two) == [
|
||||
("ref_images.ref_image_0", "/u/b.png"),
|
||||
("ref_images.ref_image_1", "/u/a.png"),
|
||||
]
|
||||
load_images = [n for n, v in two.items() if v.get("class_type") == "LoadImage"]
|
||||
assert len(load_images) == 2
|
||||
assert not any("/u/8.png" in json.dumps(v) for v in two.values())
|
||||
|
||||
|
||||
def test_single_slot_values_patch_first_frame_and_qwen_input():
|
||||
i2v = patch_workflow(_load(I2V), {"901": "/assets/uploads/first.png"})
|
||||
assert i2v["901"]["inputs"]["image"] == "/assets/uploads/first.png"
|
||||
assert i2v["131"]["inputs"]["first_frame"] == ["901", 0]
|
||||
qwen = patch_workflow(_load(QWEN_EDIT), {"10": "/assets/uploads/in.png", "4@resolution": 1536})
|
||||
assert qwen["10"]["inputs"]["image"] == "/assets/uploads/in.png"
|
||||
assert qwen["4"]["inputs"]["resolution"] == 1536
|
||||
|
||||
|
||||
def test_patch_last_frame_adds_its_own_load_image():
|
||||
base = _load(I2V)
|
||||
wf = patch_workflow(base, {"901": "/u/first.png", LAST_KEY: "/u/last.png"})
|
||||
node = wf["131"]["inputs"]
|
||||
assert node["first_frame"] == ["901", 0]
|
||||
last_id = node["last_frame"][0]
|
||||
assert last_id not in base and node["last_frame"] == [last_id, 0]
|
||||
assert wf[last_id]["class_type"] == "LoadImage"
|
||||
assert wf[last_id]["inputs"] == {"image": "/u/last.png"}
|
||||
assert wf["901"]["inputs"]["image"] == "/u/first.png"
|
||||
# base untouched; patching the result again rewires nothing
|
||||
assert "last_frame" not in base["131"]["inputs"]
|
||||
again = patch_workflow(wf, {LAST_KEY: "/u/other.png"})
|
||||
assert again["131"]["inputs"]["last_frame"] == [last_id, 0]
|
||||
|
||||
|
||||
def test_patch_without_last_frame_leaves_first_frame_only_graph():
|
||||
for values in ({"901": "/u/first.png"}, {"901": "/u/first.png", LAST_KEY: ""}):
|
||||
wf = patch_workflow(_load(I2V), values)
|
||||
assert "last_frame" not in wf["131"]["inputs"]
|
||||
assert sorted(wf) == sorted(_load(I2V))
|
||||
|
||||
|
||||
def test_legacy_job_values_keyed_by_load_image_still_patch():
|
||||
"""Jobs queued before HEL-633 carry the LoadImage id, not the group key."""
|
||||
wf = patch_workflow(_load(REF), {"901": "/assets/uploads/old.png"})
|
||||
assert _ref_links(wf) == [("ref_images.ref_image_0", "/assets/uploads/old.png")]
|
||||
|
||||
|
||||
def test_smart_fill_hands_reference_list_to_the_group():
|
||||
inputs = parse_dynamic_inputs(_load(REF))
|
||||
refs = [f"/r/{i}.png" for i in range(12)]
|
||||
values = smart_fill_inputs(inputs, prompt="p", ref_images=refs)
|
||||
assert values[REF_KEY] == refs[:9]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uploads_dir(client):
|
||||
d = settings.assets_dir / "uploads"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _pngs(d: Path, n: int) -> list[str]:
|
||||
out = []
|
||||
for i in range(n):
|
||||
p = d / f"ref{i + 1}.png"
|
||||
p.write_bytes(PNG_1PX)
|
||||
out.append(str(p))
|
||||
return out
|
||||
|
||||
|
||||
def test_validate_reference_count_limits(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_load(REF))
|
||||
base = {"1": "prompt"}
|
||||
for n in (1, 4, 9):
|
||||
clean, errs = validate_input_values(schema, {**base, REF_KEY: _pngs(uploads_dir, n)})
|
||||
assert errs == [] and len(clean[REF_KEY]) == n
|
||||
_, errs = validate_input_values(schema, {**base, REF_KEY: _pngs(uploads_dir, 10)})
|
||||
assert errs == ["参考图最多 9 张(当前 10 张),请删除多余的图片"]
|
||||
_, errs = validate_input_values(schema, base)
|
||||
assert errs == ["需要至少 1 张参考图"]
|
||||
_, errs = validate_input_values(schema, {**base, REF_KEY: []})
|
||||
assert errs == ["需要至少 1 张参考图"]
|
||||
|
||||
|
||||
def test_validate_rejects_bad_reference_files(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_load(REF))
|
||||
gif = uploads_dir / "anim.gif"
|
||||
gif.write_bytes(b"GIF89a")
|
||||
ok = _pngs(uploads_dir, 1)[0]
|
||||
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: [ok, str(gif)]})
|
||||
assert errs == ["参考图 2:格式不支持,请使用 PNG / JPG / WebP"]
|
||||
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: ["/etc/passwd.png"]})
|
||||
assert errs == ["参考图 1:只能使用在本页上传或从生成结果选入的图片"]
|
||||
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: [str(uploads_dir / "gone.png")]})
|
||||
assert errs == ["参考图 1:文件已不存在,请重新上传"]
|
||||
|
||||
|
||||
def test_validate_requires_first_frame_and_input_image():
|
||||
_, errs = validate_input_values(parse_dynamic_inputs(_load(I2V)), {"1": "p"})
|
||||
assert errs == ["请上传首帧"]
|
||||
_, errs = validate_input_values(
|
||||
parse_dynamic_inputs(_load(I2V)), {"1": "p", LAST_KEY: "/u/l.png"}
|
||||
)
|
||||
assert errs == ["请上传首帧"]
|
||||
clean, errs = validate_input_values(
|
||||
parse_dynamic_inputs(_load(I2V)), {"1": "p", "901": "/u/f.png"}
|
||||
)
|
||||
assert errs == [] and LAST_KEY not in clean
|
||||
_, errs = validate_input_values(parse_dynamic_inputs(_load(QWEN_EDIT)), {"4@prompt": "p"})
|
||||
assert errs == ["请上传输入图"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full chain against a fake ComfyUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _NamedUploadHandler(_FakeComfyHandler):
|
||||
"""/upload/image answers with the uploaded file's own name, like ComfyUI."""
|
||||
|
||||
def do_POST(self): # noqa: N802 — BaseHTTPRequestHandler hook name
|
||||
if self.path != "/upload/image":
|
||||
super().do_POST()
|
||||
return
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else b""
|
||||
self.server.requests.append(self.path)
|
||||
self.server.uploads.append(body)
|
||||
m = re.search(rb'filename="([^"]+)"', body)
|
||||
name = m.group(1).decode() if m else "x.png"
|
||||
self.server.upload_names.append(name)
|
||||
self._json(200, {"name": name, "subfolder": "calliope", "type": "input"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def named_comfy():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _NamedUploadHandler)
|
||||
server.requests = []
|
||||
server.prompts = []
|
||||
server.uploads = []
|
||||
server.upload_names = []
|
||||
server.history = {}
|
||||
server.fail_prompt = False
|
||||
server.out_name = "ref2v_00001_.mp4"
|
||||
server.outputs_key = "images"
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _upload(client, name: str) -> str:
|
||||
r = client.post("/api/playground/uploads", files={"file": (name, PNG_1PX, "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["path"]
|
||||
|
||||
|
||||
def _generate(client, wf_id: int, values: dict, source: str = "studio-video"):
|
||||
return client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": values,
|
||||
"source": source,
|
||||
"endpoint_id": settings.comfyui_active_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 4, 9])
|
||||
def test_ref2v_chain_uploads_every_image_and_wires_it_in_order(client, named_comfy, count):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
|
||||
paths = [_upload(client, f"角色{i}.png") for i in range(1, count + 1)]
|
||||
r = _generate(
|
||||
client,
|
||||
wf_id,
|
||||
{"1": "<Subject 1> 走进雨夜码头", REF_KEY: paths, "124@steps": 4, "133": 2},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["payload"]["input_values"][REF_KEY] == paths
|
||||
|
||||
assert len(named_comfy.prompts) == 1
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
expected = [f"calliope/{Path(p).name}" for p in paths]
|
||||
assert [img for _k, img in _ref_links(prompt)] == expected
|
||||
assert named_comfy.upload_names == [Path(p).name for p in paths]
|
||||
assert prompt["1"]["inputs"]["value"] == "<Subject 1> 走进雨夜码头"
|
||||
assert prompt["124"]["inputs"]["steps"] == 4
|
||||
assert prompt["133"]["inputs"]["value"] == 2
|
||||
# no leftover default reference and no unconsumed LoadImage
|
||||
assert "reference_1.png" not in json.dumps(prompt)
|
||||
loaders = {n for n, v in prompt.items() if v["class_type"] == "LoadImage"}
|
||||
wired = {v[0] for k, v in prompt["131"]["inputs"].items() if k.startswith("ref_images.")}
|
||||
assert loaders == wired
|
||||
|
||||
|
||||
def test_ref2v_replace_and_delete_leave_no_stale_image(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
|
||||
a, b, c = (_upload(client, n) for n in ("a.png", "b.png", "c.png"))
|
||||
r = _generate(client, wf_id, {"1": "x", REF_KEY: [a, b, c]})
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
# user replaced #2 with d and deleted #3
|
||||
d = _upload(client, "d.png")
|
||||
r = _generate(client, wf_id, {"1": "x", REF_KEY: [a, d]})
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
second = named_comfy.prompts[1]["prompt"]
|
||||
assert [img for _k, img in _ref_links(second)] == [
|
||||
f"calliope/{Path(a).name}",
|
||||
f"calliope/{Path(d).name}",
|
||||
]
|
||||
dumped = json.dumps(second)
|
||||
assert Path(b).name not in dumped and Path(c).name not in dumped
|
||||
|
||||
|
||||
def test_ref2v_ten_images_rejected_before_queue(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
|
||||
paths = [_upload(client, f"r{i}.png") for i in range(10)]
|
||||
r = _generate(client, wf_id, {"1": "x", REF_KEY: paths})
|
||||
assert r.status_code == 400
|
||||
assert "参考图最多 9 张" in r.json()["detail"]
|
||||
r = _generate(client, wf_id, {"1": "x"})
|
||||
assert r.status_code == 400
|
||||
assert "需要至少 1 张参考图" in r.json()["detail"]
|
||||
assert named_comfy.prompts == [] and named_comfy.upload_names == []
|
||||
|
||||
|
||||
def test_i2v_chain_uploads_first_frame(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 图生视频", "video", _load(I2V))
|
||||
r = _generate(client, wf_id, {"1": "镜头缓慢推近"})
|
||||
assert r.status_code == 400 and "请上传首帧" in r.json()["detail"]
|
||||
first = _upload(client, "首帧.png")
|
||||
r = _generate(client, wf_id, {"1": "镜头缓慢推近", "901": first, "124@denoise": 0.8})
|
||||
assert r.status_code == 200, r.text
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
assert prompt["901"]["inputs"]["image"] == f"calliope/{Path(first).name}"
|
||||
assert prompt["131"]["inputs"]["first_frame"] == ["901", 0]
|
||||
assert "last_frame" not in prompt["131"]["inputs"]
|
||||
assert prompt["124"]["inputs"]["denoise"] == 0.8
|
||||
|
||||
|
||||
def test_fl2v_chain_uploads_first_and_last_frame_into_their_own_inputs(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 首尾帧生视频", "video", _load(I2V))
|
||||
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
|
||||
assert listed["usage"] == "image2video"
|
||||
slots = {i["nodeId"]: i for i in listed["input_schema"] if i["kind"] == "image"}
|
||||
assert slots["901"]["role"] == "first_frame" and slots[LAST_KEY]["role"] == "last_frame"
|
||||
last = _upload(client, "尾帧.png")
|
||||
r = _generate(client, wf_id, {"1": "从白天过渡到夜晚", LAST_KEY: last})
|
||||
assert r.status_code == 400 and "请上传首帧" in r.json()["detail"]
|
||||
first = _upload(client, "首帧.png")
|
||||
r = _generate(client, wf_id, {"1": "从白天过渡到夜晚", "901": first, LAST_KEY: last})
|
||||
assert r.status_code == 200, r.text
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
node = prompt["131"]["inputs"]
|
||||
assert prompt[node["first_frame"][0]]["inputs"]["image"] == f"calliope/{Path(first).name}"
|
||||
assert prompt[node["last_frame"][0]]["inputs"]["image"] == f"calliope/{Path(last).name}"
|
||||
assert node["first_frame"][0] != node["last_frame"][0]
|
||||
assert sorted(named_comfy.upload_names) == sorted([Path(first).name, Path(last).name])
|
||||
|
||||
|
||||
def test_migration_renames_the_imported_first_frame_workflow(tmp_path):
|
||||
import asyncio
|
||||
import sqlite3
|
||||
|
||||
from calliope.db import migrate_db
|
||||
|
||||
db = tmp_path / "c.db"
|
||||
asyncio.run(migrate_db(db))
|
||||
conn = sqlite3.connect(db)
|
||||
i2v, ref = json.dumps(_load(I2V)), json.dumps(_load(REF))
|
||||
conn.executemany(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, description) VALUES (?, 'video', ?, ?)",
|
||||
[
|
||||
("H3 图生视频·首帧(04 用户提供)", i2v, None),
|
||||
("H3图生视频·首帧", i2v, "我自己写的说明"),
|
||||
("图生视频·首帧模板", ref, None), # not the FL2VA graph — left alone
|
||||
("我的视频工作流", i2v, None), # user already renamed — left alone
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
asyncio.run(migrate_db(db))
|
||||
asyncio.run(migrate_db(db)) # idempotent
|
||||
conn = sqlite3.connect(db)
|
||||
rows = conn.execute("SELECT name, description FROM workflows ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
assert rows[0] == (
|
||||
"H3 首尾帧生视频(04 用户提供)",
|
||||
"首帧必填,尾帧可选:只传首帧为图生视频,同时传尾帧为首尾帧生视频",
|
||||
)
|
||||
assert rows[1] == ("H3首尾帧生视频", "我自己写的说明") # suffix kept, description kept
|
||||
assert rows[2] == ("图生视频·首帧模板", None)
|
||||
assert rows[3] == ("我的视频工作流", None)
|
||||
|
||||
|
||||
def test_broken_qwen_import_warns_and_generation_is_refused(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": _load(QWEN_EDIT)})
|
||||
assert analyzed.status_code == 200
|
||||
codes = {w["code"] for w in analyzed.json()["warnings"]}
|
||||
assert {"missing_class_type", "missing_model_link", "orphan_latent"} <= codes
|
||||
# importing is allowed (the user may fix it later) …
|
||||
wf_id = _import_workflow(client, "Qwen 图片编辑", "image", _load(QWEN_EDIT))
|
||||
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
|
||||
assert any(w["level"] == "error" for w in listed["warnings"])
|
||||
# … but it never reaches ComfyUI and never shows as done
|
||||
img = _upload(client, "门面.png")
|
||||
r = _generate(client, wf_id, {"4@prompt": "换招牌", "10": img}, source="studio-image")
|
||||
assert r.status_code == 400
|
||||
assert "重新导出" in r.json()["detail"]
|
||||
assert client.get("/api/playground/jobs").json() == []
|
||||
assert named_comfy.prompts == [] and named_comfy.upload_names == []
|
||||
|
||||
|
||||
def test_repaired_qwen_export_runs_with_its_input_image(client, named_comfy):
|
||||
wf = _load(QWEN_EDIT)
|
||||
wf["1"] = {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_2.1_int8_convrot.safetensors",
|
||||
"weight_dtype": "default",
|
||||
},
|
||||
}
|
||||
del wf["9"]
|
||||
wf["6"]["inputs"]["model"] = ["1", 0]
|
||||
with _real_run(client, named_comfy):
|
||||
named_comfy.out_name = "qwen_image_00001_.png"
|
||||
wf_id = _import_workflow(client, "Qwen 图片编辑(修复版)", "image", wf)
|
||||
img = _upload(client, "门面.png")
|
||||
r = _generate(
|
||||
client, wf_id, {"4@prompt": "换招牌", "10": img, "4@resolution": 1536, "6@steps": 20},
|
||||
source="studio-image",
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done" and job["output_paths"]
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
assert prompt["10"]["inputs"]["image"] == f"calliope/{Path(img).name}"
|
||||
assert prompt["4"]["inputs"]["images.image_1"] == ["10", 0]
|
||||
assert prompt["4"]["inputs"]["resolution"] == 1536
|
||||
assert prompt["6"]["inputs"]["steps"] == 20
|
||||
|
||||
|
||||
def test_worker_refuses_broken_workflow_from_other_callers(client, named_comfy):
|
||||
"""Workbench/agent jobs skip the playground check — the worker still stops them."""
|
||||
from calliope.db import get_db
|
||||
from calliope.queue.manager import queue_manager
|
||||
from calliope.routers.playground import ensure_playground_project
|
||||
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "Qwen 图片编辑", "image", _load(QWEN_EDIT))
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
job = queue_manager.enqueue(
|
||||
project_id=pid, kind="image", workflow_id=wf_id, scene_id=None,
|
||||
payload={"input_values": {"4@prompt": "x"}, "source": "playground"},
|
||||
)
|
||||
done = _wait_done(client, job["id"])
|
||||
assert done["status"] == "failed"
|
||||
assert "重新导出" in (done["error"] or "")
|
||||
assert named_comfy.prompts == []
|
||||
@@ -216,6 +216,18 @@ def test_settings_accepts_agent_llm_assignments(client):
|
||||
# Unknown role keys are dropped, known roles stored (incl. explicit None)
|
||||
assert body["agent_llm_assignments"] == {"video": a_id, "story": None}
|
||||
|
||||
# HEL-619: updates are per-role incremental — a partial payload must
|
||||
# not wipe roles it does not mention (rapid-switch race).
|
||||
r = client.post(
|
||||
"/api/settings",
|
||||
json={"agent_llm_assignments": {"main": a_id}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["agent_llm_assignments"]["main"] == a_id
|
||||
assert body["agent_llm_assignments"]["video"] == a_id
|
||||
assert body["agent_llm_assignments"]["story"] is None
|
||||
|
||||
r = client.post(
|
||||
"/api/settings",
|
||||
json={"agent_llm_assignments": {"video": "no-such-profile"}},
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""媒体组泛化(HEL-643 批次 3):参考视频/参考音频按 JSON 实际连线进入
|
||||
input_schema 与提交流程;无连线则无入口;05_ref2va 只保留参考图。
|
||||
|
||||
视频/音频参考的真实 H3 JSON 暂无可执行样本(.15 无 ComfyUI),本套用
|
||||
自造 fixture + fake ComfyUI 验证解析/表单/回填/上传全链路(模拟链路)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import PNG_1PX, _real_run
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uploads_dir(client):
|
||||
d = settings.assets_dir / "uploads"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _load_05() -> dict:
|
||||
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _ref2va_workflow(*, images=1, videos=1, audios=1) -> dict:
|
||||
"""H3 Ref2VA 形状的自造 JSON:图/视频/音频各一组 Autogrow 连线。"""
|
||||
wf: dict = {
|
||||
"1": {
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"inputs": {"value": "提示词"},
|
||||
},
|
||||
"92": {
|
||||
"class_type": "SaveVideo",
|
||||
"inputs": {"video": ["131", 0]},
|
||||
},
|
||||
"131": {
|
||||
"class_type": "MiniMaxH3ReferenceToVideo",
|
||||
"inputs": {"prompt": ["1", 0]},
|
||||
},
|
||||
"901": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
|
||||
"902": {"class_type": "LoadVideo", "inputs": {"file": "ref.mp4", "force_rate": 0}},
|
||||
"903": {"class_type": "LoadAudio", "inputs": {"audio": "ref.wav"}},
|
||||
}
|
||||
ins = wf["131"]["inputs"]
|
||||
for i in range(images):
|
||||
ins[f"ref_images.ref_image_{i}"] = ["901", 0]
|
||||
for i in range(videos):
|
||||
ins[f"ref_videos.ref_video_{i}"] = ["902", 0]
|
||||
for i in range(audios):
|
||||
ins[f"ref_audios.ref_audio_{i}"] = ["903", 0]
|
||||
return wf
|
||||
|
||||
|
||||
def _entry(schema: list[dict], key_suffix: str) -> dict | None:
|
||||
for e in schema:
|
||||
if e["nodeId"].endswith("@" + key_suffix):
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema: 入口只由实际连线决定
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ref2va_schema_exposes_three_media_groups():
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow())
|
||||
img = _entry(schema, "ref_images")
|
||||
vid = _entry(schema, "ref_videos")
|
||||
aud = _entry(schema, "ref_audios")
|
||||
assert img and img["kind"] == "image_list" and img["role"] == "reference_image"
|
||||
assert img["max"] == 9 and img["label"] == "参考图"
|
||||
assert vid and vid["kind"] == "video_list" and vid["role"] == "reference_video"
|
||||
assert vid["max"] == 3 and vid["label"] == "参考视频"
|
||||
assert aud and aud["kind"] == "audio_list" and aud["role"] == "reference_audio"
|
||||
assert aud["max"] == 3 and aud["label"] == "参考音频"
|
||||
# 官方限额提示按节点类名驱动(通用检测不携带 hint)
|
||||
assert img["media_hint"] == vid["media_hint"] == aud["media_hint"] == "minimax_h3_ref2va"
|
||||
# 被组吸收的 loader 不再单独出现
|
||||
assert not any(e["nodeId"] == "902" for e in schema)
|
||||
assert not any(e["nodeId"] == "903" for e in schema)
|
||||
|
||||
|
||||
def test_unwired_media_kinds_produce_no_entry():
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow(videos=0, audios=0))
|
||||
assert _entry(schema, "ref_images") is not None
|
||||
assert _entry(schema, "ref_videos") is None
|
||||
assert _entry(schema, "ref_audios") is None
|
||||
|
||||
|
||||
def test_generic_class_gets_group_without_hint():
|
||||
wf = _ref2va_workflow(videos=0, audios=0)
|
||||
wf["20"] = {"class_type": "GenericRefNode", "inputs": {}}
|
||||
wf["701"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "a.mp4"}}
|
||||
wf["702"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "b.mp4"}}
|
||||
wf["20"]["inputs"]["media.clip_0"] = ["701", 0]
|
||||
wf["20"]["inputs"]["media.clip_1"] = ["702", 0]
|
||||
schema = parse_dynamic_inputs(wf)
|
||||
g = _entry(schema, "media")
|
||||
assert g and g["kind"] == "video_list" and g["max"] == 9
|
||||
assert "media_hint" not in g
|
||||
|
||||
|
||||
def test_05_ref2va_fixture_stays_image_only():
|
||||
schema = parse_dynamic_inputs(_load_05())
|
||||
lists = [e for e in schema if e["kind"].endswith("_list")]
|
||||
assert len(lists) == 1
|
||||
assert lists[0]["kind"] == "image_list" and lists[0]["max"] == 9
|
||||
assert lists[0]["nodeId"] == "131@ref_images"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _media_file(path: str, data: bytes) -> str:
|
||||
p = Path(path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(data)
|
||||
return str(p)
|
||||
|
||||
|
||||
def test_validate_video_and_audio_lists(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow())
|
||||
img_key = _entry(schema, "ref_images")["nodeId"]
|
||||
vid_key = _entry(schema, "ref_videos")["nodeId"]
|
||||
aud_key = _entry(schema, "ref_audios")["nodeId"]
|
||||
png = _media_file(str(uploads_dir / "ok.png"), PNG_1PX)
|
||||
mp4 = _media_file(str(uploads_dir / "ok.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "ok.mp3"), b"00")
|
||||
|
||||
clean, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == [] and clean[vid_key] == [mp4] and clean[aud_key] == [mp3]
|
||||
|
||||
# 错误格式(图片文件填视频槽 / 视频文件填音频槽)
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [png], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["参考视频 1:格式不支持,请使用 MP4 / WebM / MOV"]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp4]}
|
||||
)
|
||||
assert errs == ["参考音频 1:格式不支持,请使用 MP3 / WAV / M4A"]
|
||||
|
||||
# 上限(H3 官方 3 段)与最少 1 段
|
||||
four = [_media_file(str(uploads_dir / f"v{i}.mp4"), b"00") for i in range(4)]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: four, aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["参考视频最多 3 段(当前 4 段),请删除多余的视频"]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["需要至少 1 段参考视频"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patcher + full chain against fake ComfyUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patcher_clones_and_backfills_each_media_kind(uploads_dir):
|
||||
wf = _ref2va_workflow()
|
||||
schema = parse_dynamic_inputs(wf)
|
||||
pngs = [_media_file(str(uploads_dir / "r1.png"), PNG_1PX),
|
||||
_media_file(str(uploads_dir / "r2.png"), PNG_1PX)]
|
||||
mp4 = _media_file(str(uploads_dir / "v1.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "a1.mp3"), b"00")
|
||||
patched = patch_workflow(
|
||||
wf,
|
||||
{
|
||||
_entry(schema, "ref_images")["nodeId"]: pngs,
|
||||
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
||||
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
||||
},
|
||||
)
|
||||
ins = patched["131"]["inputs"]
|
||||
image_links = [v for k, v in ins.items() if k.startswith("ref_images.")]
|
||||
video_links = [v for k, v in ins.items() if k.startswith("ref_videos.")]
|
||||
audio_links = [v for k, v in ins.items() if k.startswith("ref_audios.")]
|
||||
assert len(image_links) == 2 and len(video_links) == 1 and len(audio_links) == 1
|
||||
loaders = {nid: n for nid, n in patched.items()
|
||||
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")}
|
||||
imgs = [n for n in loaders.values() if n["class_type"] == "LoadImage"]
|
||||
vids = [n for n in loaders.values() if n["class_type"] == "LoadVideo"]
|
||||
auds = [n for n in loaders.values() if n["class_type"] == "LoadAudio"]
|
||||
assert len(imgs) == 2 and all(n["inputs"]["image"] in pngs for n in imgs)
|
||||
assert vids and vids[0]["inputs"]["file"] == mp4
|
||||
assert auds and auds[0]["inputs"]["audio"] == mp3
|
||||
# 克隆出的节点带中文槽位标题
|
||||
assert {n["_meta"]["title"] for n in imgs} == {"参考图 1", "参考图 2"}
|
||||
assert vids[0]["_meta"]["title"] == "参考视频 1"
|
||||
assert auds[0]["_meta"]["title"] == "参考音频 1"
|
||||
|
||||
|
||||
def test_full_chain_uploads_and_backfills_media_groups(client, fake_comfy, uploads_dir):
|
||||
"""提交 → 上传 → 回填:fake ComfyUI 收到的 prompt 里每类 loader 都
|
||||
指向已上传文件(模拟链路:fake 端点,非真机 H3)。"""
|
||||
wf = _ref2va_workflow()
|
||||
png = _media_file(str(uploads_dir / "ref.png"), PNG_1PX)
|
||||
mp4 = _media_file(str(uploads_dir / "ref.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "ref.mp3"), b"00")
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "媒体组链路", "workflow_json": wf, "usage": "ref2video"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf_id = r.json()["id"]
|
||||
schema = r.json()["input_schema"]
|
||||
|
||||
with _real_run(client, fake_comfy):
|
||||
fake_comfy.out_name = "media_out.mp4"
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test",
|
||||
json={
|
||||
"input_values": {
|
||||
_entry(schema, "ref_images")["nodeId"]: [png],
|
||||
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
||||
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job_id = r.json()["job"]["id"]
|
||||
|
||||
deadline = time.time() + 25
|
||||
job = {}
|
||||
while time.time() < deadline:
|
||||
job = client.get(f"/api/jobs/{job_id}").json()
|
||||
if job["status"] not in ("pending", "running"):
|
||||
break
|
||||
time.sleep(0.4)
|
||||
assert job["status"] == "done", job.get("error")
|
||||
|
||||
assert len(fake_comfy.uploads) >= 3 # 图、视频、音频各一次
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
loaders = [n for n in queued.values()
|
||||
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")]
|
||||
by_field = {
|
||||
(n["class_type"], k): v
|
||||
for n in loaders
|
||||
for k, v in n["inputs"].items()
|
||||
if isinstance(v, str) and "uploaded-input" in v
|
||||
}
|
||||
assert ("LoadImage", "image") in by_field
|
||||
assert ("LoadVideo", "file") in by_field
|
||||
assert ("LoadAudio", "audio") in by_field
|
||||
# 组连线数与提交数一致
|
||||
ins = queued["131"]["inputs"]
|
||||
assert len([k for k in ins if k.startswith("ref_images.")]) == 1
|
||||
assert len([k for k in ins if k.startswith("ref_videos.")]) == 1
|
||||
assert len([k for k in ins if k.startswith("ref_audios.")]) == 1
|
||||
@@ -0,0 +1,350 @@
|
||||
"""HEL-684 — playground takes flow back to their workbench source row.
|
||||
|
||||
Covers the acceptance list: real-context-only passthrough (no guessing,
|
||||
stale rows rejected), explicit flow-back with idempotency (repeated
|
||||
operations create no duplicates), cancelled/independent paths never
|
||||
writing the project, and playground job deletion clearing the link.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from calliope.db import migrate_db
|
||||
from calliope.main import create_app
|
||||
|
||||
SAMPLE_WORKFLOW = {
|
||||
"1": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "hello"},
|
||||
"_meta": {"title": "Prompt (Input:prompt)"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"images": ["1", 0]},
|
||||
"_meta": {"title": "Save (Output:image)"},
|
||||
},
|
||||
}
|
||||
|
||||
SAMPLE_VIDEO_WORKFLOW = {
|
||||
"1": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "hello"},
|
||||
"_meta": {"title": "Prompt (Input:prompt)"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "SaveVideo",
|
||||
"inputs": {"video": ["1", 0]},
|
||||
"_meta": {"title": "Save (Output:video)"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
import asyncio
|
||||
|
||||
import calliope.config as config_module
|
||||
|
||||
s = config_module.settings
|
||||
prev = {
|
||||
"data_dir": s.data_dir,
|
||||
"assets_dir": s.assets_dir,
|
||||
"dry_run": s.dry_run,
|
||||
}
|
||||
s.data_dir = Path(tmpdir)
|
||||
s.assets_dir = Path(tmpdir) / "assets"
|
||||
s.assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
s.dry_run = True
|
||||
monkeypatch.setattr(config_module.Settings, "save_config_file", lambda self: None)
|
||||
asyncio.run(migrate_db(s.db_path))
|
||||
app = create_app()
|
||||
try:
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
finally:
|
||||
for k, v in prev.items():
|
||||
setattr(s, k, v)
|
||||
|
||||
|
||||
def _wait_done(client: TestClient, job_id: int) -> dict:
|
||||
for _ in range(60):
|
||||
jobs = client.get("/api/playground/jobs").json()
|
||||
done = next((j for j in jobs if j["id"] == job_id and j["status"] == "done"), None)
|
||||
if done and done.get("output_paths"):
|
||||
return done
|
||||
time.sleep(0.25)
|
||||
raise AssertionError("playground job did not finish in dry-run")
|
||||
|
||||
|
||||
def _seed_assets_stage(client: TestClient) -> dict:
|
||||
proj = client.post("/api/projects", json={"title": "Reel"}).json()
|
||||
# Real stage rows the way the workbench itself creates them.
|
||||
patch = client.patch(
|
||||
f"/api/workbench/projects/{proj['id']}/stages/assets",
|
||||
json={
|
||||
"content": {
|
||||
"assets": [
|
||||
{
|
||||
"id": "asset-1",
|
||||
"kind": "character",
|
||||
"name": "林夏",
|
||||
"description": "",
|
||||
"prompt": "a stoic courier",
|
||||
"negative_prompt": "",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
assert patch.status_code == 200, patch.text
|
||||
return proj
|
||||
|
||||
|
||||
def _context(proj: dict, **over: object) -> dict:
|
||||
base = {
|
||||
"project_id": proj["id"],
|
||||
"stage": "assets",
|
||||
"source_kind": "asset",
|
||||
"source_id": "asset-1",
|
||||
"label": "角色「林夏」",
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def _make_workflow(client: TestClient, name: str, workflow: dict) -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": name,
|
||||
"kind": "video" if "SaveVideo" in str(workflow) else "image",
|
||||
"workflow_json": workflow,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
# ── context passthrough ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_generate_with_context_stamps_job_and_stays_on_playground(client):
|
||||
wf_id = _make_workflow(client, "FB Img", SAMPLE_WORKFLOW)
|
||||
proj = _seed_assets_stage(client)
|
||||
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": {"1": "a cat"},
|
||||
"source": "studio-image",
|
||||
"context": _context(proj),
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()["job"]
|
||||
assert job["payload"]["workbench"]["project_id"] == proj["id"]
|
||||
assert job["payload"]["workbench"]["source_id"] == "asset-1"
|
||||
assert job["payload"]["workbench"]["label"] == "角色「林夏」"
|
||||
|
||||
# The job itself rides the hidden playground project — independent
|
||||
# desk behavior (feed, SSE, tasks) is untouched.
|
||||
scratch = client.get("/api/playground/project").json()
|
||||
assert job["project_id"] == scratch["id"]
|
||||
|
||||
|
||||
def test_generate_without_context_writes_nothing_to_any_project(client):
|
||||
wf_id = _make_workflow(client, "FB Solo", SAMPLE_WORKFLOW)
|
||||
_seed_assets_stage(client)
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "workbench" not in r.json()["job"]["payload"]
|
||||
|
||||
|
||||
def test_generate_rejects_fake_or_stale_context(client):
|
||||
wf_id = _make_workflow(client, "FB Fake", SAMPLE_WORKFLOW)
|
||||
proj = _seed_assets_stage(client)
|
||||
|
||||
# Unknown project
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "context": _context(proj, project_id=99999)},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
# Source row that does not exist in the stage content
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "context": _context(proj, source_id="asset-404")},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
# Kind mismatch: assets stage wants an image workflow
|
||||
video_wf = _make_workflow(client, "FB Vid", SAMPLE_VIDEO_WORKFLOW)
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": video_wf, "context": _context(proj)},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
# The playground scratch project is not a valid source
|
||||
scratch = client.get("/api/playground/project").json()
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "context": _context(proj, project_id=scratch["id"])},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ── flow back ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_attach_workbench_links_take_to_source_row(client):
|
||||
wf_id = _make_workflow(client, "FB Link", SAMPLE_WORKFLOW)
|
||||
proj = _seed_assets_stage(client)
|
||||
job = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
|
||||
).json()["job"]
|
||||
|
||||
# Not done yet → refused, nothing written.
|
||||
early = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
||||
assert early.status_code == 400
|
||||
|
||||
done = _wait_done(client, job["id"])
|
||||
|
||||
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["already_attached"] is False
|
||||
assert body["project_id"] == proj["id"]
|
||||
assert body["target_id"] == "asset-1"
|
||||
|
||||
# The workbench overview now shows the artifact on the source row,
|
||||
# through the exact enrichment path in-flow generation uses.
|
||||
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
|
||||
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
|
||||
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
|
||||
assert row["job"]["id"] == body["attached_job_id"]
|
||||
assert row["job"]["status"] == "done"
|
||||
assert row["job"]["output"] == done["output_paths"][0]
|
||||
|
||||
# Repeated flow-back: idempotent, no duplicate row.
|
||||
again = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
||||
assert again.status_code == 200
|
||||
assert again.json()["already_attached"] is True
|
||||
assert again.json()["attached_job_id"] == body["attached_job_id"]
|
||||
|
||||
# The desk jobs list reports the honest 已回流 marker.
|
||||
jobs = client.get("/api/playground/jobs").json()
|
||||
listed = next(j for j in jobs if j["id"] == job["id"])
|
||||
assert listed["workbench_attach"]["job_id"] == body["attached_job_id"]
|
||||
|
||||
|
||||
def test_attach_workbench_requires_real_context(client):
|
||||
wf_id = _make_workflow(client, "FB NoCtx", SAMPLE_WORKFLOW)
|
||||
job = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "solo"}},
|
||||
).json()["job"]
|
||||
_wait_done(client, job["id"])
|
||||
|
||||
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
||||
assert r.status_code == 400
|
||||
assert "独立" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_attach_workbench_refuses_regenerated_source(client):
|
||||
wf_id = _make_workflow(client, "FB Stale", SAMPLE_WORKFLOW)
|
||||
proj = _seed_assets_stage(client)
|
||||
job = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
|
||||
).json()["job"]
|
||||
_wait_done(client, job["id"])
|
||||
|
||||
# Stage content regenerated: the old row id is gone.
|
||||
client.patch(
|
||||
f"/api/workbench/projects/{proj['id']}/stages/assets",
|
||||
json={
|
||||
"content": {
|
||||
"assets": [
|
||||
{
|
||||
"id": "asset-2",
|
||||
"kind": "prop",
|
||||
"name": "Blaster",
|
||||
"description": "",
|
||||
"prompt": "a blaster",
|
||||
"negative_prompt": "",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
r = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]})
|
||||
assert r.status_code == 409
|
||||
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
|
||||
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
|
||||
assert all("job" not in a for a in assets_stage["content"]["assets"])
|
||||
|
||||
|
||||
def test_attach_workbench_allows_new_flow_job_to_take_over(client):
|
||||
"""A later in-flow generation for the same row wins the display, as
|
||||
the enrichment keeps the newest workbench job per target."""
|
||||
wf_id = _make_workflow(client, "FB Takeover", SAMPLE_WORKFLOW)
|
||||
proj = _seed_assets_stage(client)
|
||||
job = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
|
||||
).json()["job"]
|
||||
_wait_done(client, job["id"])
|
||||
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
|
||||
|
||||
flow = client.post(
|
||||
f"/api/workbench/projects/{proj['id']}/generate",
|
||||
json={
|
||||
"kind": "image",
|
||||
"stage_key": "assets",
|
||||
"target_id": "asset-1",
|
||||
"prompt": "in-flow take",
|
||||
},
|
||||
)
|
||||
assert flow.status_code == 200, flow.text
|
||||
|
||||
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
|
||||
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
|
||||
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
|
||||
assert row["job"]["id"] != link["attached_job_id"]
|
||||
|
||||
|
||||
def test_delete_playground_job_removes_workbench_link(client):
|
||||
wf_id = _make_workflow(client, "FB Del", SAMPLE_WORKFLOW)
|
||||
proj = _seed_assets_stage(client)
|
||||
job = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"1": "a cat"}, "context": _context(proj)},
|
||||
).json()["job"]
|
||||
done = _wait_done(client, job["id"])
|
||||
link = client.post("/api/playground/attach-workbench", json={"job_id": job["id"]}).json()
|
||||
|
||||
dead = client.delete(f"/api/playground/jobs/{job['id']}")
|
||||
assert dead.status_code == 200
|
||||
assert dead.json()["removed_workbench_links"] == [link["attached_job_id"]]
|
||||
|
||||
# The link row is gone: the project row no longer points at deleted files.
|
||||
overview = client.get(f"/api/workbench/projects/{proj['id']}").json()
|
||||
assets_stage = next(s for s in overview["stages"] if s["stage_key"] == "assets")
|
||||
row = next(a for a in assets_stage["content"]["assets"] if a["id"] == "asset-1")
|
||||
assert "job" not in row or row["job"]["id"] != link["attached_job_id"]
|
||||
assert not Path(done["output_paths"][0]).exists()
|
||||
@@ -0,0 +1,183 @@
|
||||
"""B4 draft persistence, concurrency, scope, confirmation and migration regression."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from calliope.agent.deliverables import complete_task
|
||||
from calliope.agent.harness.runner import runner
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, migrate_db
|
||||
|
||||
TEXT = (
|
||||
"第一场,清晨海岸,女孩将纸船推向海面,等待父亲归来。"
|
||||
"第二场,纸船带回树叶,女孩沿着灯塔的光继续前行。"
|
||||
)
|
||||
|
||||
|
||||
def setup(client, stage="script"):
|
||||
project = client.post("/api/projects", json={"title": "B4 isolated"}).json()
|
||||
session = client.post(
|
||||
"/api/agent/sessions",
|
||||
json={
|
||||
"project_id": project["id"],
|
||||
"agent_key": stage,
|
||||
},
|
||||
).json()
|
||||
runner._persist_message(session["id"], role="assistant", content=TEXT)
|
||||
complete_task(session["id"], TEXT)
|
||||
draft = client.get("/api/results/deliverables", params={"session_id": session["id"]}).json()[0]
|
||||
return project["id"], session["id"], draft
|
||||
|
||||
|
||||
def store(client, pid, draft, stage="script"):
|
||||
return client.post(
|
||||
"/api/results/store",
|
||||
json={
|
||||
"project_id": pid,
|
||||
"stage_key": stage,
|
||||
"deliverable_id": draft["id"],
|
||||
"revision": draft["revision"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_draft_save_refresh_conflict_and_scope(client):
|
||||
pid, sid, draft = setup(client)
|
||||
body = {"revision": 1, "content": TEXT + "新结尾。", "project_id": pid, "stage_key": "script"}
|
||||
saved = client.patch(f"/api/results/deliverables/{draft['id']}", json=body)
|
||||
assert saved.status_code == 200
|
||||
assert saved.json()["revision"] == 2
|
||||
versions = client.get(f"/api/results/deliverables/{draft['id']}/versions").json()
|
||||
assert len(versions) == 1 and versions[0]["content"] == TEXT
|
||||
assert (
|
||||
client.get("/api/results/deliverables", params={"session_id": sid}).json()[0]
|
||||
== saved.json()
|
||||
)
|
||||
assert client.patch(f"/api/results/deliverables/{draft['id']}", json=body).status_code == 409
|
||||
assert store(client, pid, draft).status_code == 409
|
||||
other = client.post("/api/projects", json={"title": "Other"}).json()["id"]
|
||||
assert store(client, other, saved.json()).status_code == 409
|
||||
body.update(revision=2, project_id=other)
|
||||
assert client.patch(f"/api/results/deliverables/{draft['id']}", json=body).status_code == 409
|
||||
assert client.get(f"/api/agent/sessions/{sid}").json()["task_status"] == "awaiting_confirm"
|
||||
|
||||
|
||||
def test_store_idempotency_confirm_restore_and_preserve_snapshot(client):
|
||||
pid, sid, draft = setup(client)
|
||||
url = f"/api/workbench/projects/{pid}/stages/script"
|
||||
old = {"text": TEXT + "已确认的旧版。"}
|
||||
assert client.patch(url, json={"content": old}).status_code == 200
|
||||
snapshot = client.post(url + "/confirm", json={}).json()["stage"]["confirmed_content"]
|
||||
res = store(client, pid, draft)
|
||||
assert res.status_code == 200, res.text
|
||||
stage = res.json()["stage"]
|
||||
assert stage["status"] == "awaiting_confirm"
|
||||
assert stage["confirmed_content"] == snapshot
|
||||
assert stage["content"]["text"] == TEXT
|
||||
assert len(stage["incoming_results"]) == 1
|
||||
assert store(client, pid, draft).json()["already_attached"] is True
|
||||
assert len(store(client, pid, draft).json()["stage"]["incoming_results"]) == 1
|
||||
assert client.get(f"/api/agent/sessions/{sid}").json()["task_status"] == "returned"
|
||||
assert client.post(url + "/restore", json={}).json()["stage"]["content"] == snapshot
|
||||
# A newer source revision is a new explicit handoff, never an automatic update.
|
||||
draft = client.patch(
|
||||
f"/api/results/deliverables/{draft['id']}",
|
||||
json={
|
||||
"revision": 1,
|
||||
"content": TEXT + "新版本。",
|
||||
"project_id": pid,
|
||||
"stage_key": "script",
|
||||
},
|
||||
).json()
|
||||
assert store(client, pid, draft).status_code == 200
|
||||
confirmed = client.post(url + "/confirm", json={}).json()["stage"]["confirmed_content"]
|
||||
assert confirmed["text"].endswith("新版本。")
|
||||
assert len(confirmed["incoming_results"]) == 1
|
||||
stages = client.get(f"/api/workbench/projects/{pid}").json()["stages"]
|
||||
assert all(s["status"] == "todo" for s in stages if s["stage_key"] != "script")
|
||||
|
||||
|
||||
def test_capture_idempotency_and_no_error_artifact(client):
|
||||
_, sid, _ = setup(client)
|
||||
complete_task(sid, TEXT)
|
||||
assert len(client.get("/api/results/deliverables", params={"session_id": sid}).json()) == 1
|
||||
runner._persist_message(sid, role="assistant", content="error", status="error")
|
||||
complete_task(sid, "error")
|
||||
assert len(client.get("/api/results/deliverables", params={"session_id": sid}).json()) == 1
|
||||
|
||||
|
||||
def test_sandbox_assignment_and_running_stage(client):
|
||||
pid, _, draft = setup(client)
|
||||
conn = get_db(settings.db_path)
|
||||
conn.execute("UPDATE project_stages SET status='running' WHERE project_id=?", (pid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
# Stage rows are created lazily; ensure they exist first.
|
||||
client.get(f"/api/workbench/projects/{pid}")
|
||||
conn = get_db(settings.db_path)
|
||||
conn.execute("UPDATE project_stages SET status='running' WHERE project_id=?", (pid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
assert store(client, pid, draft).status_code == 409
|
||||
assert client.get("/api/results/deliverables", params={"session_id": 99999}).json() == []
|
||||
|
||||
|
||||
def test_media_paths_scope_and_empty_errors(client):
|
||||
pid, _, _ = setup(client)
|
||||
other = client.post("/api/projects", json={"title": "other"}).json()["id"]
|
||||
path = settings.assets_dir / "b4.txt"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("isolated")
|
||||
conn = get_db(settings.db_path)
|
||||
job = conn.execute(
|
||||
"INSERT INTO jobs(project_id,kind,status,output_paths_json) VALUES (?,'image','done',?)",
|
||||
(pid, json.dumps([str(path)])),
|
||||
).lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
body = {"job_id": job, "project_id": other, "stage_key": "assets"}
|
||||
assert client.post("/api/results/store", json=body).status_code == 409
|
||||
body["project_id"] = pid
|
||||
result = client.post("/api/results/store", json=body)
|
||||
assert result.status_code == 200, result.text
|
||||
assert result.json()["stage"]["incoming_results"][0]["paths"] == [str(path)]
|
||||
assert client.post("/api/results/store", json=body).json()["already_attached"]
|
||||
assert (
|
||||
client.post(
|
||||
"/api/results/store", json={"project_id": pid, "stage_key": "bogus", "job_id": job}
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/results/store", json={"project_id": pid, "stage_key": "assets"}
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
path.unlink()
|
||||
assert client.post("/api/results/store", json=body).status_code == 404
|
||||
|
||||
|
||||
def test_migration_twice_and_sqlite_backup_rollback(client, tmp_path):
|
||||
pid, sid, draft = setup(client)
|
||||
conn = sqlite3.connect(settings.db_path)
|
||||
backup = sqlite3.connect(tmp_path / "before-b4.db")
|
||||
conn.backup(backup)
|
||||
backup.close()
|
||||
conn.close()
|
||||
asyncio.run(migrate_db(settings.db_path))
|
||||
asyncio.run(migrate_db(settings.db_path))
|
||||
assert client.get("/api/results/deliverables").json()[0]["content"] == draft["content"]
|
||||
conn = get_db(settings.db_path)
|
||||
# Old-code SQL continues reading the original schema and rows.
|
||||
assert (
|
||||
conn.execute(
|
||||
"SELECT title,status,project_id FROM agent_sessions WHERE id=?", (sid,)
|
||||
).fetchone()["project_id"]
|
||||
== pid
|
||||
)
|
||||
assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
|
||||
conn.close()
|
||||
@@ -13,6 +13,7 @@ The shell is default-OFF; every guard branch here is a security contract:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -25,6 +26,23 @@ from calliope.agent.harness.registry import ToolContext
|
||||
PYTHON = Path(sys.executable).stem # bare name ("python") — the tool refuses paths
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def _interpreter_on_path():
|
||||
"""Make the bare interpreter name resolvable via PATH (HEL-619).
|
||||
|
||||
The guard resolves the command with ``shutil.which`` against PATH —
|
||||
running pytest as ``path/to/.venv/bin/python -m pytest`` (or on a box
|
||||
with no ``python`` alias) leaves the stem unresolvable and every
|
||||
exec-path test fails for environmental reasons only. Prepending the
|
||||
interpreter's own bin dir (restored afterwards) pins the lookup to the
|
||||
interpreter actually running the suite, on every OS.
|
||||
"""
|
||||
prev = os.environ.get("PATH", "")
|
||||
os.environ["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{prev}"
|
||||
yield
|
||||
os.environ["PATH"] = prev
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def _never_touch_real_db():
|
||||
"""Redirect data_dir before ANY write in this module (repo test rule)."""
|
||||
@@ -210,12 +228,19 @@ def test_structured_shell_approval_allows(enabled):
|
||||
|
||||
|
||||
def test_cwd_outside_workspace_denied(enabled):
|
||||
"""Guard denial tests use PYTHON + a structured approval card (not
|
||||
ffprobe): the interpreter resolves on every machine, and the card
|
||||
clears the approval gate so the LATER containment check is what fires
|
||||
(HEL-619 — ffprobe-absent boxes used to fail these for environmental
|
||||
reasons)."""
|
||||
registry, _ = build_harness()
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "cwd": str(Path(settings_data_dir_home()))},
|
||||
{"command": PYTHON, "cwd": str(settings_data_dir_home())},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_outside_workspace"
|
||||
@@ -230,11 +255,13 @@ def test_sensitive_arg_denied(enabled, tmp_path):
|
||||
registry, _ = build_harness()
|
||||
secret = tmp_path / "calliope_config.json"
|
||||
secret.write_text("{}", encoding="utf-8")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": [str(secret)]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", str(secret)]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_denied_path"
|
||||
@@ -244,11 +271,13 @@ def test_db_arg_denied(enabled, tmp_path):
|
||||
registry, _ = build_harness()
|
||||
db = tmp_path / "calliope.db"
|
||||
db.write_bytes(b"")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": [str(db)]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", str(db)]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_denied_path"
|
||||
@@ -256,11 +285,13 @@ def test_db_arg_denied(enabled, tmp_path):
|
||||
|
||||
def test_env_file_arg_denied(enabled):
|
||||
registry, _ = build_harness()
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": ["--", ".env"]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", "--", ".env"]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_denied_path"
|
||||
@@ -270,11 +301,13 @@ def test_outside_absolute_arg_denied(enabled, tmp_path):
|
||||
registry, _ = build_harness()
|
||||
outside = tmp_path / "movie.mp4"
|
||||
outside.write_bytes(b"00")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": [str(outside)]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", str(outside)]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_outside_workspace"
|
||||
@@ -287,16 +320,22 @@ def test_inside_asset_arg_allowed(enabled):
|
||||
registry, _ = build_harness()
|
||||
ref = settings.assets_dir / "ref.mp4"
|
||||
ref.write_bytes(b"00")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": ["-v", "error", str(ref)]},
|
||||
{
|
||||
# The asset path is a real argv entry the guard must allow
|
||||
# (and the interpreter actually reads it).
|
||||
"command": PYTHON,
|
||||
"args": ["-c", "import sys; print(open(sys.argv[1]).read())", str(ref)],
|
||||
},
|
||||
)
|
||||
)
|
||||
# Not a real mp4 — ffprobe fails, but the GUARD must not have denied it.
|
||||
assert out.get("reason_code") is None
|
||||
assert out.get("exit_code") != 0 or out.get("ok") is False or out["ok"] is True
|
||||
assert out["ok"] is True, out
|
||||
assert "00" in out["stdout"]
|
||||
|
||||
|
||||
def test_workspace_relative_cwd_allowed(enabled, tmp_path):
|
||||
|
||||
@@ -25,9 +25,19 @@ PNG_1PX = bytes.fromhex(
|
||||
|
||||
|
||||
class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
"""Minimal ComfyUI: /system_stats, /prompt, /history/{id}, /view, /upload/image."""
|
||||
"""Minimal ComfyUI: /system_stats, /prompt, /history/{id}, /view, /upload/image.
|
||||
|
||||
History outputs are keyed by the prompt's real output node id (what
|
||||
ComfyUI does); a known output class that is absent falls back to
|
||||
``server.forced_output_node`` (default "9"). Scripted tests can pin
|
||||
``server.forced_output_node`` or drop entries via ``server.drop_outputs``.
|
||||
"""
|
||||
|
||||
server_version = "FakeComfy/1.0"
|
||||
KNOWN_OUTPUT_CLASSES = {
|
||||
"SaveImage", "PreviewImage", "SaveImageAdvanced", "SaveImageWebsocket",
|
||||
"SaveVideo", "VideoOutput", "VHS_VideoCombine", "AnimateDiffCombine",
|
||||
}
|
||||
|
||||
def do_GET(self):
|
||||
self.server.requests.append(self.path)
|
||||
@@ -40,6 +50,9 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
},
|
||||
)
|
||||
return
|
||||
if self.path == "/object_info":
|
||||
self._json(200, dict(getattr(self.server, "object_info", None) or {}))
|
||||
return
|
||||
if self.path.startswith("/history/"):
|
||||
pid = self.path[len("/history/") :]
|
||||
entry = self.server.history.get(pid)
|
||||
@@ -64,11 +77,17 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
prompt_id = uuid.uuid4().hex
|
||||
outputs_key = self.server.outputs_key
|
||||
out_entry = {
|
||||
outputs_key: [{"filename": self.server.out_name, "subfolder": "", "type": "output"}]
|
||||
outputs_key: [
|
||||
{"filename": self.server.out_name, "subfolder": "", "type": "output"}
|
||||
]
|
||||
}
|
||||
outputs = (
|
||||
{} if getattr(self.server, "drop_outputs", False) else
|
||||
{self._output_node(body): out_entry}
|
||||
)
|
||||
self.server.history[prompt_id] = {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": {"9": out_entry},
|
||||
"outputs": outputs,
|
||||
"prompt": [],
|
||||
}
|
||||
self.server.prompts.append(json.loads(body.decode("utf-8") or "{}"))
|
||||
@@ -80,6 +99,19 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def _output_node(self, body: bytes) -> str:
|
||||
forced = getattr(self.server, "forced_output_node", None)
|
||||
if forced:
|
||||
return forced
|
||||
try:
|
||||
prompt = json.loads(body.decode("utf-8") or "{}").get("prompt") or {}
|
||||
except json.JSONDecodeError:
|
||||
prompt = {}
|
||||
for nid, node in prompt.items():
|
||||
if isinstance(node, dict) and node.get("class_type") in self.KNOWN_OUTPUT_CLASSES:
|
||||
return str(nid)
|
||||
return "9"
|
||||
|
||||
def _json(self, status: int, payload: dict) -> None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
@@ -109,6 +141,9 @@ def fake_comfy():
|
||||
server.fail_prompt = False
|
||||
server.out_name = "out-1.png"
|
||||
server.outputs_key = "images"
|
||||
server.forced_output_node = None
|
||||
server.object_info = {}
|
||||
server.drop_outputs = False
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
|
||||
@@ -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,371 @@
|
||||
"""HEL-638: unknown custom output nodes close the loop — no name-based
|
||||
auto-typing, feature-less sinks become mappable candidates, a usage choice
|
||||
alone never bypasses the output designation, and generation harvests (and
|
||||
checks) exactly the nodes the user mapped.
|
||||
|
||||
Two brand-new class names drive the cases:
|
||||
|
||||
* ``NebulaGlaxVideoVault`` — screams "video" in its name and carries a
|
||||
filename_prefix; before HEL-638 the fuzzy name match auto-typed it video
|
||||
and enabled the workflow.
|
||||
* ``QuarkMuffinDisplay`` — no save widget, no media link name; before
|
||||
HEL-638 such a graph only reported ``no_output`` with no way to map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, migrate_db
|
||||
from tests.test_studio_generation import _real_run, _wait_done
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _vault_workflow() -> dict:
|
||||
return {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "GlaxSamplerNode",
|
||||
"inputs": {"prompt": ["1", 0], "length": 121},
|
||||
},
|
||||
"12": {
|
||||
"class_type": "NebulaGlaxVideoVault",
|
||||
"inputs": {"filename_prefix": "anim", "frames": ["7", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _muffin_workflow() -> dict:
|
||||
return {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "GlaxSamplerNode",
|
||||
"inputs": {"prompt": ["1", 0], "length": 121},
|
||||
},
|
||||
"15": {"class_type": "QuarkMuffinDisplay", "inputs": {"payload": ["7", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def _analyze(client, wf: dict) -> dict:
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": wf})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Unknown outputs stay pending — names never decide (gap 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_video_semantic_unknown_node_is_never_auto_typed(client):
|
||||
body = _analyze(client, _vault_workflow())
|
||||
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "12")
|
||||
assert entry["evidence"] == "unknown_node"
|
||||
assert entry["kind"] is None # the user picks the media type
|
||||
assert entry["suggested_kind"] == "video" # only a form default
|
||||
assert body["needs_mapping"] is True
|
||||
assert body["usage"] is None and body["usage_ambiguous"] is True
|
||||
assert "指定实际输出节点" in body["usage_problem"]
|
||||
codes = {(w["level"], w["code"]) for w in body["warnings"]}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_choosing_a_usage_alone_does_not_bypass_output_mapping(client):
|
||||
"""create/PATCH with a usage but no output map keeps the row disabled."""
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "未知·视频语义", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video", "usage_source": "user"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
|
||||
assert wf["is_enabled"] is False # usage set, outputs still unconfirmed
|
||||
assert wf["needs_mapping"] is True and not wf["output_map"]
|
||||
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
|
||||
# forcing the switch on is refused with the mapping reason
|
||||
r = client.patch(f"/api/workflows/{wf['id']}", json={"is_enabled": True})
|
||||
assert r.status_code == 400
|
||||
assert "指定" in r.json()["detail"] and "输出节点" in r.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Feature-less unknown sinks become mappable candidates (gap 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_featureless_unknown_node_is_a_candidate_not_a_hard_error(client):
|
||||
body = _analyze(client, _muffin_workflow())
|
||||
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "15")
|
||||
assert entry["evidence"] == "unknown_node" and entry["kind"] is None
|
||||
assert body["needs_mapping"] is True
|
||||
codes = {(w["level"], w["code"]) for w in body["warnings"]}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_truly_outputless_graph_still_reports_the_chinese_error(client):
|
||||
wf = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "p"}},
|
||||
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
||||
}
|
||||
body = _analyze(client, wf)
|
||||
assert body["usage"] is None and body["needs_mapping"] is False
|
||||
assert "未在图中找到输出节点" in body["usage_problem"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Manual mapping → enable → scoped generation (fake ComfyUI round trip)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manual_mapping_enables_and_extracts_from_the_mapped_node(client, fake_comfy):
|
||||
fake_comfy.outputs_key = "gifs"
|
||||
fake_comfy.out_name = "clip.mp4"
|
||||
fake_comfy.forced_output_node = "12" # the mapped node reports results
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·视频库",
|
||||
"workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}],
|
||||
"output_map_source": "user",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["is_enabled"] is True
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user"
|
||||
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "星空延时"},
|
||||
"source": "studio-video"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["output_paths"][0].endswith(".mp4")
|
||||
|
||||
|
||||
def test_mapped_node_returning_nothing_fails_for_real(client, fake_comfy):
|
||||
"""Results that only land on an unmapped node are NOT a completion."""
|
||||
fake_comfy.forced_output_node = "7" # something else reports results
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·空手而归",
|
||||
"workflow_json": _muffin_workflow(),
|
||||
"usage": "text2image",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "15", "kind": "image"}],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["is_enabled"] is True
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": r.json()["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "没有返回任何图片或视频" in job["error"]
|
||||
assert "15" in job["error"] # names the node it waited on
|
||||
assert job["output_paths"] == []
|
||||
|
||||
|
||||
def test_media_type_mismatch_fails_for_real(client, fake_comfy):
|
||||
"""A video mapping that only yields a png is a real failure."""
|
||||
fake_comfy.outputs_key = "images"
|
||||
fake_comfy.out_name = "still.png"
|
||||
fake_comfy.forced_output_node = "12"
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·类型不符",
|
||||
"workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}],
|
||||
},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "媒体类型与映射不符" in job["error"]
|
||||
assert job["output_paths"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Bad / cancelled mappings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invalid_mappings_are_rejected_in_chinese(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "999", "kind": "video"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "不存在" in r.json()["detail"]
|
||||
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射2", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "audio"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "媒体类型" in r.json()["detail"]
|
||||
|
||||
# a prompt/parameter node can never be an output
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射3", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "1", "kind": "video"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "不能作为输出节点" in r.json()["detail"]
|
||||
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "先好后坏", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
).json()["id"]
|
||||
r = client.patch(
|
||||
f"/api/workflows/{wid}", json={"output_map": [{"nodeId": "42", "kind": "image"}]}
|
||||
)
|
||||
assert r.status_code == 422 and "不存在" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_clearing_the_mapping_resets_to_pending(client):
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "映射后清空", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
).json()["id"]
|
||||
wf = client.get(f"/api/workflows/{wid}").json()
|
||||
assert wf["is_enabled"] is True
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"output_map": []})
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["is_enabled"] is False and not wf["output_map"]
|
||||
assert wf["usage"] == "text2video" # the usage decision survives
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Saved mappings survive reanalyze / field edits / migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_saved_mapping_survives_reanalyze_and_field_edits(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "重分析·保映射", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"name": "改名不影响映射",
|
||||
"description": "编辑其他字段"})
|
||||
wf = r.json()
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user" and wf["is_enabled"] is True
|
||||
|
||||
r = client.post(f"/api/workflows/{wid}/reanalyze")
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user"
|
||||
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
|
||||
assert wf["is_enabled"] is True
|
||||
|
||||
|
||||
def test_sql_forced_enabled_row_without_mapping_is_blocked(client):
|
||||
"""Backend-direct generation on an unconfirmed row is refused."""
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "强开", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video"},
|
||||
).json()["id"]
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute("UPDATE workflows SET is_enabled = 1 WHERE id = ?", (wid,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
assert "输出节点" in gen.json()["detail"]
|
||||
|
||||
|
||||
def test_migration_parks_unknown_output_rows_and_keeps_trusted_ones(client):
|
||||
import asyncio
|
||||
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
# Pre-HEL-638 shape: user confirmed a usage on an unknown-output
|
||||
# graph and the old PATCH re-enabled it.
|
||||
conn.execute(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
||||
" output_schema, is_enabled, usage, usage_source)"
|
||||
" VALUES (?, 'video', ?, '[]', '[]', 1, 'text2video', 'user')",
|
||||
("存量·未知输出", json.dumps(_vault_workflow())),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
asyncio.run(migrate_db(settings.db_path)) # idempotent re-run
|
||||
|
||||
wfs = {w["name"]: w for w in client.get("/api/workflows").json()}
|
||||
parked = wfs["存量·未知输出"]
|
||||
assert parked["is_enabled"] is False
|
||||
assert parked["usage"] == "text2video" # decision preserved
|
||||
assert not parked["output_map"] and parked["needs_mapping"] is True
|
||||
|
||||
# A trusted five-type import keeps working untouched.
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "存量·可信", "kind": "image",
|
||||
"workflow_json": json.loads(
|
||||
(FIXTURES / "qwen_image_t2i_API.json").read_text(encoding="utf-8")
|
||||
)},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
trusted = next(w for w in client.get("/api/workflows").json() if w["id"] == r.json()["id"])
|
||||
assert trusted["is_enabled"] is True
|
||||
assert trusted["output_map_source"] == "auto"
|
||||
assert any(e["nodeId"] == "60" and e["kind"] == "image" for e in trusted["output_map"])
|
||||
@@ -58,8 +58,9 @@ def test_native_qwen_edit_detects_image_and_mask_slots():
|
||||
assert set(media) == {"78", "95"}
|
||||
assert media["95"]["role"] == "mask"
|
||||
assert "mask" in media["95"]["label"].lower() or "遮罩" in media["95"]["label"]
|
||||
# the workflow's own image names stay valid, so the slots are optional
|
||||
assert not media["78"]["required"] and not media["95"]["required"]
|
||||
# HEL-633: a wired input image must be uploaded (the export's own file name
|
||||
# rarely exists on the target ComfyUI); the mask stays optional
|
||||
assert media["78"]["required"] and not media["95"]["required"]
|
||||
|
||||
|
||||
def test_untagged_h3_video_detects_prompt_duration_seed():
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""HEL-636 A1–A6: the five-usage classifier judges from graph evidence only.
|
||||
|
||||
Every case feeds real (or minimally mutated) ComfyUI API exports — never a
|
||||
file name, model name or fixed node id — and asserts the usage, the media
|
||||
slots it implies, and the failure paths for ambiguous / unknown graphs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.parser import (
|
||||
classify_usage,
|
||||
detect_untagged_inputs,
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
|
||||
def _load(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _rename_everything(wf: dict, id_shift: int = 7) -> dict:
|
||||
"""Change every node id, title and widget value the classifier must not
|
||||
depend on (model file names / prompt texts)."""
|
||||
def new_id(nid: str) -> str:
|
||||
return f"x{int(nid) * id_shift + 3}" if nid.isdigit() else "x" + nid.replace(":", "_")
|
||||
|
||||
out: dict = {}
|
||||
for nid, node in wf.items():
|
||||
out[new_id(nid)] = json.loads(json.dumps(node))
|
||||
for node in out.values():
|
||||
for val in (node.get("inputs") or {}).values():
|
||||
if isinstance(val, list) and len(val) == 2 and str(val[0]).isdigit():
|
||||
val[0] = new_id(str(val[0]))
|
||||
if node.get("_meta", {}).get("title"):
|
||||
node["_meta"]["title"] = f"改·{node['_meta']['title']}"
|
||||
for key, val in list((node.get("inputs") or {}).items()):
|
||||
if isinstance(val, str) and val.endswith((".safetensors", ".gguf")):
|
||||
node["inputs"][key] = "另一个模型_" + val
|
||||
return out
|
||||
|
||||
|
||||
def _media(inputs: list[dict]) -> list[dict]:
|
||||
return [
|
||||
i
|
||||
for i in inputs
|
||||
if i["kind"] in ("image", "image_list", "video", "audio")
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A1 / A2 / A3: the three real shapes, plus id/title/model renames
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a1_native_t2i_is_text2image_and_form_has_no_image_slot():
|
||||
wf = _load(FIXTURES / "qwen_image_t2i_API.json")
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "text2image"
|
||||
assert c["ambiguous"] is False and c["needs_mapping"] is False
|
||||
assert _media(parse_dynamic_inputs(wf)) == []
|
||||
outs = parse_dynamic_outputs(wf)
|
||||
assert [(o["nodeId"], o["kind"]) for o in outs] == [("60", "image")]
|
||||
|
||||
|
||||
def test_a2_qwen_edit_variant_with_new_ids_titles_and_model_stays_image2image():
|
||||
wf = _rename_everything(_load(FIXTURES / "qwen_image_edit_mask_API.json"))
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "image2image"
|
||||
inputs = parse_dynamic_inputs(wf)
|
||||
img = [i for i in _media(inputs) if i.get("role") == "image"]
|
||||
assert len(img) == 1 and img[0]["required"] is True
|
||||
mask = [i for i in _media(inputs) if i.get("role") == "mask"]
|
||||
assert len(mask) == 1 # mask never counts as an input image
|
||||
|
||||
|
||||
def test_a3_i2v_variant_renamed_still_image2video_with_optional_last_frame():
|
||||
wf = _rename_everything(_load(FIXTURES / "h3_i2v_04_API.json"))
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "image2video"
|
||||
media = _media(parse_dynamic_inputs(wf))
|
||||
# HEL-671: the FL2VA node's unwired last_frame is offered, but optional
|
||||
assert [(i.get("role"), i["required"]) for i in media] == [
|
||||
("first_frame", True),
|
||||
("last_frame", False),
|
||||
]
|
||||
assert blocking_problem(wf) is None
|
||||
|
||||
|
||||
def test_a4_ref2va_variant_with_renamed_class_and_prefix_still_groups():
|
||||
wf = _rename_everything(_load(FIXTURES / "h3_ref2va_05_API.json"))
|
||||
for node in wf.values():
|
||||
if node.get("class_type") == "MiniMaxH3ReferenceToVideo":
|
||||
node["class_type"] = "MyFancyRefToVideo"
|
||||
inputs = node["inputs"]
|
||||
for k in list(inputs):
|
||||
if k.startswith("ref_images.ref_image_"):
|
||||
inputs[k.replace("ref_images.ref_image_", "refs.pic_")] = inputs.pop(k)
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "ref2video"
|
||||
groups = [i for i in detect_untagged_inputs(wf) if i["kind"] == "image_list"]
|
||||
assert len(groups) == 1
|
||||
g = groups[0]
|
||||
assert g["role"] == "reference_image" and (g["min"], g["max"]) == (1, 9)
|
||||
# patch rewires the renamed group in order and refuses a 10th image
|
||||
key = g["nodeId"]
|
||||
node_id = key.split("@")[0]
|
||||
patched = patch_workflow(wf, {key: [f"/u/{i}.png" for i in range(4)]})
|
||||
names = sorted(
|
||||
(k for k in patched[node_id]["inputs"] if k.startswith("refs.pic_")),
|
||||
key=lambda k: int(k.rsplit("_", 1)[-1]) if k.rsplit("_", 1)[-1].isdigit() else int(k[-1]),
|
||||
)
|
||||
assert names == [f"refs.pic_{i}" for i in range(4)]
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
|
||||
_c, errs = validate_input_values(
|
||||
detect_untagged_inputs(wf), {key: [f"/u/{i}.png" for i in range(10)]}
|
||||
)
|
||||
assert any("最多 9 张" in e for e in errs)
|
||||
|
||||
|
||||
def test_qwen_single_numbered_encoder_input_is_not_mistaken_for_a_group():
|
||||
"""TextEncodeQwenImage21's ``images.image_1`` stays an ordinary input slot."""
|
||||
inputs = parse_dynamic_inputs(_load(FIXTURES / "qwen21_edit_sage_API.json"))
|
||||
assert not any(i["kind"] == "image_list" for i in inputs)
|
||||
assert any(i.get("role") == "image" for i in inputs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Judgment table rows from the real example library
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_all_real_exports_classify():
|
||||
expect = {
|
||||
"qwen_image_t2i_API.json": "text2image",
|
||||
"qwen_image_edit_mask_API.json": "image2image",
|
||||
"qwen21_edit_sage_API.json": "image2image",
|
||||
"h3_i2v_04_API.json": "image2video",
|
||||
"h3_ref2va_05_API.json": "ref2video",
|
||||
"minimax_h3_t2v_untagged_API.json": "text2video",
|
||||
"wen_sheng_tu_API.json": "text2image",
|
||||
"wen_sheng_shipin_API.json": "text2video",
|
||||
}
|
||||
for name, usage in expect.items():
|
||||
assert classify_usage(_load(FIXTURES / name))["usage"] == usage, name
|
||||
examples = {
|
||||
"Krea2_t2i_20260818_API.json": "text2image",
|
||||
"krea2_T2I_CharSheetV1_API.json": "text2image",
|
||||
"minimax_h3-Turbo_Text2Video_20260813_API.json": "text2video",
|
||||
"minimax_h3-Turbo_Image2Video_20260813_API.json": "text2video",
|
||||
"MiniMax-H3-Turbo-R2V-Extend-2Refs1Vid_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_2ref_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_3ref_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_4ref_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_5ref_API.json": "ref2video",
|
||||
}
|
||||
for name, usage in examples.items():
|
||||
assert classify_usage(_load(EXAMPLES / name))["usage"] == usage, name
|
||||
|
||||
|
||||
def test_r2v_extend_reports_its_video_input_slot():
|
||||
wf = _load(EXAMPLES / "MiniMax-H3-Turbo-R2V-Extend-2Refs1Vid_API.json")
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "ref2video"
|
||||
assert c["evidence"]["video_slots"], "the reference-video slot must be reported"
|
||||
assert any("视频输入槽" in line for line in c["evidence"]["summary"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A5: ambiguity — single loose image into a video graph; multi-output graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_image(nid: str) -> dict:
|
||||
return {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "x.png"},
|
||||
"_meta": {"title": f"图 {nid}"},
|
||||
}
|
||||
|
||||
|
||||
def test_single_loose_image_with_video_output_is_ambiguous():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] is None and c["ambiguous"] is True
|
||||
assert "首帧还是参考图" in c["problem"]
|
||||
|
||||
|
||||
def test_first_frame_role_decides_image2video_without_ambiguity():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "first_frame": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
assert classify_usage(wf)["usage"] == "image2video"
|
||||
|
||||
|
||||
def test_same_graph_both_image_and_video_outputs_is_ambiguous():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
"10": {"class_type": "SaveImage", "inputs": {"images": ["7", 1]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] is None and c["ambiguous"] is True
|
||||
assert "同时输出图片和视频" in c["problem"]
|
||||
|
||||
|
||||
def test_two_reference_images_into_a_custom_node_become_a_group_ref2video():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
|
||||
"6": {"class_type": "LoadImage", "inputs": {"image": "b.png"}},
|
||||
"7": {
|
||||
"class_type": "GenericRefToVideo",
|
||||
"inputs": {
|
||||
"prompt": ["1", 0],
|
||||
"refs.pic_1": ["5", 0],
|
||||
"refs.pic_2": ["6", 0],
|
||||
},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "ref2video"
|
||||
groups = [i for i in detect_untagged_inputs(wf) if i["kind"] == "image_list"]
|
||||
assert [g["nodeId"] for g in groups] == ["7@refs"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A6: failure paths — no output, unknown custom output nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_output_graph_reports_chinese_error_and_blocks():
|
||||
wf = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "p"}},
|
||||
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] is None and c["ambiguous"] is False
|
||||
assert "未在图中找到输出节点" in c["problem"]
|
||||
assert blocking_problem(wf) and "未在图中找到输出节点" in blocking_problem(wf)
|
||||
|
||||
|
||||
def test_unknown_custom_save_node_needs_mapping_and_keeps_a_runnable_path():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "MyCloudExporter",
|
||||
"inputs": {"filename_prefix": "out", "render": ["8", 0]},
|
||||
},
|
||||
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["needs_mapping"] is True
|
||||
assert c["usage"] is None and "无法识别" in (c["problem"] or "")
|
||||
# unknown savers are a warning (user maps them at import), not a block —
|
||||
# once the user confirms the usage the job can still run and results are
|
||||
# pulled from ComfyUI history's images/gifs/videos keys.
|
||||
codes = {(w["level"], w["code"]) for w in workflow_warnings(wf)}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_utility_sink_tail_is_not_an_output_and_does_not_break_detection():
|
||||
"""VideoCombine feeding a cache-cleanup tail still counts as the output."""
|
||||
wf = _load(FIXTURES / "minimax_h3_t2v_untagged_API.json")
|
||||
wf["9999"] = {
|
||||
"class_type": "easy cleanGpuUsed",
|
||||
"inputs": {"anything": ["5978", 0]},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "text2video"
|
||||
assert c["needs_mapping"] is False
|
||||
assert workflow_warnings(wf) == []
|
||||
|
||||
|
||||
def test_evidence_lists_output_nodes_and_slots():
|
||||
wf = _load(FIXTURES / "h3_ref2va_05_API.json")
|
||||
c = classify_usage(wf)
|
||||
ev = c["evidence"]
|
||||
assert any(o["nodeId"] == "92" and o["kind"] == "video" for o in ev["outputs"])
|
||||
assert any(s["kind"] == "image_list" for s in ev["image_slots"])
|
||||
assert any("输出" in line for line in ev["summary"])
|
||||
@@ -26,6 +26,15 @@ VIDEO_WORKFLOW = {
|
||||
"inputs": {"file": ""},
|
||||
"_meta": {"title": "Previous Clip (Input:video)"},
|
||||
},
|
||||
"30": {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {"images": ["40", 0]},
|
||||
"_meta": {"title": "Video Output (Output:video)"},
|
||||
},
|
||||
"40": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["10", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
# Same but the video slot is missing entirely — must be rejected at enqueue.
|
||||
@@ -35,6 +44,15 @@ NO_VIDEO_WORKFLOW = {
|
||||
"inputs": {"text": ""},
|
||||
"_meta": {"title": "Main Prompt (Input:prompt)"},
|
||||
},
|
||||
"30": {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {"images": ["40", 0]},
|
||||
"_meta": {"title": "Video Output (Output:video)"},
|
||||
},
|
||||
"40": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["10", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -250,12 +268,14 @@ def _scene_ids(pid: int) -> list[int]:
|
||||
|
||||
|
||||
async def _fake_history(_client, _prompt_id, *_args):
|
||||
"""A successful ComfyUI history with one video output named out.mp4.
|
||||
Extra positional args (job_id) are accepted for the cancel-check wiring."""
|
||||
"""A successful ComfyUI history with one video output named out.mp4,
|
||||
reported under the workflow's real output node (30 = the tagged
|
||||
VHS_VideoCombine). Extra positional args (job_id) are accepted for the
|
||||
cancel-check wiring."""
|
||||
return {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": {
|
||||
"99": {"videos": [{"filename": "out.mp4", "subfolder": "", "type": "output"}]}
|
||||
"30": {"videos": [{"filename": "out.mp4", "subfolder": "", "type": "output"}]}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -269,7 +289,11 @@ def _stub_comfy(monkeypatch, captured: dict) -> list[str]:
|
||||
prepare_media_inputs handed it; download_image writes out.mp4. Returns
|
||||
the list that collects uploaded local paths.
|
||||
"""
|
||||
queue_manager.paused = True
|
||||
# monkeypatch (not bare assignment): queue_manager is a process-level
|
||||
# singleton — a bare `paused = True` leaks into every later test and
|
||||
# stalls e.g. test_workbench's worker (HEL-619). monkeypatch restores
|
||||
# the previous value at teardown.
|
||||
monkeypatch.setattr(queue_manager, "paused", True)
|
||||
uploads: list[str] = []
|
||||
|
||||
async def fake_upload_video(self, path, subfolder="calliope"):
|
||||
|
||||
@@ -197,6 +197,39 @@ def _confirm(client, pid: int, key: str, **extra):
|
||||
return client.post(f"/api/workbench/projects/{pid}/stages/{key}/confirm", json=extra or None)
|
||||
|
||||
|
||||
def test_assistant_notes_keep_confirmation_and_survive_artifact_edits(client):
|
||||
pid = _make_project(client)
|
||||
note_url = f"/api/workbench/projects/{pid}/stages/style/note"
|
||||
r = client.patch(note_url, json={"text": "建议先比较冷暖两个方向。"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stage"]["status"] == "todo"
|
||||
assert r.json()["stage"]["assistant_note"] == "建议先比较冷暖两个方向。"
|
||||
assert _confirm(client, pid, "style").status_code == 400
|
||||
# An ordinary structured save must keep the separate advice.
|
||||
r = client.patch(f"/api/workbench/projects/{pid}/stages/style", json={"content": json.loads(STYLE_REPLY)})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stage"]["assistant_note"] == "建议先比较冷暖两个方向。"
|
||||
# Notes on confirmed content don't change its status or confirmed snapshot.
|
||||
client.post(f"/api/workbench/projects/{pid}/stages/script/import", json={"text": SCRIPT_TEXT})
|
||||
before = _stage(_overview(client, pid), "script")
|
||||
r = client.patch(f"/api/workbench/projects/{pid}/stages/script/note", json={"text": "下一版建议加强人物动机。"})
|
||||
assert r.status_code == 200
|
||||
after = r.json()["stage"]
|
||||
assert after["status"] == before["status"] == "confirmed"
|
||||
assert after["confirmed_content"] == before["confirmed_content"]
|
||||
assert after["draft_edited"] == before["draft_edited"]
|
||||
assert _confirm(client, pid, "script").status_code == 200
|
||||
confirmed = _stage(_overview(client, pid), "script")
|
||||
assert "assistant_note" not in confirmed["confirmed_content"]
|
||||
client.patch(f"/api/workbench/projects/{pid}/stages/script/note", json={"text": "保留确认后的新建议。"})
|
||||
restored = client.post(f"/api/workbench/projects/{pid}/stages/script/restore").json()["stage"]
|
||||
assert restored["assistant_note"] == "保留确认后的新建议。"
|
||||
assert restored["confirmed_content"] == confirmed["confirmed_content"]
|
||||
assert client.patch(note_url, json={"text": ""}).json()["stage"]["assistant_note"] == ""
|
||||
assert client.patch(note_url, json={"text": "x" * 200_001}).status_code == 422
|
||||
assert client.patch(f"/api/workbench/projects/{pid}/stages/unknown/note", json={"text": "x"}).status_code == 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,235 @@
|
||||
"""HEL-636: the two-step import closes the loop — analyze → confirm → save,
|
||||
user corrections stick (reanalyze never overwrites them), pending workflows
|
||||
cannot generate, and legacy rows migrate losslessly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, migrate_db
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
AMBIGUOUS = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def test_analyze_returns_usage_evidence_and_problem(client):
|
||||
wf = _load("qwen_image_t2i_API.json")
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": wf})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["usage"] == "text2image"
|
||||
assert body["usage_ambiguous"] is False and body["needs_mapping"] is False
|
||||
assert any(o["kind"] == "image" for o in body["usage_evidence"]["outputs"])
|
||||
assert body["usable_usage"] == [
|
||||
"text2image", "image2image", "text2video", "image2video", "ref2video",
|
||||
]
|
||||
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": AMBIGUOUS})
|
||||
body = r.json()
|
||||
assert body["usage"] is None and body["usage_ambiguous"] is True
|
||||
assert "首帧还是参考图" in body["usage_problem"]
|
||||
assert body["usage_evidence"]["image_slots"][0]["nodeId"] == "5"
|
||||
|
||||
bare = {"1": {"class_type": "Note", "inputs": {}}}
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": bare})
|
||||
body = r.json()
|
||||
assert "未在图中找到输出节点" in body["usage_problem"]
|
||||
|
||||
|
||||
def test_create_stores_auto_usage_and_kind(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "自动·文生图", "workflow_json": _load("qwen_image_t2i_API.json")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "text2image" and wf["usage_source"] == "auto"
|
||||
assert wf["kind"] == "image" and wf["is_enabled"] is True
|
||||
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "自动·参考生视频", "workflow_json": _load("h3_ref2va_05_API.json")},
|
||||
)
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "ref2video" and wf["kind"] == "video"
|
||||
|
||||
|
||||
def test_ambiguous_import_stays_pending_and_cannot_generate(client):
|
||||
r = client.post("/api/workflows", json={"name": "歧义", "workflow_json": AMBIGUOUS})
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] is None and wf["is_enabled"] is False
|
||||
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
assert "已停用" in gen.json()["detail"]
|
||||
assert client.get("/api/playground/jobs").json() == []
|
||||
|
||||
|
||||
def test_confirm_page_correction_roundtrip(client):
|
||||
"""analyze → user picks usage + fixes the slot role → create carries both."""
|
||||
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": AMBIGUOUS}).json()
|
||||
assert analyzed["usage"] is None
|
||||
|
||||
slot = analyzed["usage_evidence"]["image_slots"][0]
|
||||
schema = [
|
||||
{**i, "role": "first_frame", "manual": True} if i["nodeId"] == slot["nodeId"] else i
|
||||
for i in analyzed["inputs"]
|
||||
]
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "确认·图生视频",
|
||||
"workflow_json": AMBIGUOUS,
|
||||
"usage": "image2video",
|
||||
"usage_source": "user",
|
||||
"input_schema": schema,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "image2video" and wf["usage_source"] == "user"
|
||||
assert wf["kind"] == "video" and wf["is_enabled"] is True
|
||||
# the manual role sticks on read (and through re-derivation)
|
||||
entry = next(i for i in wf["input_schema"] if i["nodeId"] == slot["nodeId"])
|
||||
assert entry["role"] == "first_frame"
|
||||
|
||||
# reanalyze refreshes the auto schema but never the user's usage
|
||||
r = client.post(f"/api/workflows/{wf['id']}/reanalyze")
|
||||
assert r.status_code == 200
|
||||
fresh = r.json()
|
||||
assert fresh["usage"] == "image2video" and fresh["usage_source"] == "user"
|
||||
entry = next(i for i in fresh["input_schema"] if i["nodeId"] == slot["nodeId"])
|
||||
assert entry["role"] == "first_frame" and entry.get("manual") is True
|
||||
|
||||
|
||||
def test_patch_usage_confirms_pending_workflow(client):
|
||||
r = client.post("/api/workflows", json={"name": "待确认", "workflow_json": AMBIGUOUS})
|
||||
wid = r.json()["id"]
|
||||
assert r.json()["is_enabled"] is False
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"usage": "ref2video"})
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "ref2video" and wf["usage_source"] == "user"
|
||||
assert wf["kind"] == "video" and wf["is_enabled"] is True
|
||||
|
||||
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wid)
|
||||
assert listed["usage"] == "ref2video"
|
||||
|
||||
|
||||
def test_reanalyze_upgrades_auto_usage_only(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "重分析", "workflow_json": _load("qwen_image_t2i_API.json")},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"usage": "image2image", "usage_source": "user"})
|
||||
assert r.json()["usage"] == "image2image"
|
||||
|
||||
r = client.post(f"/api/workflows/{wid}/reanalyze")
|
||||
assert r.json()["usage"] == "image2image" # user decision survives
|
||||
assert r.json()["usage_source"] == "user"
|
||||
|
||||
|
||||
def test_legacy_kind_only_callers_still_work(client):
|
||||
"""Old clients post kind without usage — determinate graphs classify, the
|
||||
row is enabled and generation proceeds as before."""
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "旧调用", "kind": "image", "workflow_json": _load("qwen_image_t2i_API.json")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "text2image" and wf["is_enabled"] is True
|
||||
|
||||
|
||||
def test_migration_backfills_usage_and_keeps_history(client):
|
||||
"""A8: pre-HEL-636 rows (direct SQL, no usage) settle on re-migration;
|
||||
determinate ones gain usage, ambiguous ones are parked disabled, and
|
||||
existing jobs/history stay readable with their original kind."""
|
||||
pid = client.post("/api/projects", json={"title": "存量项目", "idea": "x"}).json()["id"]
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
||||
" output_schema, is_enabled) VALUES (?, 'image', ?, '[]', '[]', 1)",
|
||||
("存量·文生图", json.dumps(_load("qwen_image_t2i_API.json"))),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
||||
" output_schema, is_enabled) VALUES (?, 'video', ?, '[]', '[]', 1)",
|
||||
("存量·歧义", json.dumps(AMBIGUOUS)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (project_id, kind, workflow_id, status, payload_json,"
|
||||
" output_paths_json) VALUES (?, 'image', NULL, 'done', '{}', ?)",
|
||||
(pid, json.dumps(["/old/result.png"])),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(migrate_db(settings.db_path)) # idempotent re-run
|
||||
|
||||
wfs = {w["name"]: w for w in client.get("/api/workflows").json()}
|
||||
assert wfs["存量·文生图"]["usage"] == "text2image"
|
||||
assert wfs["存量·文生图"]["is_enabled"] is True
|
||||
assert wfs["存量·歧义"]["usage"] is None
|
||||
assert wfs["存量·歧义"]["is_enabled"] is False
|
||||
assert wfs["存量·歧义"]["kind"] == "video" # old kind semantics untouched
|
||||
|
||||
jobs = client.get(f"/api/jobs?project_id={pid}").json()
|
||||
assert any(
|
||||
j["status"] == "done" and j["kind"] == "image" and j["output_paths"] == ["/old/result.png"]
|
||||
for j in jobs
|
||||
)
|
||||
|
||||
# a third run changes nothing (idempotent)
|
||||
asyncio.run(migrate_db(settings.db_path))
|
||||
wfs2 = {w["name"]: w for w in client.get("/api/workflows").json()}
|
||||
assert wfs2["存量·文生图"]["usage"] == "text2image"
|
||||
assert wfs2["存量·歧义"]["usage"] is None
|
||||
|
||||
|
||||
def test_broken_qwen_import_stays_blocked_but_recorded(client):
|
||||
"""The damaged Qwen export classifies fine (image2image); its structural
|
||||
lint errors still refuse generation — a broken file case, not a usage case."""
|
||||
wf_json = _load("qwen21_edit_sage_API.json")
|
||||
r = client.post(
|
||||
"/api/workflows/analyze", json={"workflow_json": wf_json}
|
||||
)
|
||||
assert r.json()["usage"] == "image2image"
|
||||
r = client.post(
|
||||
"/api/workflows", json={"name": "损坏·Qwen", "kind": "image", "workflow_json": wf_json}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "image2image" and wf["is_enabled"] is True
|
||||
assert any(w["level"] == "error" for w in wf["warnings"])
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"4@prompt": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400 and "重新导出" in gen.json()["detail"]
|
||||
@@ -0,0 +1,272 @@
|
||||
"""L2 连接核查(HEL-643 批次 1):导入/复查时用目标 ComfyUI 的 /object_info
|
||||
预检节点与模型可用性。全部基于 fake ComfyUI 的脚本化 /object_info,
|
||||
不针对任何固定 JSON 或模型名写死判定。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.client import clear_object_info_cache
|
||||
from calliope.comfyui.preflight import check_workflow_against
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import _url
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _load_05() -> dict:
|
||||
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def build_object_info(workflow: dict) -> dict:
|
||||
"""A catalogue where every widget value the JSON carries is legal.
|
||||
|
||||
String widgets become combos whose candidates include the workflow's own
|
||||
value (merged across nodes of the same class); numbers/booleans are typed
|
||||
inputs; wired inputs are link types — none of those can mismatch.
|
||||
"""
|
||||
info: dict = {}
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
ct = str(node.get("class_type") or "")
|
||||
if not ct:
|
||||
continue
|
||||
spec = info.setdefault(ct, {"input": {"required": {}}, "output": ["*"]})
|
||||
required = spec["input"]["required"]
|
||||
for name, val in (node.get("inputs") or {}).items():
|
||||
if isinstance(val, str):
|
||||
# Merge: two nodes of one class with different values → both legal.
|
||||
if name in required and isinstance(required[name][0], list):
|
||||
if val not in required[name][0]:
|
||||
required[name][0] = [*required[name][0], val]
|
||||
continue
|
||||
required[name] = [[val], {}]
|
||||
elif name in required:
|
||||
continue
|
||||
elif isinstance(val, list):
|
||||
required[name] = ["*", {}]
|
||||
elif isinstance(val, bool):
|
||||
required[name] = ["BOOLEAN", {}]
|
||||
elif isinstance(val, (int, float)):
|
||||
required[name] = ["NUMBER", {}]
|
||||
return info
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_cache():
|
||||
clear_object_info_cache()
|
||||
yield
|
||||
clear_object_info_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def endpoint_at(client, fake_comfy): # noqa: F811
|
||||
"""Register a connection pointing at the fake server; yields its id."""
|
||||
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
||||
prev_active = settings.comfyui_active_id
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "预检桩", "base_url": _url(fake_comfy), "auth_type": "none"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
endpoints = r.json()["endpoints"]
|
||||
ep = next(e for e in endpoints if e["base_url"] == _url(fake_comfy))
|
||||
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["base_url"] == _url(fake_comfy)]
|
||||
settings.comfyui_active_id = ep["id"]
|
||||
settings.apply_active_comfyui()
|
||||
try:
|
||||
yield ep["id"]
|
||||
finally:
|
||||
settings.comfyui_endpoints = prev_eps
|
||||
settings.comfyui_active_id = prev_active
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unit_missing_node_reported_with_node_context():
|
||||
wf = {"900": {"class_type": "H3SLAAttention", "inputs": {"enabled": True},
|
||||
"_meta": {"title": "H3 SLA Attention"}}}
|
||||
findings = check_workflow_against({"OtherNode": {}}, wf)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f["code"] == "missing_node" and f["level"] == "error"
|
||||
msg = f["message"]
|
||||
assert "H3SLAAttention" in msg and "900" in msg and "H3 SLA Attention" in msg
|
||||
assert "重新检查" in f["hint"]
|
||||
|
||||
|
||||
def test_unit_combo_value_not_in_list():
|
||||
wf = {"127": {"class_type": "UNETLoader",
|
||||
"inputs": {"unet_name": "missing_model.safetensors", "weight_dtype": "default"}}}
|
||||
info = {"UNETLoader": {"input": {"required": {
|
||||
"unet_name": [["installed_model.safetensors"], {}],
|
||||
"weight_dtype": [["default", "fp8"], {"upload": False}],
|
||||
}}, "output": ["MODEL"]}}
|
||||
findings = check_workflow_against(info, wf)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f["code"] == "value_not_in_list" and f["node_id"] == "127"
|
||||
assert "missing_model.safetensors" in f["message"] and "unet_name" in f["message"]
|
||||
assert "模型文件" in f["message"]
|
||||
assert "installed_model.safetensors" in f["hint"]
|
||||
|
||||
|
||||
def test_unit_upload_backed_and_wired_inputs_skipped():
|
||||
wf = {
|
||||
"1": {"class_type": "LoadImage", "inputs": {"image": "placeholder.png"}},
|
||||
"2": {"class_type": "LoadVideo", "inputs": {"file": "clip.mp4"}},
|
||||
"3": {"class_type": "LoadAudio", "inputs": {"audio": "voice.wav"}},
|
||||
"4": {"class_type": "KSampler", "inputs": {"seed": 7, "model": ["9", 0]}},
|
||||
}
|
||||
info = {
|
||||
"LoadImage": {"input": {"required": {"image": [["other.png"], {"image_upload": True}]}},
|
||||
"output": ["IMAGE"]},
|
||||
"LoadVideo": {"input": {"required": {"file": [[], {}]}}, "output": ["IMAGE"]},
|
||||
"LoadAudio": {"input": {"required": {"audio": [[], {}]}}, "output": ["AUDIO"]},
|
||||
"KSampler": {"input": {"required": {"seed": ["INT", {}], "model": ["MODEL", {}]}},
|
||||
"output": ["IMAGE"]},
|
||||
}
|
||||
assert check_workflow_against(info, wf) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analyze with a target connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_analyze_all_green_against_fake_object_info(client, endpoint_at, fake_comfy):
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
check = r.json()["connection_check"]
|
||||
assert check["reachable"] is True and check["ok"] is True
|
||||
assert check["findings"] == []
|
||||
|
||||
|
||||
def test_analyze_reports_missing_custom_node(client, endpoint_at, fake_comfy):
|
||||
info = build_object_info(_load_05())
|
||||
info.pop("H3SLAAttention")
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["ok"] is False
|
||||
missing = [f for f in check["findings"] if f["code"] == "missing_node"]
|
||||
assert missing and "H3SLAAttention" in missing[0]["message"]
|
||||
assert "900" in missing[0]["message"]
|
||||
|
||||
|
||||
def test_analyze_reports_missing_model_value(client, endpoint_at, fake_comfy):
|
||||
info = build_object_info(_load_05())
|
||||
# The server has a DIFFERENT unet installed than the one the JSON names.
|
||||
info["UNETLoader"]["input"]["required"]["unet_name"] = [["another_unet.safetensors"], {}]
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["ok"] is False
|
||||
bad = [f for f in check["findings"] if f["code"] == "value_not_in_list"]
|
||||
assert bad and bad[0]["node_id"] == "127"
|
||||
assert "minimax_h3_fused_refdelta" in bad[0]["message"]
|
||||
|
||||
|
||||
def test_unreachable_connection_degrades_to_unchecked(client):
|
||||
# A port that nothing listens on: the endpoint resolves, the server doesn't.
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
||||
prev_active = settings.comfyui_active_id
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "断连桩", "base_url": f"http://127.0.0.1:{port}", "auth_type": "none"},
|
||||
)
|
||||
endpoints = r.json()["endpoints"]
|
||||
ep = next(e for e in endpoints if e["base_url"] == f"http://127.0.0.1:{port}")
|
||||
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["id"] == ep["id"]]
|
||||
settings.comfyui_active_id = ep["id"]
|
||||
settings.apply_active_comfyui()
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": ep["id"]},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["reachable"] is False and check["ok"] is False
|
||||
assert "无法核查" in check["message"]
|
||||
assert check["findings"] == []
|
||||
finally:
|
||||
settings.comfyui_endpoints = prev_eps
|
||||
settings.comfyui_active_id = prev_active
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Saved-workflow re-check + cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_workflow(client, workflow: dict, endpoint_id: str | None = None) -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "预检工作流",
|
||||
"workflow_json": workflow,
|
||||
"usage": "ref2video",
|
||||
**({"endpoint_id": endpoint_id} if endpoint_id else {}),
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_saved_workflow_recheck_and_refresh(client, endpoint_at, fake_comfy):
|
||||
wf_id = _create_workflow(client, _load_05(), endpoint_at)
|
||||
info = build_object_info(_load_05())
|
||||
info.pop("UNETLoader")
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(f"/api/workflows/{wf_id}/check", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is False and body["endpoint_id"] == endpoint_at
|
||||
assert any(f["code"] == "missing_node" for f in body["findings"])
|
||||
|
||||
# User installs the node package, comes back and re-checks with refresh.
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
r = client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
|
||||
body = r.json()
|
||||
assert body["ok"] is True and body["findings"] == []
|
||||
|
||||
|
||||
def test_object_info_cached_within_ttl(client, endpoint_at, fake_comfy):
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
for _ in range(2):
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
assert r.json()["connection_check"]["ok"] is True
|
||||
hits = [p for p in fake_comfy.requests if p == "/object_info"]
|
||||
assert len(hits) == 1 # second call served from the 5-minute cache
|
||||
|
||||
wf_id = _create_workflow(client, _load_05())
|
||||
client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
|
||||
hits = [p for p in fake_comfy.requests if p == "/object_info"]
|
||||
assert len(hits) == 2 # manual refresh bypasses the TTL
|
||||
@@ -0,0 +1,197 @@
|
||||
"""「测试工作流」(HEL-643 批次 2)端到端:真实队列 → fake ComfyUI → 产物隔离。
|
||||
|
||||
测试任务与普通任务区分(jobs.origin)、dry-run 拒绝、幂等复用、
|
||||
保留策略与级联清理,全部对着 fake ComfyUI 跑真实链路。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.queue.workflow_test import workflow_test_dir
|
||||
from tests.test_studio_generation import (
|
||||
IMAGE_WORKFLOW,
|
||||
PNG_1PX,
|
||||
_real_run,
|
||||
)
|
||||
|
||||
|
||||
def _wait_terminal(client, job_id: int, timeout: float = 25.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
r = client.get(f"/api/jobs/{job_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
if job["status"] not in ("pending", "running"):
|
||||
return job
|
||||
time.sleep(0.4)
|
||||
raise AssertionError(f"test job {job_id} did not finish in {timeout}s")
|
||||
|
||||
|
||||
def _create_workflow(client, workflow: dict, name: str = "可测工作流") -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": name, "workflow_json": workflow, "usage": "text2image"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_passing_test_downloads_real_output_to_scratch_dir(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="通过用例")
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "测试提示词"}}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["reused"] is False
|
||||
job = _wait_terminal(client, body["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["origin"] == "workflow_test"
|
||||
assert job["payload"]["source"] == "workflow_test"
|
||||
assert job["phase"] == "finished"
|
||||
# 真实拿到可下载输出才算通过:产物落在独立 scratch 目录
|
||||
assert len(job["output_paths"]) >= 1
|
||||
out = Path(job["output_paths"][0])
|
||||
assert out.is_file() and out.read_bytes() == PNG_1PX
|
||||
assert workflow_test_dir(wf_id) in out.parents
|
||||
# 提交给 fake ComfyUI 的是补丁后的真实提示词
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
assert queued["1"]["inputs"]["text"] == "测试提示词"
|
||||
|
||||
|
||||
def test_tasks_list_shows_origin_but_playground_history_hides_it(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="可见性用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job_id = r.json()["job"]["id"]
|
||||
_wait_terminal(client, job_id)
|
||||
|
||||
listed = client.get("/api/jobs?limit=50").json()
|
||||
mine = [j for j in listed if j["id"] == job_id]
|
||||
assert mine and mine[0]["origin"] == "workflow_test"
|
||||
|
||||
pg = client.get("/api/playground/jobs").json()
|
||||
assert all(j["id"] != job_id for j in pg), "test jobs must stay out of studio history"
|
||||
|
||||
|
||||
def test_failed_prompt_reports_chinese_reason(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="排队被拒用例")
|
||||
fake_comfy.fail_prompt = True
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job = _wait_terminal(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "拒绝执行" in (job["error"] or "")
|
||||
|
||||
|
||||
def test_no_output_does_not_pass(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="无输出用例")
|
||||
fake_comfy.drop_outputs = True
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job = _wait_terminal(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "没有返回任何图片或视频" in (job["error"] or "")
|
||||
|
||||
|
||||
def test_dry_run_setting_refuses_real_test(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
settings.dry_run = True # conftest default; _real_run clears it — set again
|
||||
try:
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="dry 拒绝用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
assert r.status_code == 400
|
||||
assert "Dry-run" in r.json()["detail"]
|
||||
finally:
|
||||
settings.dry_run = False
|
||||
|
||||
|
||||
def test_repeat_click_reuses_inflight_job(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="幂等用例")
|
||||
assert client.post("/api/jobs/pause").status_code == 200
|
||||
try:
|
||||
r1 = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "一次就好"}}
|
||||
)
|
||||
assert r1.status_code == 200 and r1.json()["reused"] is False
|
||||
r2 = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "重复点击"}}
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["reused"] is True
|
||||
assert r2.json()["job"]["id"] == r1.json()["job"]["id"]
|
||||
finally:
|
||||
client.post("/api/jobs/resume")
|
||||
client.post(f"/api/jobs/{r1.json()['job']['id']}/cancel")
|
||||
_wait_terminal(client, r1.json()["job"]["id"])
|
||||
|
||||
|
||||
def test_retention_keeps_newest_records_and_prunes_artifacts(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="保留策略用例")
|
||||
for i in range(7):
|
||||
fake_comfy.out_name = f"t{i}.png"
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test",
|
||||
json={"input_values": {"1": f"第{i}次"}},
|
||||
)
|
||||
_wait_terminal(client, r.json()["job"]["id"])
|
||||
rows = client.get(f"/api/workflows/{wf_id}/tests").json()
|
||||
assert len(rows) <= 5
|
||||
kept_ids = {row["id"] for row in rows}
|
||||
# 被清掉的旧记录其产物文件也一并删除
|
||||
leftovers = [p for p in workflow_test_dir(wf_id).iterdir() if p.suffix == ".png"]
|
||||
assert all(p.stem.lstrip("t").isdigit() and int(p.stem[1:]) >= 2 for p in leftovers)
|
||||
assert len(kept_ids) == 5
|
||||
|
||||
|
||||
def test_clear_tests_and_workflow_delete_cascade(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="清理用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
_wait_terminal(client, r.json()["job"]["id"])
|
||||
assert workflow_test_dir(wf_id).is_dir()
|
||||
|
||||
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
|
||||
assert r.status_code == 200 and r.json()["removed"] >= 1
|
||||
assert not workflow_test_dir(wf_id).exists()
|
||||
assert client.get(f"/api/workflows/{wf_id}/tests").json() == []
|
||||
|
||||
# 进行中的测试:先拒绝清除,取消后删除工作流级联清理
|
||||
assert client.post("/api/jobs/pause").status_code == 200
|
||||
try:
|
||||
job_id = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}}
|
||||
).json()["job"]["id"]
|
||||
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
|
||||
assert r.status_code == 409
|
||||
finally:
|
||||
client.post("/api/jobs/resume")
|
||||
client.post(f"/api/jobs/{job_id}/cancel")
|
||||
_wait_terminal(client, job_id)
|
||||
|
||||
# 工作流列表带出「上次测试」实时状态
|
||||
listed = client.get("/api/workflows").json()
|
||||
mine = [w for w in listed if w["id"] == wf_id][0]
|
||||
assert mine["last_test"]["id"] == job_id
|
||||
|
||||
assert client.delete(f"/api/workflows/{wf_id}").status_code == 200
|
||||
assert not workflow_test_dir(wf_id).exists()
|
||||
assert client.get(f"/api/workflows/{wf_id}/tests").status_code == 404
|
||||
|
||||
|
||||
def test_last_test_summary_live_on_list(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_a = _create_workflow(client, IMAGE_WORKFLOW, name="A")
|
||||
wf_b = _create_workflow(client, IMAGE_WORKFLOW, name="B")
|
||||
r = client.post(f"/api/workflows/{wf_a}/test", json={"input_values": {"1": "p"}})
|
||||
done = _wait_terminal(client, r.json()["job"]["id"])
|
||||
listed = {w["id"]: w for w in client.get("/api/workflows").json()}
|
||||
assert listed[wf_a]["last_test"]["status"] == "done"
|
||||
assert listed[wf_a]["last_test"]["output_count"] == len(done["output_paths"])
|
||||
assert listed[wf_b]["last_test"] is None
|
||||
@@ -0,0 +1,49 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
|
||||
export default [
|
||||
// SvelteKit sync and adapter-static output; these are not source files.
|
||||
{ ignores: [".svelte-kit/**", "build/**"] },
|
||||
js.configs.recommended,
|
||||
...svelte.configs["flat/recommended"],
|
||||
// v3 removed these from recommended; retain the previous checks explicitly.
|
||||
{
|
||||
rules: {
|
||||
"svelte/valid-compile": "error",
|
||||
"svelte/no-dynamic-slot-name": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,svelte}"],
|
||||
plugins: { "@typescript-eslint": tsPlugin },
|
||||
rules: {
|
||||
...tsPlugin.configs["eslint-recommended"].overrides[0].rules,
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
ignores: ["**/*.svelte.ts"],
|
||||
languageOptions: { parser: tsParser },
|
||||
},
|
||||
{
|
||||
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
||||
languageOptions: { parserOptions: { parser: tsParser } },
|
||||
rules: {
|
||||
"prefer-const": "off",
|
||||
"svelte/prefer-const": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.{js,ts,svelte}"],
|
||||
ignores: ["src/hooks.server.ts"],
|
||||
languageOptions: { globals: globals.browser },
|
||||
},
|
||||
{
|
||||
files: ["*.{js,ts}", "scripts/**/*.mjs", "src/hooks.server.ts"],
|
||||
languageOptions: { globals: globals.node },
|
||||
},
|
||||
];
|
||||
Generated
+4511
-4256
File diff suppressed because it is too large
Load Diff
+39
-37
@@ -1,39 +1,41 @@
|
||||
{
|
||||
"name": "calliope-web",
|
||||
"version": "1.5.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.70.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@types/node": "^22.13.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.20.0",
|
||||
"eslint-plugin-svelte": "^2.46.0",
|
||||
"postcss": "^8.5.0",
|
||||
"prettier": "^3.5.0",
|
||||
"prettier-plugin-svelte": "^3.3.0",
|
||||
"svelte": "^5.20.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/svelte-query": "^5.66.0",
|
||||
"@types/three": "0.185.0",
|
||||
"@xyflow/svelte": "^1.6.5",
|
||||
"mannequin-js": "5.2.3",
|
||||
"three": "0.185.1"
|
||||
},
|
||||
"type": "module"
|
||||
"name": "calliope-web",
|
||||
"version": "1.5.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs && node scripts/check-export-frame.mjs",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.70.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@types/node": "^22.13.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.2",
|
||||
"@typescript-eslint/parser": "8.46.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.20.0",
|
||||
"eslint-plugin-svelte": "3.23.1",
|
||||
"postcss": "^8.5.0",
|
||||
"prettier": "^3.5.0",
|
||||
"prettier-plugin-svelte": "^3.3.0",
|
||||
"svelte": "^5.20.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/svelte-query": "^5.66.0",
|
||||
"@types/three": "0.185.0",
|
||||
"@xyflow/svelte": "^1.6.5",
|
||||
"mannequin-js": "5.2.3",
|
||||
"three": "0.185.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pure-logic checks for 导出尾帧 (lib/media/exportFrame.ts): which media
|
||||
* times get tried near the end, how the PNG is named, and when a sampled
|
||||
* frame counts as black / empty. The decode + download path itself runs in
|
||||
* a real browser (e2e/lastframe.mjs).
|
||||
*
|
||||
* Run with `npm test` (or `node scripts/check-export-frame.mjs`).
|
||||
*/
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const MODULE = join(HERE, "..", "src", "lib", "media", "exportFrame.ts");
|
||||
|
||||
const {
|
||||
tailSeekTimes,
|
||||
videoBaseName,
|
||||
frameFileName,
|
||||
blankKind,
|
||||
statusErrorCode,
|
||||
TAIL_OFFSETS,
|
||||
} = await import(pathToFileURL(MODULE).href);
|
||||
|
||||
let failures = 0;
|
||||
function check(name, cond, extra = "") {
|
||||
if (cond) console.log(`ok ${name}`);
|
||||
else {
|
||||
failures += 1;
|
||||
console.error(`FAIL ${name}${extra ? ` — ${extra}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- seek targets -------------------------------------------------------
|
||||
|
||||
const t6 = tailSeekTimes(6.04);
|
||||
check(
|
||||
"first try is ~1 frame before the end, never the end itself",
|
||||
t6[0] < 6.04 && 6.04 - t6[0] <= 0.05 + 1e-9,
|
||||
JSON.stringify(t6),
|
||||
);
|
||||
check(
|
||||
"later tries step strictly further back",
|
||||
t6.every((t, i) => i === 0 || t < t6[i - 1]),
|
||||
JSON.stringify(t6),
|
||||
);
|
||||
check(
|
||||
"tries stay within ~1s of the end",
|
||||
t6.length === TAIL_OFFSETS.length && 6.04 - t6[t6.length - 1] <= 1 + 1e-9,
|
||||
);
|
||||
check(
|
||||
"short clip clamps to 0 without duplicates",
|
||||
JSON.stringify(tailSeekTimes(0.1)) === "[0.05,0]",
|
||||
);
|
||||
check(
|
||||
"unknown / zero duration falls back to t=0",
|
||||
JSON.stringify(tailSeekTimes(NaN)) === "[0]" &&
|
||||
JSON.stringify(tailSeekTimes(0)) === "[0]",
|
||||
);
|
||||
|
||||
// ---- file names ---------------------------------------------------------
|
||||
|
||||
const api =
|
||||
"/api/file?path=" +
|
||||
encodeURIComponent(
|
||||
"/opt/aigc-home/data/assets/1/video/05_ref2va_4step_sla_00035_.mp4",
|
||||
);
|
||||
check(
|
||||
"base name from /api/file?path=…",
|
||||
videoBaseName(api) === "05_ref2va_4step_sla_00035_",
|
||||
);
|
||||
check(
|
||||
"base name from a Windows path",
|
||||
videoBaseName(
|
||||
"/api/file?path=" +
|
||||
encodeURIComponent("D:\\calliope\\assets\\镜头 01.webm"),
|
||||
) === "镜头 01",
|
||||
);
|
||||
check(
|
||||
"base name from a plain URL",
|
||||
videoBaseName("http://x.test/media/clip-9.mov?x=1") === "clip-9",
|
||||
);
|
||||
check(
|
||||
"unsafe characters are replaced",
|
||||
!/[:*?"<>|]/.test(
|
||||
videoBaseName("/api/file?path=" + encodeURIComponent('a:b*c?"d<e>f|g.mp4')),
|
||||
),
|
||||
);
|
||||
check(
|
||||
"blob URL without a name falls back to 'video'",
|
||||
videoBaseName("blob:http://x.test/") === "video",
|
||||
);
|
||||
const fname = frameFileName(api, 5.958, "尾帧");
|
||||
check(
|
||||
"PNG name names the video, the tag and the frame time",
|
||||
fname === "05_ref2va_4step_sla_00035__尾帧_5.958s.png",
|
||||
fname,
|
||||
);
|
||||
check(
|
||||
"spaces in the tag become dashes",
|
||||
frameFileName(api, 1, "last frame").includes("_last-frame_1.000s.png"),
|
||||
);
|
||||
|
||||
// ---- black / empty detection -------------------------------------------
|
||||
|
||||
function frame(n, rgba) {
|
||||
const out = new Uint8ClampedArray(n * 4);
|
||||
for (let i = 0; i < n; i++) out.set(rgba(i), i * 4);
|
||||
return out;
|
||||
}
|
||||
const N = 64 * 64;
|
||||
check(
|
||||
"fully transparent sample → empty",
|
||||
blankKind(frame(N, () => [0, 0, 0, 0])) === "empty",
|
||||
);
|
||||
check(
|
||||
"pure black → black",
|
||||
blankKind(frame(N, () => [0, 0, 0, 255])) === "black",
|
||||
);
|
||||
check(
|
||||
"near-black encoder noise (16..18) → black",
|
||||
blankKind(frame(N, (i) => [16 + (i % 3), 16, 16, 255])) === "black",
|
||||
);
|
||||
check(
|
||||
"mid-grey picture → not blank",
|
||||
blankKind(frame(N, () => [128, 128, 128, 255])) === null,
|
||||
);
|
||||
check(
|
||||
"dim but visible picture (≈50) → not blank",
|
||||
blankKind(frame(N, () => [50, 48, 52, 255])) === null,
|
||||
);
|
||||
check(
|
||||
"dark night scene with a bright highlight → not blank",
|
||||
blankKind(
|
||||
frame(N, (i) => (i === 100 ? [250, 240, 200, 255] : [4, 4, 6, 255])),
|
||||
) === null,
|
||||
);
|
||||
|
||||
// ---- HTTP status classification ----------------------------------------
|
||||
|
||||
check("404 → not-found", statusErrorCode(404) === "not-found");
|
||||
check(
|
||||
"403 → forbidden",
|
||||
statusErrorCode(403) === "forbidden" && statusErrorCode(401) === "forbidden",
|
||||
);
|
||||
check("500 → network", statusErrorCode(500) === "network");
|
||||
check(
|
||||
"206 / 200 are not errors",
|
||||
statusErrorCode(206) === null && statusErrorCode(200) === null,
|
||||
);
|
||||
|
||||
if (failures) {
|
||||
console.error(`\n${failures} export-frame check(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("export-frame checks passed.");
|
||||
@@ -98,9 +98,9 @@ check(
|
||||
JSON.stringify(stripSkillFrontmatter(plain)),
|
||||
);
|
||||
|
||||
// 5. Static: the page uses the shared helper; no `\A` regex remains.
|
||||
// 5. Static: the extracted skill editor uses the shared helper; no `\A` regex remains.
|
||||
const page = readFileSync(
|
||||
join(ROOT, "src/routes/agents/+page.svelte"),
|
||||
join(ROOT, "src/lib/components/agent/LegacyAgentWorkspace.svelte"),
|
||||
"utf-8",
|
||||
);
|
||||
check(
|
||||
|
||||
@@ -16,51 +16,53 @@
|
||||
*
|
||||
* Run with `npm test` (or `node scripts/check-storage-ssr.mjs`).
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..');
|
||||
const SRC = join(ROOT, 'src');
|
||||
const HELPER = join(SRC, 'lib', 'storage.ts');
|
||||
const ROOT = join(HERE, "..");
|
||||
const SRC = join(ROOT, "src");
|
||||
const HELPER = join(SRC, "lib", "storage.ts");
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function fail(message) {
|
||||
failures += 1;
|
||||
console.error(`FAIL ${message}`);
|
||||
failures += 1;
|
||||
console.error(`FAIL ${message}`);
|
||||
}
|
||||
|
||||
function ok(message) {
|
||||
console.log(`ok ${message}`);
|
||||
console.log(`ok ${message}`);
|
||||
}
|
||||
|
||||
/** Replace the global under test. Node's own binding may be getter-only. */
|
||||
function setGlobalStorage(value) {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** A Storage that behaves like a browser's. */
|
||||
function fakeStorage(initial = {}) {
|
||||
const map = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
||||
setItem: (k, v) => void map.set(k, String(v)),
|
||||
removeItem: (k) => void map.delete(k),
|
||||
clear: () => map.clear(),
|
||||
key: (i) => [...map.keys()][i] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
const map = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
||||
setItem: (k, v) => void map.set(k, String(v)),
|
||||
removeItem: (k) => void map.delete(k),
|
||||
clear: () => map.clear(),
|
||||
key: (i) => [...map.keys()][i] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { storageGet, storageSet, storageRemove } = await import(pathToFileURL(HELPER).href);
|
||||
const { storageGet, storageSet, storageRemove } = await import(
|
||||
pathToFileURL(HELPER).href
|
||||
);
|
||||
|
||||
// ---- 1. behavioural ----------------------------------------------------
|
||||
|
||||
@@ -68,124 +70,133 @@ const { storageGet, storageSet, storageRemove } = await import(pathToFileURL(HEL
|
||||
// This is the configuration that produced the original crash.
|
||||
setGlobalStorage({});
|
||||
try {
|
||||
if (storageGet('calliope-lang') !== null) fail('method-less localStorage: expected null');
|
||||
else if (storageSet('calliope-lang', 'ja') !== undefined) fail('method-less localStorage: setItem should no-op');
|
||||
else {
|
||||
storageRemove('calliope-lang');
|
||||
ok('method-less localStorage (Node SSR shape) degrades to null without throwing');
|
||||
}
|
||||
if (storageGet("calliope-lang") !== null)
|
||||
fail("method-less localStorage: expected null");
|
||||
else if (storageSet("calliope-lang", "ja") !== undefined)
|
||||
fail("method-less localStorage: setItem should no-op");
|
||||
else {
|
||||
storageRemove("calliope-lang");
|
||||
ok(
|
||||
"method-less localStorage (Node SSR shape) degrades to null without throwing",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`method-less localStorage threw: ${err.message}`);
|
||||
fail(`method-less localStorage threw: ${err.message}`);
|
||||
}
|
||||
|
||||
// Absent entirely (the common server case).
|
||||
setGlobalStorage(undefined);
|
||||
try {
|
||||
if (storageGet('k') !== null) fail('undefined localStorage: expected null');
|
||||
else ok('absent localStorage degrades to null');
|
||||
if (storageGet("k") !== null) fail("undefined localStorage: expected null");
|
||||
else ok("absent localStorage degrades to null");
|
||||
} catch (err) {
|
||||
fail(`undefined localStorage threw: ${err.message}`);
|
||||
fail(`undefined localStorage threw: ${err.message}`);
|
||||
}
|
||||
|
||||
// Access itself can throw (sandboxed frame / blocked third-party storage).
|
||||
setGlobalStorage(
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('SecurityError');
|
||||
},
|
||||
},
|
||||
),
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("SecurityError");
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
try {
|
||||
if (storageGet('k') !== null) fail('throwing localStorage: expected null');
|
||||
else ok('throwing localStorage access degrades to null');
|
||||
if (storageGet("k") !== null) fail("throwing localStorage: expected null");
|
||||
else ok("throwing localStorage access degrades to null");
|
||||
} catch (err) {
|
||||
fail(`throwing localStorage access threw: ${err.message}`);
|
||||
fail(`throwing localStorage access threw: ${err.message}`);
|
||||
}
|
||||
|
||||
// Quota / private-mode errors on write must not escape.
|
||||
setGlobalStorage({
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error('QuotaExceededError');
|
||||
},
|
||||
removeItem: () => {},
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error("QuotaExceededError");
|
||||
},
|
||||
removeItem: () => {},
|
||||
});
|
||||
try {
|
||||
storageSet('k', 'v');
|
||||
ok('setItem failure is swallowed');
|
||||
storageSet("k", "v");
|
||||
ok("setItem failure is swallowed");
|
||||
} catch (err) {
|
||||
fail(`setItem failure escaped: ${err.message}`);
|
||||
fail(`setItem failure escaped: ${err.message}`);
|
||||
}
|
||||
|
||||
// A real browser Storage must still work — guards against a helper that just
|
||||
// returns null unconditionally to satisfy the checks above.
|
||||
setGlobalStorage(fakeStorage());
|
||||
try {
|
||||
storageSet('calliope-lang', 'ko');
|
||||
const read = storageGet('calliope-lang');
|
||||
if (read !== 'ko') fail(`working storage round-trip: expected "ko", got ${JSON.stringify(read)}`);
|
||||
else {
|
||||
storageRemove('calliope-lang');
|
||||
if (storageGet('calliope-lang') !== null) fail('working storage remove: expected null');
|
||||
else ok('working storage round-trips and removes');
|
||||
}
|
||||
storageSet("calliope-lang", "ko");
|
||||
const read = storageGet("calliope-lang");
|
||||
if (read !== "ko")
|
||||
fail(
|
||||
`working storage round-trip: expected "ko", got ${JSON.stringify(read)}`,
|
||||
);
|
||||
else {
|
||||
storageRemove("calliope-lang");
|
||||
if (storageGet("calliope-lang") !== null)
|
||||
fail("working storage remove: expected null");
|
||||
else ok("working storage round-trips and removes");
|
||||
}
|
||||
} catch (err) {
|
||||
fail(`working storage threw: ${err.message}`);
|
||||
fail(`working storage threw: ${err.message}`);
|
||||
}
|
||||
|
||||
// ---- 2. static ---------------------------------------------------------
|
||||
|
||||
const UNSAFE = /typeof\s+localStorage/;
|
||||
const EXTENSIONS = ['.ts', '.js', '.svelte'];
|
||||
const EXTENSIONS = [".ts", ".js", ".svelte"];
|
||||
|
||||
function walk(dir) {
|
||||
const found = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules') continue;
|
||||
found.push(...walk(full));
|
||||
} else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
|
||||
found.push(full);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
const found = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules") continue;
|
||||
found.push(...walk(full));
|
||||
} else if (EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
|
||||
found.push(full);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function exists(p) {
|
||||
try {
|
||||
return statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const offenders = [];
|
||||
for (const file of walk(SRC)) {
|
||||
if (file === HELPER) continue; // the one place allowed to probe the global
|
||||
const lines = readFileSync(file, 'utf8').split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
if (UNSAFE.test(line)) offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
|
||||
});
|
||||
if (file === HELPER) continue; // the one place allowed to probe the global
|
||||
const lines = readFileSync(file, "utf8").split("\n");
|
||||
lines.forEach((line, i) => {
|
||||
if (UNSAFE.test(line))
|
||||
offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (offenders.length) {
|
||||
fail(
|
||||
`unsafe \`typeof localStorage\` guard outside lib/storage.ts — use storageGet/storageSet instead:\n ${offenders.join('\n ')}`,
|
||||
);
|
||||
fail(
|
||||
`unsafe \`typeof localStorage\` guard outside lib/storage.ts — use storageGet/storageSet instead:\n ${offenders.join("\n ")}`,
|
||||
);
|
||||
} else {
|
||||
ok('no unsafe `typeof localStorage` guards in src/');
|
||||
ok("no unsafe `typeof localStorage` guards in src/");
|
||||
}
|
||||
|
||||
if (!exists(HELPER)) fail('src/lib/storage.ts is missing');
|
||||
if (!exists(HELPER)) fail("src/lib/storage.ts is missing");
|
||||
|
||||
// ---- result ------------------------------------------------------------
|
||||
|
||||
if (failures) {
|
||||
console.error(`\n${failures} storage/SSR check(s) failed.`);
|
||||
process.exit(1);
|
||||
console.error(`\n${failures} storage/SSR check(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nstorage SSR checks passed.');
|
||||
console.log("\nstorage SSR checks passed.");
|
||||
|
||||
+29
-18
@@ -1,19 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>影工坊</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script>
|
||||
(function () {
|
||||
var saved;
|
||||
try {
|
||||
saved = localStorage.getItem("aigc-theme");
|
||||
} catch (_) {}
|
||||
document.documentElement.dataset.theme =
|
||||
/^(night|day|grade|bluehour)$/.test(saved || "") ? saved : "night";
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/themes.css" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>影工坊</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { HandleClientError } from '@sveltejs/kit';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import type { HandleClientError } from "@sveltejs/kit";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
export const handleError: HandleClientError = ({ error }) => {
|
||||
console.error(error);
|
||||
return {
|
||||
message: t('global.somethingWentWrong'),
|
||||
};
|
||||
console.error(error);
|
||||
return {
|
||||
message: t("global.somethingWentWrong"),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export async function handle({ event, resolve }) {
|
||||
const response = await resolve(event);
|
||||
return response;
|
||||
const response = await resolve(event);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Action } from "svelte/action";
|
||||
|
||||
/** A local honeycomb highlight follows the pointer, independent of selected state. */
|
||||
export const spotlight: Action<HTMLElement> = (node) => {
|
||||
const move = (event: PointerEvent) => {
|
||||
if (
|
||||
document.documentElement.dataset.reduce === "1" ||
|
||||
event.pointerType === "touch"
|
||||
)
|
||||
return;
|
||||
const rect = node.getBoundingClientRect();
|
||||
node.style.setProperty("--pointer-x", `${event.clientX - rect.left}px`);
|
||||
node.style.setProperty("--pointer-y", `${event.clientY - rect.top}px`);
|
||||
};
|
||||
node.addEventListener("pointermove", move);
|
||||
return { destroy: () => node.removeEventListener("pointermove", move) };
|
||||
};
|
||||
@@ -2,50 +2,50 @@
|
||||
* Shared types and helpers for the Agent composer (@workflow mentions + attachments).
|
||||
*/
|
||||
|
||||
export type WorkflowKind = 'image' | 'video';
|
||||
export type AttachmentKind = 'image' | 'video' | 'audio' | 'document';
|
||||
export type WorkflowKind = "image" | "video";
|
||||
export type AttachmentKind = "image" | "video" | "audio" | "document";
|
||||
|
||||
export interface WorkflowMention {
|
||||
type: 'workflow';
|
||||
id: number;
|
||||
name: string;
|
||||
kind: WorkflowKind;
|
||||
type: "workflow";
|
||||
id: number;
|
||||
name: string;
|
||||
kind: WorkflowKind;
|
||||
}
|
||||
|
||||
export interface AgentAttachment {
|
||||
path: string;
|
||||
name: string;
|
||||
kind: AttachmentKind;
|
||||
path: string;
|
||||
name: string;
|
||||
kind: AttachmentKind;
|
||||
}
|
||||
|
||||
export interface AgentComposerPayload {
|
||||
content: string;
|
||||
mentions: (WorkflowMention | SkillMention)[];
|
||||
attachments: AgentAttachment[];
|
||||
/** question/asked seq this message answers (question-card click). */
|
||||
answer_to?: number;
|
||||
content: string;
|
||||
mentions: (WorkflowMention | SkillMention)[];
|
||||
attachments: AgentAttachment[];
|
||||
/** question/asked seq this message answers (question-card click). */
|
||||
answer_to?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowOption {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: WorkflowKind;
|
||||
description?: string | null;
|
||||
is_enabled?: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
kind: WorkflowKind;
|
||||
description?: string | null;
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SkillOption {
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/** A `/skill` command inserted as a chip. Serialized into the message's
|
||||
* mentions payload so the backend can project it into the LLM context. */
|
||||
export interface SkillMention {
|
||||
type: 'skill';
|
||||
name: string;
|
||||
description: string;
|
||||
type: "skill";
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const MAX_WORKFLOW_MENTIONS = 1;
|
||||
@@ -53,27 +53,36 @@ export const MAX_WORKFLOW_MENTIONS = 1;
|
||||
export const MENTION_LIMIT = 12;
|
||||
|
||||
/** Case-insensitive name filter of enabled workflows, capped for the typeahead. */
|
||||
export function filterWorkflows(workflows: WorkflowOption[], query: string): WorkflowOption[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
const enabled = workflows.filter((w) => w.is_enabled !== false);
|
||||
const matched = q
|
||||
? enabled.filter((w) => w.name.toLowerCase().includes(q))
|
||||
: enabled;
|
||||
return matched.slice(0, MENTION_LIMIT);
|
||||
export function filterWorkflows(
|
||||
workflows: WorkflowOption[],
|
||||
query: string,
|
||||
): WorkflowOption[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
const enabled = workflows.filter((w) => w.is_enabled !== false);
|
||||
const matched = q
|
||||
? enabled.filter((w) => w.name.toLowerCase().includes(q))
|
||||
: enabled;
|
||||
return matched.slice(0, MENTION_LIMIT);
|
||||
}
|
||||
|
||||
/** Case-insensitive filter of skills for the `/` typeahead (name + description). */
|
||||
export function filterSkills(skills: SkillOption[], query: string): SkillOption[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matched = q
|
||||
? skills.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
|
||||
)
|
||||
: skills;
|
||||
return matched.slice(0, MENTION_LIMIT);
|
||||
export function filterSkills(
|
||||
skills: SkillOption[],
|
||||
query: string,
|
||||
): SkillOption[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matched = q
|
||||
? skills.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.description.toLowerCase().includes(q),
|
||||
)
|
||||
: skills;
|
||||
return matched.slice(0, MENTION_LIMIT);
|
||||
}
|
||||
|
||||
export function payloadIsEmpty(p: AgentComposerPayload): boolean {
|
||||
return !p.content.trim() && p.mentions.length === 0 && p.attachments.length === 0;
|
||||
return (
|
||||
!p.content.trim() && p.mentions.length === 0 && p.attachments.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,28 +7,30 @@
|
||||
* buttons stay dumb and the wording never drifts out of sync.
|
||||
*/
|
||||
|
||||
export type AgentTaskKind = 'story' | 'script';
|
||||
export type AgentTaskKind = "story" | "script";
|
||||
|
||||
/** The pre-filled composer prompt for each task. */
|
||||
export const AGENT_TASK_PROMPTS: Record<AgentTaskKind, string> = {
|
||||
story:
|
||||
'Draft the storyline for this project — generate the beats, characters, environments, and any misc. items from the story idea.',
|
||||
script:
|
||||
'Regenerate the full script for this project — turn the storyline into ordered scenes with action and dialogue.',
|
||||
story:
|
||||
"Draft the storyline for this project — generate the beats, characters, environments, and any misc. items from the story idea.",
|
||||
script:
|
||||
"Regenerate the full script for this project — turn the storyline into ordered scenes with action and dialogue.",
|
||||
};
|
||||
|
||||
/** i18n key for the auto-created session's sidebar title. */
|
||||
export const AGENT_TASK_TITLE_KEYS: Record<AgentTaskKind, string> = {
|
||||
story: 'agent.task.story',
|
||||
script: 'agent.task.script',
|
||||
story: "agent.task.story",
|
||||
script: "agent.task.script",
|
||||
};
|
||||
|
||||
export function isAgentTaskKind(value: string | null | undefined): value is AgentTaskKind {
|
||||
return value === 'story' || value === 'script';
|
||||
export function isAgentTaskKind(
|
||||
value: string | null | undefined,
|
||||
): value is AgentTaskKind {
|
||||
return value === "story" || value === "script";
|
||||
}
|
||||
|
||||
/** Build the /agents deep-link URL for a project-scoped task. */
|
||||
export function agentDeepLink(projectId: number, task: AgentTaskKind): string {
|
||||
const params = new URLSearchParams({ project: String(projectId), task });
|
||||
return `/agents?${params.toString()}`;
|
||||
const params = new URLSearchParams({ project: String(projectId), task });
|
||||
return `/agents?${params.toString()}`;
|
||||
}
|
||||
|
||||
+210
-8
@@ -133,8 +133,18 @@ import type {
|
||||
SceneVideoSettings,
|
||||
Clip,
|
||||
Workflow,
|
||||
WorkflowUsage,
|
||||
WorkflowAnalyzeResult,
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
WorkflowMeta,
|
||||
WorkflowUIFileInput,
|
||||
WorkflowConvertFileReport,
|
||||
WorkflowConvertReport,
|
||||
WorkflowImportResult,
|
||||
} from "./comfy/types";
|
||||
export type {
|
||||
Job,
|
||||
@@ -142,8 +152,18 @@ export type {
|
||||
SceneVideoSettings,
|
||||
Clip,
|
||||
Workflow,
|
||||
WorkflowUsage,
|
||||
WorkflowAnalyzeResult,
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
WorkflowMeta,
|
||||
WorkflowUIFileInput,
|
||||
WorkflowConvertFileReport,
|
||||
WorkflowConvertReport,
|
||||
WorkflowImportResult,
|
||||
};
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -452,21 +472,103 @@ export const comfy = {
|
||||
|
||||
export const workflows = {
|
||||
list: () => api<Workflow[]>("/api/workflows"),
|
||||
analyze: (workflow_json: Record<string, unknown>) =>
|
||||
api<{
|
||||
inputs: ComfyDynamicInput[];
|
||||
outputs: ComfyDynamicOutput[];
|
||||
suggested_profile?: string;
|
||||
}>("/api/workflows/analyze", {
|
||||
/** 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({ workflow_json }),
|
||||
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,
|
||||
refresh = false,
|
||||
) =>
|
||||
api<WorkflowAnalyzeResult>("/api/workflows/analyze", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
workflow_json,
|
||||
...(endpoint_id ? { endpoint_id } : {}),
|
||||
...(refresh ? { refresh } : {}),
|
||||
}),
|
||||
}),
|
||||
reanalyze: (id: number) =>
|
||||
api<Workflow>(`/api/workflows/${id}/reanalyze`, { method: "POST" }),
|
||||
/** L2 re-check against a live ComfyUI (HEL-643): node/model availability. */
|
||||
check: (
|
||||
id: number,
|
||||
payload?: { endpoint_id?: string | null; refresh?: boolean },
|
||||
) =>
|
||||
api<WorkflowConnectionCheck>(`/api/workflows/${id}/check`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
}),
|
||||
/** Launch (or re-attach to) one REAL test run (HEL-643 L3). */
|
||||
test: (
|
||||
id: number,
|
||||
payload: {
|
||||
endpoint_id?: string | null;
|
||||
input_values?: Record<string, unknown>;
|
||||
},
|
||||
) =>
|
||||
api<{ ok: boolean; job: Job; reused: boolean }>(
|
||||
`/api/workflows/${id}/test`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
),
|
||||
tests: (id: number) => api<Job[]>(`/api/workflows/${id}/tests`),
|
||||
clearTests: (id: number) =>
|
||||
api<{ ok: boolean; removed: number }>(`/api/workflows/${id}/clear-tests`, {
|
||||
method: "POST",
|
||||
}),
|
||||
create: (payload: {
|
||||
name: string;
|
||||
kind: "image" | "video";
|
||||
workflow_json: Record<string, unknown>;
|
||||
kind?: "image" | "video";
|
||||
usage?: WorkflowUsage;
|
||||
usage_source?: "auto" | "user";
|
||||
input_schema?: ComfyDynamicInput[];
|
||||
output_map?: WorkflowOutputMapEntry[];
|
||||
output_map_source?: "auto" | "user";
|
||||
description?: string;
|
||||
prompt_profile?: string;
|
||||
endpoint_id?: string | null;
|
||||
@@ -484,6 +586,15 @@ export const workflows = {
|
||||
description: string;
|
||||
prompt_profile: string;
|
||||
endpoint_id: string | null;
|
||||
usage: WorkflowUsage;
|
||||
usage_source: "auto" | "user";
|
||||
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}`, {
|
||||
@@ -585,6 +696,15 @@ export const playgroundApi = {
|
||||
endpoint_id?: string | null;
|
||||
/** Origin recorded on the job payload (tasks page labels). */
|
||||
source?: "playground" | "studio-image" | "studio-video";
|
||||
/** Optional real workbench source (HEL-684) — validated server-side and
|
||||
* stamped on the job so finished takes can flow back to their row. */
|
||||
context?: {
|
||||
project_id: number;
|
||||
stage: "assets" | "shot";
|
||||
source_kind: "asset" | "shot";
|
||||
source_id: string;
|
||||
label?: string;
|
||||
};
|
||||
}) =>
|
||||
api<{ ok: boolean; job: Job }>("/api/playground/generate", {
|
||||
method: "POST",
|
||||
@@ -602,7 +722,24 @@ export const playgroundApi = {
|
||||
job_id: number;
|
||||
deleted_files: string[];
|
||||
missing_files: string[];
|
||||
removed_workbench_links?: number[];
|
||||
}>(`/api/playground/jobs/${jobId}`, { method: "DELETE" }),
|
||||
/** Flow a finished take back to the workbench row it came from (HEL-684).
|
||||
* The server derives the target from the job's own recorded context —
|
||||
* idempotent, never overwrites, refuses stale source rows. */
|
||||
attachWorkbench: (jobId: number) =>
|
||||
api<{
|
||||
ok: boolean;
|
||||
already_attached: boolean;
|
||||
job_id: number;
|
||||
attached_job_id: number;
|
||||
project_id: number;
|
||||
stage: "assets" | "shot";
|
||||
target_id: string;
|
||||
}>("/api/playground/attach-workbench", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ job_id: jobId }),
|
||||
}),
|
||||
attach: (payload: {
|
||||
path: string;
|
||||
project_id: number;
|
||||
@@ -697,6 +834,8 @@ export interface VideoShot {
|
||||
}
|
||||
|
||||
export interface WorkbenchStage {
|
||||
incoming_results?: IncomingResult[];
|
||||
assistant_note?: string;
|
||||
stage_key: WorkbenchStageKey;
|
||||
status: WorkbenchStageStatus;
|
||||
content: Record<string, unknown> | null;
|
||||
@@ -720,6 +859,11 @@ export interface WorkbenchOverview {
|
||||
}
|
||||
|
||||
export const workbenchApi = {
|
||||
saveNote: (projectId: number, stageKey: WorkbenchStageKey, text: string) =>
|
||||
api<{ ok: boolean; stage: WorkbenchStage }>(
|
||||
`/api/workbench/projects/${projectId}/stages/${stageKey}/note`,
|
||||
{ method: "PATCH", body: JSON.stringify({ text }) },
|
||||
),
|
||||
overview: (projectId: number) =>
|
||||
api<WorkbenchOverview>(`/api/workbench/projects/${projectId}`),
|
||||
patchStage: (
|
||||
@@ -811,7 +955,65 @@ export const libraryApi = {
|
||||
|
||||
// ── Agents (agentic harness) ────────────────────────────────────────────
|
||||
|
||||
export interface AgentDeliverable {
|
||||
id: number;
|
||||
session_id: number;
|
||||
source_message_id: number;
|
||||
project_id: number | null;
|
||||
stage_key: WorkbenchStageKey;
|
||||
title: string;
|
||||
content: string;
|
||||
revision: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export interface IncomingResult {
|
||||
source_key: string;
|
||||
title: string;
|
||||
text?: string;
|
||||
kind?: string;
|
||||
paths?: string[];
|
||||
session_id?: number;
|
||||
job_id?: number;
|
||||
}
|
||||
export const resultsApi = {
|
||||
versions: (id: number) =>
|
||||
api<{ revision: number; content: string }[]>(
|
||||
`/api/results/deliverables/${id}/versions`,
|
||||
),
|
||||
list: (sessionId?: number) =>
|
||||
api<AgentDeliverable[]>(
|
||||
`/api/results/deliverables${sessionId == null ? "" : `?session_id=${sessionId}`}`,
|
||||
),
|
||||
patch: (
|
||||
id: number,
|
||||
payload: {
|
||||
revision: number;
|
||||
content: string;
|
||||
project_id: number | null;
|
||||
stage_key: WorkbenchStageKey;
|
||||
},
|
||||
) =>
|
||||
api<AgentDeliverable>(`/api/results/deliverables/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
store: (payload: {
|
||||
project_id: number;
|
||||
stage_key: WorkbenchStageKey;
|
||||
deliverable_id?: number;
|
||||
revision?: number;
|
||||
job_id?: number;
|
||||
}) =>
|
||||
api<{ ok: boolean; already_attached: boolean; stage: WorkbenchStage }>(
|
||||
"/api/results/store",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
),
|
||||
};
|
||||
|
||||
export interface AgentSession {
|
||||
task_summary?: string;
|
||||
task_status?: string;
|
||||
id: number;
|
||||
project_id: number | null;
|
||||
title: string;
|
||||
|
||||
@@ -1,50 +1,60 @@
|
||||
/** Shared shape for Omni / video ref pickers. */
|
||||
export type AssetGroup = 'character' | 'location' | 'item' | 'upload' | 'clip' | 'shot';
|
||||
export type AssetGroup =
|
||||
"character" | "location" | "item" | "upload" | "clip" | "shot";
|
||||
|
||||
export interface AssetOption {
|
||||
label: string;
|
||||
path: string;
|
||||
kind?: 'image' | 'video' | 'audio';
|
||||
group?: AssetGroup;
|
||||
label: string;
|
||||
path: string;
|
||||
kind?: "image" | "video" | "audio";
|
||||
group?: AssetGroup;
|
||||
}
|
||||
|
||||
const GROUP_FROM_SUFFIX: Array<{ re: RegExp; group: AssetGroup }> = [
|
||||
{ re: /\s·\s*sheet$/i, group: 'character' },
|
||||
{ re: /\s·\s*environment$/i, group: 'location' },
|
||||
{ re: /\s·\s*item$/i, group: 'item' },
|
||||
{ re: /\s·\s*upload$/i, group: 'upload' },
|
||||
{ re: /\s·\s*blockout(\s+clip)?$/i, group: 'shot' },
|
||||
{ re: /^clip\s*#/i, group: 'clip' },
|
||||
{ re: /\s·\s*sheet$/i, group: "character" },
|
||||
{ re: /\s·\s*environment$/i, group: "location" },
|
||||
{ re: /\s·\s*item$/i, group: "item" },
|
||||
{ re: /\s·\s*upload$/i, group: "upload" },
|
||||
{ re: /\s·\s*blockout(\s+clip)?$/i, group: "shot" },
|
||||
{ re: /^clip\s*#/i, group: "clip" },
|
||||
];
|
||||
|
||||
export function assetGroup(opt: AssetOption): AssetGroup {
|
||||
if (opt.group) return opt.group;
|
||||
for (const { re, group } of GROUP_FROM_SUFFIX) {
|
||||
if (re.test(opt.label)) return group;
|
||||
}
|
||||
return 'upload';
|
||||
if (opt.group) return opt.group;
|
||||
for (const { re, group } of GROUP_FROM_SUFFIX) {
|
||||
if (re.test(opt.label)) return group;
|
||||
}
|
||||
return "upload";
|
||||
}
|
||||
|
||||
/** Strip the " · sheet" style suffix so the tab already names the type. */
|
||||
export function assetDisplayName(opt: AssetOption): string {
|
||||
return opt.label.replace(/\s·\s*(sheet|environment|item|upload|blockout(\s+clip)?)\s*$/i, '').trim() || opt.label;
|
||||
return (
|
||||
opt.label
|
||||
.replace(
|
||||
/\s·\s*(sheet|environment|item|upload|blockout(\s+clip)?)\s*$/i,
|
||||
"",
|
||||
)
|
||||
.trim() || opt.label
|
||||
);
|
||||
}
|
||||
|
||||
export const ASSET_GROUP_TABS: Array<{ id: AssetGroup; label: string }> = [
|
||||
{ id: 'character', label: 'Characters' },
|
||||
{ id: 'location', label: 'Environments' },
|
||||
{ id: 'item', label: 'Misc. Items' },
|
||||
{ id: 'shot', label: 'From Build Scene' },
|
||||
{ id: 'clip', label: 'Clips' },
|
||||
{ id: 'upload', label: 'Uploads' },
|
||||
{ id: "character", label: "Characters" },
|
||||
{ id: "location", label: "Environments" },
|
||||
{ id: "item", label: "Misc. Items" },
|
||||
{ id: "shot", label: "From Build Scene" },
|
||||
{ id: "clip", label: "Clips" },
|
||||
{ id: "upload", label: "Uploads" },
|
||||
];
|
||||
|
||||
export function tabsForMediaKind(kind: string | undefined): Array<{ id: AssetGroup; label: string }> {
|
||||
const ids: AssetGroup[] =
|
||||
kind === 'video'
|
||||
? ['clip', 'upload']
|
||||
: kind === 'audio'
|
||||
? ['upload']
|
||||
: ['character', 'location', 'item', 'shot', 'upload'];
|
||||
return ASSET_GROUP_TABS.filter((t) => ids.includes(t.id));
|
||||
export function tabsForMediaKind(
|
||||
kind: string | undefined,
|
||||
): Array<{ id: AssetGroup; label: string }> {
|
||||
const ids: AssetGroup[] =
|
||||
kind === "video"
|
||||
? ["clip", "upload"]
|
||||
: kind === "audio"
|
||||
? ["upload"]
|
||||
: ["character", "location", "item", "shot", "upload"];
|
||||
return ASSET_GROUP_TABS.filter((t) => ids.includes(t.id));
|
||||
}
|
||||
|
||||
@@ -47,14 +47,12 @@
|
||||
</header>
|
||||
<div class="media">
|
||||
{#if videoSrc}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<button
|
||||
type="button"
|
||||
class="media-btn"
|
||||
onclick={openPreview}
|
||||
title={t('canvas.openPreview')}
|
||||
>
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
class="media-el"
|
||||
src={videoSrc}
|
||||
@@ -64,7 +62,6 @@
|
||||
></video>
|
||||
</button>
|
||||
{:else if mediaSrc}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<button
|
||||
type="button"
|
||||
class="media-btn"
|
||||
@@ -98,8 +95,8 @@
|
||||
position: relative;
|
||||
}
|
||||
.artifact-node.running {
|
||||
border-color: rgba(125, 211, 252, 0.6);
|
||||
box-shadow: 0 0 18px rgba(125, 211, 252, 0.25);
|
||||
border-color: var(--run);
|
||||
box-shadow: 0 0 18px var(--run-soft);
|
||||
}
|
||||
:global(.svelte-flow__node.selected) .artifact-node {
|
||||
border-color: var(--accent);
|
||||
@@ -117,9 +114,9 @@
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #bae6fd;
|
||||
background: rgba(12, 74, 110, 0.55);
|
||||
border: 1px solid rgba(125, 211, 252, 0.35);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 999px;
|
||||
padding: 2px 6px;
|
||||
flex-shrink: 0;
|
||||
@@ -141,12 +138,12 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.running-badge {
|
||||
color: #bae6fd;
|
||||
background: rgba(12, 74, 110, 0.6);
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.failed-badge {
|
||||
color: #fecaca;
|
||||
background: rgba(127, 29, 29, 0.5);
|
||||
color: var(--err);
|
||||
background: var(--err-soft);
|
||||
}
|
||||
.play-badge {
|
||||
font-size: 9px;
|
||||
@@ -205,9 +202,9 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: lowercase;
|
||||
color: var(--text-muted);
|
||||
color: var(--on-media);
|
||||
pointer-events: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
background: var(--media-scrim);
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
<SafeMedia class="media-el" src={posterSrc} alt={data.title} kind="image" controls={false} />
|
||||
{:else if videoSrc}
|
||||
<!-- No poster available: #t=0.1 first-frame thumbnail. -->
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
class="media-el"
|
||||
src={videoSrc}
|
||||
@@ -108,7 +107,7 @@
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.entity-node:hover {
|
||||
border-color: #3a3a44;
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
/* Svelte Flow marks the selected node wrapper with .selected */
|
||||
:global(.svelte-flow__node.selected) .entity-node {
|
||||
@@ -176,9 +175,9 @@
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: lowercase;
|
||||
color: var(--text-muted);
|
||||
color: var(--on-media);
|
||||
pointer-events: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
background: var(--media-scrim);
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
/* Canvas v2.1 scoped visual tokens (spec v2.0 §2) — canvas surfaces only.
|
||||
The global --accent stays untouched for v1 pages. */
|
||||
.canvas-root {
|
||||
--accent: #4f46a5;
|
||||
--accent-hover: #6157c9;
|
||||
--accent-glow: rgba(99, 102, 241, 0.3);
|
||||
--edge: #7dd3fc;
|
||||
/* Edge-chip surfaces (LinkEdge label) — fallbacks match the dark theme. */
|
||||
--panel: #17171f;
|
||||
--panel-border: #2a2a35;
|
||||
--fg: #f4f4f5;
|
||||
--fg-dim: #a1a1aa;
|
||||
}
|
||||
@@ -12,24 +12,25 @@
|
||||
* control → compact pills (resolution, duration, seed)
|
||||
* advanced → hidden inside an "Advanced" popover
|
||||
*/
|
||||
import type { ComfyDynamicInput } from './types';
|
||||
import { normalizeInputRole } from './parser';
|
||||
import type { ComfyDynamicInput } from "./types";
|
||||
import { normalizeInputRole } from "./parser";
|
||||
|
||||
export type InputZone = 'composer' | 'media' | 'control' | 'advanced' | 'hidden';
|
||||
export type InputZone =
|
||||
"composer" | "media" | "control" | "advanced" | "hidden";
|
||||
|
||||
export type OmniWidget =
|
||||
| 'promptTextarea'
|
||||
| 'negativeTextarea'
|
||||
| 'uploadTile'
|
||||
| 'assetTile'
|
||||
| 'resolutionPill'
|
||||
| 'numberPill'
|
||||
| 'textPill';
|
||||
| "promptTextarea"
|
||||
| "negativeTextarea"
|
||||
| "uploadTile"
|
||||
| "assetTile"
|
||||
| "resolutionPill"
|
||||
| "numberPill"
|
||||
| "textPill";
|
||||
|
||||
export interface ClassifiedInput {
|
||||
input: ComfyDynamicInput;
|
||||
zone: InputZone;
|
||||
widget: OmniWidget;
|
||||
input: ComfyDynamicInput;
|
||||
zone: InputZone;
|
||||
widget: OmniWidget;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,49 +40,49 @@ export interface ClassifiedInput {
|
||||
* zone. Unknown roles fall to `advanced` so no field is ever hidden or lost.
|
||||
*/
|
||||
export function classifyInput(inp: ComfyDynamicInput): ClassifiedInput {
|
||||
const role = normalizeInputRole(inp.role);
|
||||
const role = normalizeInputRole(inp.role);
|
||||
|
||||
// --- Composer ---
|
||||
if (role === 'prompt' || role === 'positive') {
|
||||
return { input: inp, zone: 'composer', widget: 'promptTextarea' };
|
||||
}
|
||||
if (role === 'negative') {
|
||||
return { input: inp, zone: 'composer', widget: 'negativeTextarea' };
|
||||
}
|
||||
// --- Composer ---
|
||||
if (role === "prompt" || role === "positive") {
|
||||
return { input: inp, zone: "composer", widget: "promptTextarea" };
|
||||
}
|
||||
if (role === "negative") {
|
||||
return { input: inp, zone: "composer", widget: "negativeTextarea" };
|
||||
}
|
||||
|
||||
// --- Media tray ---
|
||||
if (
|
||||
role === 'character' ||
|
||||
role === 'location' ||
|
||||
role === 'image' ||
|
||||
role === 'video' ||
|
||||
role === 'audio'
|
||||
) {
|
||||
const isMediaKind =
|
||||
inp.kind === 'image' ||
|
||||
inp.kind === 'image_url' ||
|
||||
inp.kind === 'audio' ||
|
||||
inp.kind === 'video';
|
||||
return {
|
||||
input: inp,
|
||||
zone: 'media',
|
||||
widget: isMediaKind ? 'uploadTile' : 'assetTile',
|
||||
};
|
||||
}
|
||||
// --- Media tray ---
|
||||
if (
|
||||
role === "character" ||
|
||||
role === "location" ||
|
||||
role === "image" ||
|
||||
role === "video" ||
|
||||
role === "audio"
|
||||
) {
|
||||
const isMediaKind =
|
||||
inp.kind === "image" ||
|
||||
inp.kind === "image_url" ||
|
||||
inp.kind === "audio" ||
|
||||
inp.kind === "video";
|
||||
return {
|
||||
input: inp,
|
||||
zone: "media",
|
||||
widget: isMediaKind ? "uploadTile" : "assetTile",
|
||||
};
|
||||
}
|
||||
|
||||
// --- Control bar ---
|
||||
if (role === 'width' || role === 'height') {
|
||||
return { input: inp, zone: 'control', widget: 'resolutionPill' };
|
||||
}
|
||||
if (role === 'duration' || role === 'seed') {
|
||||
return { input: inp, zone: 'control', widget: 'numberPill' };
|
||||
}
|
||||
// --- Control bar ---
|
||||
if (role === "width" || role === "height") {
|
||||
return { input: inp, zone: "control", widget: "resolutionPill" };
|
||||
}
|
||||
if (role === "duration" || role === "seed") {
|
||||
return { input: inp, zone: "control", widget: "numberPill" };
|
||||
}
|
||||
|
||||
// --- Advanced popover ---
|
||||
if (inp.kind === 'number') {
|
||||
return { input: inp, zone: 'advanced', widget: 'numberPill' };
|
||||
}
|
||||
return { input: inp, zone: 'advanced', widget: 'textPill' };
|
||||
// --- Advanced popover ---
|
||||
if (inp.kind === "number") {
|
||||
return { input: inp, zone: "advanced", widget: "numberPill" };
|
||||
}
|
||||
return { input: inp, zone: "advanced", widget: "textPill" };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,57 +93,62 @@ export function classifyInput(inp: ComfyDynamicInput): ClassifiedInput {
|
||||
* nodeIds on change.
|
||||
*/
|
||||
export function classifyAll(inputs: ComfyDynamicInput[]): {
|
||||
prompt: ClassifiedInput | null;
|
||||
negative: ClassifiedInput | null;
|
||||
media: ClassifiedInput[];
|
||||
control: ClassifiedInput[];
|
||||
advanced: ClassifiedInput[];
|
||||
/** Non-null when both width AND height are present → merge into one pill. */
|
||||
resolutionPair: { width: ClassifiedInput; height: ClassifiedInput } | null;
|
||||
prompt: ClassifiedInput | null;
|
||||
negative: ClassifiedInput | null;
|
||||
media: ClassifiedInput[];
|
||||
control: ClassifiedInput[];
|
||||
advanced: ClassifiedInput[];
|
||||
/** Non-null when both width AND height are present → merge into one pill. */
|
||||
resolutionPair: { width: ClassifiedInput; height: ClassifiedInput } | null;
|
||||
} {
|
||||
const classified = inputs.map(classifyInput);
|
||||
const classified = inputs.map(classifyInput);
|
||||
|
||||
const prompt = classified.find((c) => c.widget === 'promptTextarea') ?? null;
|
||||
const negative = classified.find((c) => c.widget === 'negativeTextarea') ?? null;
|
||||
const media = classified.filter((c) => c.zone === 'media');
|
||||
const advanced = classified.filter((c) => c.zone === 'advanced');
|
||||
const prompt = classified.find((c) => c.widget === "promptTextarea") ?? null;
|
||||
const negative =
|
||||
classified.find((c) => c.widget === "negativeTextarea") ?? null;
|
||||
const media = classified.filter((c) => c.zone === "media");
|
||||
const advanced = classified.filter((c) => c.zone === "advanced");
|
||||
|
||||
// Separate resolution from other control inputs
|
||||
const controlNonRes = classified.filter(
|
||||
(c) => c.zone === 'control' && c.widget !== 'resolutionPill',
|
||||
);
|
||||
const resInputs = classified.filter((c) => c.widget === 'resolutionPill');
|
||||
// Separate resolution from other control inputs
|
||||
const controlNonRes = classified.filter(
|
||||
(c) => c.zone === "control" && c.widget !== "resolutionPill",
|
||||
);
|
||||
const resInputs = classified.filter((c) => c.widget === "resolutionPill");
|
||||
|
||||
// Detect width + height pair
|
||||
const wInput = resInputs.find((c) => normalizeInputRole(c.input.role) === 'width');
|
||||
const hInput = resInputs.find((c) => normalizeInputRole(c.input.role) === 'height');
|
||||
const resolutionPair =
|
||||
wInput && hInput ? { width: wInput, height: hInput } : null;
|
||||
// Detect width + height pair
|
||||
const wInput = resInputs.find(
|
||||
(c) => normalizeInputRole(c.input.role) === "width",
|
||||
);
|
||||
const hInput = resInputs.find(
|
||||
(c) => normalizeInputRole(c.input.role) === "height",
|
||||
);
|
||||
const resolutionPair =
|
||||
wInput && hInput ? { width: wInput, height: hInput } : null;
|
||||
|
||||
// If only one of width/height exists, treat it as a standalone control number pill
|
||||
const standaloneRes = resolutionPair ? [] : resInputs;
|
||||
// If only one of width/height exists, treat it as a standalone control number pill
|
||||
const standaloneRes = resolutionPair ? [] : resInputs;
|
||||
|
||||
return {
|
||||
prompt,
|
||||
negative,
|
||||
media,
|
||||
control: [...controlNonRes, ...standaloneRes],
|
||||
advanced,
|
||||
resolutionPair,
|
||||
};
|
||||
return {
|
||||
prompt,
|
||||
negative,
|
||||
media,
|
||||
control: [...controlNonRes, ...standaloneRes],
|
||||
advanced,
|
||||
resolutionPair,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Resolution presets ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ResolutionPreset {
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const RESOLUTION_PRESETS: ResolutionPreset[] = [
|
||||
{ label: '720p', width: 1280, height: 720 },
|
||||
{ label: '1080p', width: 1920, height: 1080 },
|
||||
{ label: "720p", width: 1280, height: 720 },
|
||||
{ label: "1080p", width: 1920, height: 1080 },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -150,12 +156,12 @@ export const RESOLUTION_PRESETS: ResolutionPreset[] = [
|
||||
* (or return null for custom / unset values).
|
||||
*/
|
||||
export function resolutionLabel(
|
||||
width: number | string | undefined,
|
||||
height: number | string | undefined,
|
||||
width: number | string | undefined,
|
||||
height: number | string | undefined,
|
||||
): string | null {
|
||||
const w = Number(width);
|
||||
const h = Number(height);
|
||||
if (!w || !h) return null;
|
||||
const match = RESOLUTION_PRESETS.find((p) => p.width === w && p.height === h);
|
||||
return match?.label ?? `${w}×${h}`;
|
||||
const w = Number(width);
|
||||
const h = Number(height);
|
||||
if (!w || !h) return null;
|
||||
const match = RESOLUTION_PRESETS.find((p) => p.width === w && p.height === h);
|
||||
return match?.label ?? `${w}×${h}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
/** Studio image slots (ref-upload-spec §3): PNG / JPG / WebP, ≤ 20MB each. */
|
||||
export const IMAGE_SLOT_ACCEPT =
|
||||
".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp";
|
||||
export const IMAGE_SLOT_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp"];
|
||||
|
||||
/** Reference video slots (HEL-643): MP4 / WebM / MOV, ≤ 500MB (upload cap). */
|
||||
export const VIDEO_SLOT_ACCEPT =
|
||||
"video/mp4,video/webm,video/quicktime,.mp4,.webm,.mov";
|
||||
export const VIDEO_SLOT_MAX_BYTES = 500 * 1024 * 1024;
|
||||
const VIDEO_EXTS = [".mp4", ".webm", ".mov"];
|
||||
|
||||
/** Reference audio slots (HEL-643): MP3 / WAV / M4A, ≤ 100MB. */
|
||||
export const AUDIO_SLOT_ACCEPT =
|
||||
"audio/mpeg,audio/wav,audio/mp4,.mp3,.wav,.m4a";
|
||||
export const AUDIO_SLOT_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const AUDIO_EXTS = [".mp3", ".wav", ".m4a"];
|
||||
|
||||
export type MediaSlotKind = "image" | "video" | "audio";
|
||||
|
||||
export function mediaSlotAccept(kind: MediaSlotKind): string {
|
||||
if (kind === "video") return VIDEO_SLOT_ACCEPT;
|
||||
if (kind === "audio") return AUDIO_SLOT_ACCEPT;
|
||||
return IMAGE_SLOT_ACCEPT;
|
||||
}
|
||||
|
||||
/** Why this file cannot fill a media slot of this kind (null = fine). */
|
||||
export function mediaFileProblem(
|
||||
kind: MediaSlotKind,
|
||||
file: { name: string; size: number },
|
||||
): string | null {
|
||||
const dot = file.name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : "";
|
||||
const max =
|
||||
kind === "video"
|
||||
? VIDEO_SLOT_MAX_BYTES
|
||||
: kind === "audio"
|
||||
? AUDIO_SLOT_MAX_BYTES
|
||||
: IMAGE_SLOT_MAX_BYTES;
|
||||
if (kind === "video" && !VIDEO_EXTS.includes(ext))
|
||||
return t("studio.errVideoFormat", { name: file.name });
|
||||
if (kind === "audio" && !AUDIO_EXTS.includes(ext))
|
||||
return t("studio.errAudioFormat", { name: file.name });
|
||||
if (kind === "image" && !IMAGE_EXTS.includes(ext))
|
||||
return t("studio.errImageFormat", { name: file.name });
|
||||
if (file.size > max)
|
||||
return kind === "image"
|
||||
? t("studio.errImageSize", { name: file.name })
|
||||
: t("studio.errMediaSize", { name: file.name });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Why this file cannot fill an image slot (null = fine). */
|
||||
export function imageFileProblem(file: {
|
||||
name: string;
|
||||
size: number;
|
||||
}): string | null {
|
||||
return mediaFileProblem("image", file);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
/** Display name of an uploaded path (strips the uuid8- prefix the upload endpoint writes). */
|
||||
export function uploadBaseName(p: string): string {
|
||||
const raw = p.split(/[/\\]/).pop() ?? p;
|
||||
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
|
||||
}
|
||||
@@ -1,142 +1,192 @@
|
||||
import type { ComfyDynamicInput, ComfyDynamicOutput, WorkflowNode } from './types';
|
||||
import type {
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
WorkflowNode,
|
||||
} from "./types";
|
||||
|
||||
const TEXT_AREA = new Set([
|
||||
'CLIPTextEncode',
|
||||
'Note',
|
||||
'ShowText',
|
||||
'ImpactWildcardProcessor',
|
||||
'PrimitiveStringMultiline',
|
||||
'PrimitiveString',
|
||||
"CLIPTextEncode",
|
||||
"Note",
|
||||
"ShowText",
|
||||
"ImpactWildcardProcessor",
|
||||
"PrimitiveStringMultiline",
|
||||
"PrimitiveString",
|
||||
]);
|
||||
const NUMBER = new Set([
|
||||
"INT",
|
||||
"FLOAT",
|
||||
"PrimitiveInt",
|
||||
"PrimitiveFloat",
|
||||
"KSampler",
|
||||
"KSamplerAdvanced",
|
||||
]);
|
||||
const IMAGE = new Set([
|
||||
"LoadImage",
|
||||
"LoadImageMask",
|
||||
"ImageLoader",
|
||||
"ETN_LoadImageBase64",
|
||||
]);
|
||||
const IMAGE_URL = new Set(["Load Image From Url (mtb)"]);
|
||||
const AUDIO = new Set(["LoadAudio", "VHS_LoadAudio"]);
|
||||
const VIDEO = new Set(["LoadVideo", "VHS_LoadVideo", "VHS_LoadVideoPath"]);
|
||||
const VIDEO_OUT = new Set([
|
||||
"VHS_VideoCombine",
|
||||
"SaveVideo",
|
||||
"VideoOutput",
|
||||
"AnimateDiffCombine",
|
||||
]);
|
||||
const IMAGE_OUT = new Set([
|
||||
"SaveImage",
|
||||
"PreviewImage",
|
||||
"SaveImageWebsocket",
|
||||
"ETN_SendImageWebSocket",
|
||||
]);
|
||||
const NUMBER = new Set(['INT', 'FLOAT', 'PrimitiveInt', 'PrimitiveFloat', 'KSampler', 'KSamplerAdvanced']);
|
||||
const IMAGE = new Set(['LoadImage', 'LoadImageMask', 'ImageLoader', 'ETN_LoadImageBase64']);
|
||||
const IMAGE_URL = new Set(['Load Image From Url (mtb)']);
|
||||
const AUDIO = new Set(['LoadAudio', 'VHS_LoadAudio']);
|
||||
const VIDEO = new Set(['LoadVideo', 'VHS_LoadVideo', 'VHS_LoadVideoPath']);
|
||||
const VIDEO_OUT = new Set(['VHS_VideoCombine', 'SaveVideo', 'VideoOutput', 'AnimateDiffCombine']);
|
||||
const IMAGE_OUT = new Set(['SaveImage', 'PreviewImage', 'SaveImageWebsocket', 'ETN_SendImageWebSocket']);
|
||||
|
||||
/** Matches (Input), (Input:prompt), (Output:image), … */
|
||||
const TITLE_TAG_RE = /\((Input|Output)(?::([a-zA-Z0-9_-]+))?\)/i;
|
||||
|
||||
const INPUT_ROLE_ALIASES: Record<string, string[]> = {
|
||||
prompt: ['prompt', 'positive'],
|
||||
negative: ['negative', 'neg'],
|
||||
width: ['width', 'w'],
|
||||
height: ['height', 'h'],
|
||||
character: ['character', 'char', 'portrait', 'sheet', 'face', 'ref'],
|
||||
location: ['location', 'loc', 'environment', 'env', 'background', 'scene'],
|
||||
image: ['image', 'img'],
|
||||
video: ['video', 'vid'],
|
||||
audio: ['audio', 'sound', 'sfx'],
|
||||
seed: ['seed'],
|
||||
duration: ['duration', 'dur', 'length', 'seconds'],
|
||||
prompt: ["prompt", "positive"],
|
||||
negative: ["negative", "neg"],
|
||||
width: ["width", "w"],
|
||||
height: ["height", "h"],
|
||||
character: ["character", "char", "portrait", "sheet", "face", "ref"],
|
||||
location: ["location", "loc", "environment", "env", "background", "scene"],
|
||||
image: ["image", "img"],
|
||||
video: ["video", "vid"],
|
||||
audio: ["audio", "sound", "sfx"],
|
||||
seed: ["seed"],
|
||||
duration: ["duration", "dur", "length", "seconds"],
|
||||
};
|
||||
|
||||
const OUTPUT_ROLE_ALIASES: Record<string, string[]> = {
|
||||
image: ['image', 'img'],
|
||||
video: ['video', 'vid'],
|
||||
image: ["image", "img"],
|
||||
video: ["video", "vid"],
|
||||
};
|
||||
|
||||
export function parseTitleTag(title: string): {
|
||||
kind: 'input' | 'output' | null;
|
||||
role: string | null;
|
||||
label: string;
|
||||
kind: "input" | "output" | null;
|
||||
role: string | null;
|
||||
label: string;
|
||||
} {
|
||||
if (!title) return { kind: null, role: null, label: '' };
|
||||
const match = TITLE_TAG_RE.exec(title);
|
||||
if (!match) return { kind: null, role: null, label: title.trim() };
|
||||
const kind = match[1].toLowerCase() as 'input' | 'output';
|
||||
const role = match[2] ? match[2].toLowerCase() : null;
|
||||
const stripped = title.replace(TITLE_TAG_RE, '').trim();
|
||||
const label = stripped || role || kind;
|
||||
return { kind, role, label };
|
||||
if (!title) return { kind: null, role: null, label: "" };
|
||||
const match = TITLE_TAG_RE.exec(title);
|
||||
if (!match) return { kind: null, role: null, label: title.trim() };
|
||||
const kind = match[1].toLowerCase() as "input" | "output";
|
||||
const role = match[2] ? match[2].toLowerCase() : null;
|
||||
const stripped = title.replace(TITLE_TAG_RE, "").trim();
|
||||
const label = stripped || role || kind;
|
||||
return { kind, role, label };
|
||||
}
|
||||
|
||||
export function normalizeInputRole(role: string | null | undefined): string | null {
|
||||
if (!role) return null;
|
||||
const r = role.toLowerCase().trim();
|
||||
for (const [canonical, aliases] of Object.entries(INPUT_ROLE_ALIASES)) {
|
||||
if (r === canonical || aliases.includes(r)) return canonical;
|
||||
}
|
||||
return r;
|
||||
export function normalizeInputRole(
|
||||
role: string | null | undefined,
|
||||
): string | null {
|
||||
if (!role) return null;
|
||||
const r = role.toLowerCase().trim();
|
||||
for (const [canonical, aliases] of Object.entries(INPUT_ROLE_ALIASES)) {
|
||||
if (r === canonical || aliases.includes(r)) return canonical;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
export function normalizeOutputRole(role: string | null | undefined): string | null {
|
||||
if (!role) return null;
|
||||
const r = role.toLowerCase().trim();
|
||||
for (const [canonical, aliases] of Object.entries(OUTPUT_ROLE_ALIASES)) {
|
||||
if (r === canonical || aliases.includes(r)) return canonical;
|
||||
}
|
||||
return r;
|
||||
export function normalizeOutputRole(
|
||||
role: string | null | undefined,
|
||||
): string | null {
|
||||
if (!role) return null;
|
||||
const r = role.toLowerCase().trim();
|
||||
for (const [canonical, aliases] of Object.entries(OUTPUT_ROLE_ALIASES)) {
|
||||
if (r === canonical || aliases.includes(r)) return canonical;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
function classToInputKind(classType: string): ComfyDynamicInput['kind'] {
|
||||
if (IMAGE.has(classType)) return 'image';
|
||||
if (IMAGE_URL.has(classType)) return 'image_url';
|
||||
if (AUDIO.has(classType)) return 'audio';
|
||||
if (VIDEO.has(classType)) return 'video';
|
||||
if (NUMBER.has(classType)) return 'number';
|
||||
if (TEXT_AREA.has(classType)) return 'textarea';
|
||||
const lower = classType.toLowerCase();
|
||||
if (lower.includes('video')) return 'video';
|
||||
if (lower.includes('audio')) return 'audio';
|
||||
if (lower.includes('image') || lower.includes('load')) return 'image';
|
||||
if (lower.includes('int') || lower.includes('float') || lower.includes('seed')) return 'number';
|
||||
if (lower.includes('text') || lower.includes('clip') || lower.includes('prompt')) return 'textarea';
|
||||
return 'text';
|
||||
function classToInputKind(classType: string): ComfyDynamicInput["kind"] {
|
||||
if (IMAGE.has(classType)) return "image";
|
||||
if (IMAGE_URL.has(classType)) return "image_url";
|
||||
if (AUDIO.has(classType)) return "audio";
|
||||
if (VIDEO.has(classType)) return "video";
|
||||
if (NUMBER.has(classType)) return "number";
|
||||
if (TEXT_AREA.has(classType)) return "textarea";
|
||||
const lower = classType.toLowerCase();
|
||||
if (lower.includes("video")) return "video";
|
||||
if (lower.includes("audio")) return "audio";
|
||||
if (lower.includes("image") || lower.includes("load")) return "image";
|
||||
if (
|
||||
lower.includes("int") ||
|
||||
lower.includes("float") ||
|
||||
lower.includes("seed")
|
||||
)
|
||||
return "number";
|
||||
if (
|
||||
lower.includes("text") ||
|
||||
lower.includes("clip") ||
|
||||
lower.includes("prompt")
|
||||
)
|
||||
return "textarea";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function extractDefault(node: WorkflowNode): string | number | undefined {
|
||||
const { inputs, class_type } = node;
|
||||
if (IMAGE.has(class_type) || AUDIO.has(class_type) || VIDEO.has(class_type)) return undefined;
|
||||
if (typeof inputs.text === 'string') return inputs.text;
|
||||
if (typeof inputs.value === 'string' || typeof inputs.value === 'number') return inputs.value;
|
||||
if (typeof inputs.int === 'number') return inputs.int;
|
||||
if (typeof inputs.float === 'number') return inputs.float;
|
||||
return undefined;
|
||||
const { inputs, class_type } = node;
|
||||
if (IMAGE.has(class_type) || AUDIO.has(class_type) || VIDEO.has(class_type))
|
||||
return undefined;
|
||||
if (typeof inputs.text === "string") return inputs.text;
|
||||
if (typeof inputs.value === "string" || typeof inputs.value === "number")
|
||||
return inputs.value;
|
||||
if (typeof inputs.int === "number") return inputs.int;
|
||||
if (typeof inputs.float === "number") return inputs.float;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseDynamicInputs(workflow: Record<string, WorkflowNode>): ComfyDynamicInput[] {
|
||||
const results: ComfyDynamicInput[] = [];
|
||||
for (const [nodeId, node] of Object.entries(workflow)) {
|
||||
const title = node._meta?.title ?? '';
|
||||
const { kind, role, label } = parseTitleTag(title);
|
||||
if (kind !== 'input') continue;
|
||||
results.push({
|
||||
nodeId,
|
||||
label: label || node.class_type,
|
||||
role: normalizeInputRole(role),
|
||||
kind: classToInputKind(node.class_type),
|
||||
defaultValue: extractDefault(node),
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
export function parseDynamicInputs(
|
||||
workflow: Record<string, WorkflowNode>,
|
||||
): ComfyDynamicInput[] {
|
||||
const results: ComfyDynamicInput[] = [];
|
||||
for (const [nodeId, node] of Object.entries(workflow)) {
|
||||
const title = node._meta?.title ?? "";
|
||||
const { kind, role, label } = parseTitleTag(title);
|
||||
if (kind !== "input") continue;
|
||||
results.push({
|
||||
nodeId,
|
||||
label: label || node.class_type,
|
||||
role: normalizeInputRole(role),
|
||||
kind: classToInputKind(node.class_type),
|
||||
defaultValue: extractDefault(node),
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function parseDynamicOutputs(workflow: Record<string, WorkflowNode>): ComfyDynamicOutput[] {
|
||||
const results: ComfyDynamicOutput[] = [];
|
||||
for (const [nodeId, node] of Object.entries(workflow)) {
|
||||
const title = node._meta?.title ?? '';
|
||||
const { kind, role, label } = parseTitleTag(title);
|
||||
if (kind !== 'output') continue;
|
||||
const canon = normalizeOutputRole(role);
|
||||
let outKind: ComfyDynamicOutput['kind'] = 'other';
|
||||
if (canon === 'video' || VIDEO_OUT.has(node.class_type) || node.class_type.toLowerCase().includes('video'))
|
||||
outKind = 'video';
|
||||
else if (
|
||||
canon === 'image' ||
|
||||
IMAGE_OUT.has(node.class_type) ||
|
||||
node.class_type.toLowerCase().includes('image')
|
||||
)
|
||||
outKind = 'image';
|
||||
results.push({
|
||||
nodeId,
|
||||
label: label || node.class_type,
|
||||
role: canon,
|
||||
kind: outKind,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
export function parseDynamicOutputs(
|
||||
workflow: Record<string, WorkflowNode>,
|
||||
): ComfyDynamicOutput[] {
|
||||
const results: ComfyDynamicOutput[] = [];
|
||||
for (const [nodeId, node] of Object.entries(workflow)) {
|
||||
const title = node._meta?.title ?? "";
|
||||
const { kind, role, label } = parseTitleTag(title);
|
||||
if (kind !== "output") continue;
|
||||
const canon = normalizeOutputRole(role);
|
||||
let outKind: ComfyDynamicOutput["kind"] = "other";
|
||||
if (
|
||||
canon === "video" ||
|
||||
VIDEO_OUT.has(node.class_type) ||
|
||||
node.class_type.toLowerCase().includes("video")
|
||||
)
|
||||
outKind = "video";
|
||||
else if (
|
||||
canon === "image" ||
|
||||
IMAGE_OUT.has(node.class_type) ||
|
||||
node.class_type.toLowerCase().includes("image")
|
||||
)
|
||||
outKind = "image";
|
||||
results.push({
|
||||
nodeId,
|
||||
label: label || node.class_type,
|
||||
role: canon,
|
||||
kind: outKind,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -1,50 +1,55 @@
|
||||
import type { ComfyDynamicInput } from './types';
|
||||
import { normalizeInputRole } from './parser';
|
||||
import type { ComfyDynamicInput } from "./types";
|
||||
import { normalizeInputRole } from "./parser";
|
||||
|
||||
const PROMPT_ROLES = new Set(['prompt', 'positive']);
|
||||
const PROMPT_ROLES = new Set(["prompt", "positive"]);
|
||||
|
||||
function roleOf(inp: ComfyDynamicInput): string | null {
|
||||
return normalizeInputRole(inp.role ?? null);
|
||||
return normalizeInputRole(inp.role ?? null);
|
||||
}
|
||||
|
||||
/** Positive prompt input — prefers (Input:prompt), legacy label fallback. */
|
||||
export function isPromptLikeInput(inp: ComfyDynamicInput): boolean {
|
||||
const role = roleOf(inp);
|
||||
if (role && PROMPT_ROLES.has(role)) return true;
|
||||
if (role) return false; // other roles are never "the" prompt
|
||||
if (inp.kind !== 'text' && inp.kind !== 'textarea') return false;
|
||||
const label = (inp.label || '').toLowerCase();
|
||||
if (label.includes('negative')) return false;
|
||||
return (
|
||||
label.includes('prompt') ||
|
||||
label.includes('positive') ||
|
||||
label.includes('text') ||
|
||||
inp.kind === 'textarea'
|
||||
);
|
||||
const role = roleOf(inp);
|
||||
if (role && PROMPT_ROLES.has(role)) return true;
|
||||
if (role) return false; // other roles are never "the" prompt
|
||||
if (inp.kind !== "text" && inp.kind !== "textarea") return false;
|
||||
const label = (inp.label || "").toLowerCase();
|
||||
if (label.includes("negative")) return false;
|
||||
return (
|
||||
label.includes("prompt") ||
|
||||
label.includes("positive") ||
|
||||
label.includes("text") ||
|
||||
inp.kind === "textarea"
|
||||
);
|
||||
}
|
||||
|
||||
/** Drop prompt-role fields from shared workflow forms (entity Image prompt owns those). */
|
||||
export function withoutPromptInputs(inputs: ComfyDynamicInput[] | undefined | null): ComfyDynamicInput[] {
|
||||
if (!inputs?.length) return [];
|
||||
return inputs.filter((inp) => !isPromptLikeInput(inp));
|
||||
export function withoutPromptInputs(
|
||||
inputs: ComfyDynamicInput[] | undefined | null,
|
||||
): ComfyDynamicInput[] {
|
||||
if (!inputs?.length) return [];
|
||||
return inputs.filter((inp) => !isPromptLikeInput(inp));
|
||||
}
|
||||
|
||||
/** True if this workflow schema has a prompt-role (or legacy prompt-like) (Input). */
|
||||
export function workflowHasPromptInput(inputs: ComfyDynamicInput[] | undefined | null): boolean {
|
||||
if (!inputs?.length) return false;
|
||||
return inputs.some(isPromptLikeInput);
|
||||
export function workflowHasPromptInput(
|
||||
inputs: ComfyDynamicInput[] | undefined | null,
|
||||
): boolean {
|
||||
if (!inputs?.length) return false;
|
||||
return inputs.some(isPromptLikeInput);
|
||||
}
|
||||
|
||||
/** Strip blank strings so empty fields cannot wipe smart-fill. */
|
||||
export function compactInputValues(
|
||||
values: Record<string, unknown> | undefined | null,
|
||||
values: Record<string, unknown> | undefined | null,
|
||||
): Record<string, unknown> {
|
||||
if (!values) return {};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
if (v === null || v === undefined) continue;
|
||||
if (typeof v === 'string' && !v.trim()) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
if (!values) return {};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
if (v === null || v === undefined) continue;
|
||||
if (typeof v === "string" && !v.trim()) continue;
|
||||
if (Array.isArray(v) && !v.length) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -3,39 +3,42 @@
|
||||
* ratios and rounding, so the size shown is the size ComfyUI renders.
|
||||
*/
|
||||
const RATIOS: Record<string, [number, number]> = {
|
||||
'1:1 (Square)': [1, 1],
|
||||
'2:3 (Portrait Photo)': [2, 3],
|
||||
'3:2 (Photo)': [3, 2],
|
||||
'3:4 (Portrait Standard)': [3, 4],
|
||||
'4:3 (Standard)': [4, 3],
|
||||
'9:16 (Portrait Widescreen)': [9, 16],
|
||||
'16:9 (Widescreen)': [16, 9],
|
||||
'21:9 (Ultrawide)': [21, 9],
|
||||
"1:1 (Square)": [1, 1],
|
||||
"2:3 (Portrait Photo)": [2, 3],
|
||||
"3:2 (Photo)": [3, 2],
|
||||
"3:4 (Portrait Standard)": [3, 4],
|
||||
"4:3 (Standard)": [4, 3],
|
||||
"9:16 (Portrait Widescreen)": [9, 16],
|
||||
"16:9 (Widescreen)": [16, 9],
|
||||
"21:9 (Ultrawide)": [21, 9],
|
||||
};
|
||||
|
||||
/** Python's round(): halves go to the even neighbour. */
|
||||
function roundHalfEven(x: number): number {
|
||||
const f = Math.floor(x);
|
||||
const diff = x - f;
|
||||
if (diff > 0.5) return f + 1;
|
||||
if (diff < 0.5) return f;
|
||||
return f % 2 === 0 ? f : f + 1;
|
||||
const f = Math.floor(x);
|
||||
const diff = x - f;
|
||||
if (diff > 0.5) return f + 1;
|
||||
if (diff < 0.5) return f;
|
||||
return f % 2 === 0 ? f : f + 1;
|
||||
}
|
||||
|
||||
export function resolutionSelectorSize(
|
||||
aspectRatio: string,
|
||||
megapixels: number,
|
||||
multiple: number,
|
||||
aspectRatio: string,
|
||||
megapixels: number,
|
||||
multiple: number,
|
||||
): { width: number; height: number } | null {
|
||||
const ratio = RATIOS[aspectRatio];
|
||||
if (!ratio || !Number.isFinite(megapixels) || megapixels <= 0) return null;
|
||||
const [w, h] = ratio;
|
||||
const m = Math.max(1, Math.floor(multiple) || 8);
|
||||
const scale = Math.sqrt((megapixels * 1024 * 1024) / (w * h));
|
||||
return { width: roundHalfEven((w * scale) / m) * m, height: roundHalfEven((h * scale) / m) * m };
|
||||
const ratio = RATIOS[aspectRatio];
|
||||
if (!ratio || !Number.isFinite(megapixels) || megapixels <= 0) return null;
|
||||
const [w, h] = ratio;
|
||||
const m = Math.max(1, Math.floor(multiple) || 8);
|
||||
const scale = Math.sqrt((megapixels * 1024 * 1024) / (w * h));
|
||||
return {
|
||||
width: roundHalfEven((w * scale) / m) * m,
|
||||
height: roundHalfEven((h * scale) / m) * m,
|
||||
};
|
||||
}
|
||||
|
||||
/** "16:9 (Widescreen)" → "16:9". */
|
||||
export function ratioShort(option: string): string {
|
||||
return option.split(' ')[0] ?? option;
|
||||
return option.split(" ")[0] ?? option;
|
||||
}
|
||||
|
||||
+358
-108
@@ -1,134 +1,384 @@
|
||||
export type ComfyInputKind =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'number'
|
||||
| 'select'
|
||||
| 'image'
|
||||
| 'image_url'
|
||||
| 'audio'
|
||||
| 'video';
|
||||
export type ComfyOutputKind = 'image' | 'video' | 'other';
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "number"
|
||||
| "select"
|
||||
| "image"
|
||||
| "image_url"
|
||||
| "audio"
|
||||
| "video"
|
||||
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
|
||||
| "image_list"
|
||||
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
|
||||
| "video_list"
|
||||
| "audio_list";
|
||||
export type ComfyOutputKind = "image" | "video" | "other";
|
||||
|
||||
/** Structural finding on an imported workflow; `error` blocks generation. */
|
||||
export interface WorkflowWarning {
|
||||
level: "error" | "warning";
|
||||
code: string;
|
||||
node_id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ComfyDynamicInput {
|
||||
nodeId: string;
|
||||
label: string;
|
||||
/** Role from title tag, e.g. (Input:prompt) → "prompt". Null for plain (Input). */
|
||||
role?: string | null;
|
||||
kind: ComfyInputKind;
|
||||
defaultValue?: string | number;
|
||||
required: boolean;
|
||||
/** Inferred from node classes/wiring — the workflow carries no (Input) tags. */
|
||||
auto?: boolean;
|
||||
/** ComfyUI widget limits (number kind). */
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
integer?: boolean;
|
||||
/** Allowed values (select kind). */
|
||||
options?: string[];
|
||||
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
|
||||
resolution?: { megapixels: string; multiple: number };
|
||||
nodeId: string;
|
||||
label: string;
|
||||
/** Role from title tag, e.g. (Input:prompt) → "prompt". Null for plain (Input). */
|
||||
role?: string | null;
|
||||
kind: ComfyInputKind;
|
||||
defaultValue?: string | number;
|
||||
required: boolean;
|
||||
/** Inferred from node classes/wiring — the workflow carries no (Input) tags. */
|
||||
auto?: boolean;
|
||||
/** ComfyUI widget limits (number kind). */
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
integer?: boolean;
|
||||
/** Allowed values (select kind). */
|
||||
options?: string[];
|
||||
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
|
||||
resolution?: { megapixels: string; multiple: number };
|
||||
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
|
||||
media_hint?: string;
|
||||
}
|
||||
|
||||
export interface ComfyDynamicOutput {
|
||||
nodeId: string;
|
||||
label: string;
|
||||
/** Role from title tag, e.g. (Output:image) → "image". */
|
||||
role?: string | null;
|
||||
kind: ComfyOutputKind;
|
||||
nodeId: string;
|
||||
label: string;
|
||||
/** Role from title tag, e.g. (Output:image). */
|
||||
role?: string | null;
|
||||
/** Null while an unknown custom node awaits the user's media-type pick. */
|
||||
kind: ComfyOutputKind | null;
|
||||
/** Untagged detection: node class + which rule typed it. */
|
||||
class_type?: string;
|
||||
evidence?: "registry" | "class_name_match" | "unknown_node";
|
||||
/** Form default for unknown candidates — never auto-applied. */
|
||||
suggested_kind?: "image" | "video" | null;
|
||||
}
|
||||
|
||||
/** One entry of a workflow's confirmed output mapping (HEL-638). */
|
||||
export interface WorkflowOutputMapEntry {
|
||||
nodeId: string;
|
||||
kind: "image" | "video";
|
||||
label?: string;
|
||||
class_type?: string;
|
||||
}
|
||||
|
||||
/** The five workflow usages the studio distinguishes (HEL-636). */
|
||||
export type WorkflowUsage =
|
||||
"text2image" | "image2image" | "text2video" | "image2video" | "ref2video";
|
||||
|
||||
export const WORKFLOW_USAGES: WorkflowUsage[] = [
|
||||
"text2image",
|
||||
"image2image",
|
||||
"text2video",
|
||||
"image2video",
|
||||
"ref2video",
|
||||
];
|
||||
|
||||
/** Which usages each generate page serves. */
|
||||
export const USAGE_BY_KIND: Record<"image" | "video", WorkflowUsage[]> = {
|
||||
image: ["text2image", "image2image"],
|
||||
video: ["text2video", "image2video", "ref2video"],
|
||||
};
|
||||
|
||||
/** One L2 connection-check finding against a live ComfyUI (HEL-643). */
|
||||
export interface PreflightFinding {
|
||||
level: "error" | "warn" | "info" | "ok";
|
||||
code: string;
|
||||
node_id?: string;
|
||||
node_title?: string | null;
|
||||
message: string;
|
||||
hint?: string | null;
|
||||
}
|
||||
|
||||
/** Result of checking a workflow against a target connection's /object_info. */
|
||||
export interface WorkflowConnectionCheck {
|
||||
endpoint_id: string | null;
|
||||
endpoint_name: string | null;
|
||||
base_url?: string | null;
|
||||
/** False when any error-level finding exists (or the server was unreachable). */
|
||||
ok: boolean;
|
||||
/** False → 无法核查 (server down): honest, non-blocking. */
|
||||
reachable: boolean;
|
||||
findings: PreflightFinding[];
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
/** Slim live record of a workflow's latest test run (HEL-643). */
|
||||
export interface WorkflowLastTest {
|
||||
id: number;
|
||||
status: string;
|
||||
phase: string | null;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
output_count: number;
|
||||
}
|
||||
|
||||
/** Result of POST /api/workflows/analyze (import confirm step 1). */
|
||||
export interface WorkflowAnalyzeResult {
|
||||
inputs: ComfyDynamicInput[];
|
||||
outputs: ComfyDynamicOutput[];
|
||||
suggested_profile?: string;
|
||||
warnings?: WorkflowWarning[];
|
||||
usage: WorkflowUsage | null;
|
||||
usage_ambiguous: boolean;
|
||||
needs_mapping: boolean;
|
||||
usage_problem: string | null;
|
||||
usage_evidence: {
|
||||
outputs: ComfyDynamicOutput[];
|
||||
image_slots: Array<{
|
||||
nodeId: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
role: string | null;
|
||||
}>;
|
||||
video_slots: Array<{
|
||||
nodeId: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
role: string | null;
|
||||
}>;
|
||||
summary: string[];
|
||||
};
|
||||
usable_usage: WorkflowUsage[];
|
||||
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
|
||||
connection_check?: WorkflowConnectionCheck;
|
||||
}
|
||||
|
||||
export interface WorkflowNode {
|
||||
class_type: string;
|
||||
inputs: Record<string, unknown>;
|
||||
_meta?: { title?: string };
|
||||
class_type: string;
|
||||
inputs: Record<string, unknown>;
|
||||
_meta?: { title?: string };
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: 'image' | 'video';
|
||||
workflow_json: Record<string, WorkflowNode>;
|
||||
input_schema: ComfyDynamicInput[];
|
||||
output_schema: ComfyDynamicOutput[];
|
||||
description: string | null;
|
||||
prompt_profile: string;
|
||||
is_enabled: boolean;
|
||||
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
|
||||
endpoint_id?: string | null;
|
||||
id: number;
|
||||
name: string;
|
||||
kind: "image" | "video";
|
||||
/** Five-way usage (HEL-636); null = pending human confirmation. */
|
||||
usage?: WorkflowUsage | null;
|
||||
usage_source?: "auto" | "user" | null;
|
||||
workflow_json: Record<string, WorkflowNode>;
|
||||
input_schema: ComfyDynamicInput[];
|
||||
output_schema: ComfyDynamicOutput[];
|
||||
description: string | null;
|
||||
prompt_profile: string;
|
||||
is_enabled: boolean;
|
||||
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
|
||||
endpoint_id?: string | null;
|
||||
warnings?: WorkflowWarning[];
|
||||
/** HEL-638: confirmed output mapping ({nodeId, kind}); null/empty = pending. */
|
||||
output_map?: WorkflowOutputMapEntry[] | null;
|
||||
output_map_source?: "auto" | "user" | null;
|
||||
/** True when the graph has unknown output nodes needing designation. */
|
||||
needs_mapping?: boolean;
|
||||
/** Latest test-run record, queried live (HEL-643); null = never tested. */
|
||||
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;
|
||||
scene_id: number | null;
|
||||
/** Render unit — video jobs are clip jobs (scene_id stays for grouping). */
|
||||
clip_id: number | null;
|
||||
kind: string;
|
||||
workflow_id: number | null;
|
||||
status: string;
|
||||
payload: Record<string, unknown>;
|
||||
output_paths: string[];
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
retry_count: number;
|
||||
/** Prompt id ComfyUI accepted this job under (null until /prompt succeeds). */
|
||||
comfy_prompt_id?: string | null;
|
||||
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
|
||||
phase?: string | null;
|
||||
id: number;
|
||||
project_id: number;
|
||||
scene_id: number | null;
|
||||
/** Render unit — video jobs are clip jobs (scene_id stays for grouping). */
|
||||
clip_id: number | null;
|
||||
kind: string;
|
||||
workflow_id: number | null;
|
||||
status: string;
|
||||
payload: Record<string, unknown>;
|
||||
output_paths: string[];
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
retry_count: number;
|
||||
/** Prompt id ComfyUI accepted this job under (null until /prompt succeeds). */
|
||||
comfy_prompt_id?: string | null;
|
||||
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
|
||||
phase?: string | null;
|
||||
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
|
||||
origin?: string;
|
||||
/** HEL-684: present when this take has been flowed back to its
|
||||
* workbench source row (link job id on the source project). */
|
||||
workbench_attach?: {
|
||||
job_id: number;
|
||||
stage: string;
|
||||
target_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SceneVideoSettings {
|
||||
input_values?: Record<string, string | number>;
|
||||
/** 'auto' | 'upload' | target scene id as string. */
|
||||
clip_source?: string;
|
||||
form_workflow_id?: number;
|
||||
prompt_draft?: string;
|
||||
prompt_draft_meta?: { based_on?: string; saved_at?: string };
|
||||
[key: string]: unknown;
|
||||
input_values?: Record<string, string | number>;
|
||||
/** 'auto' | 'upload' | target scene id as string. */
|
||||
clip_source?: string;
|
||||
form_workflow_id?: number;
|
||||
prompt_draft?: string;
|
||||
prompt_draft_meta?: { based_on?: string; saved_at?: string };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
id: number;
|
||||
project_id: number;
|
||||
order_index: number;
|
||||
heading: string | null;
|
||||
action: string | null;
|
||||
dialog: string | null;
|
||||
duration_sec: number | null;
|
||||
workflow_id: number | null;
|
||||
env_image_path: string | null;
|
||||
location_id: number | null;
|
||||
video_path: string | null;
|
||||
chain_from_prev?: number | boolean | null;
|
||||
video_settings?: SceneVideoSettings | null;
|
||||
character_ids: number[];
|
||||
characters: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
role: string | null;
|
||||
portrait_path: string | null;
|
||||
sheet_path: string | null;
|
||||
}>;
|
||||
clips: Clip[];
|
||||
id: number;
|
||||
project_id: number;
|
||||
order_index: number;
|
||||
heading: string | null;
|
||||
action: string | null;
|
||||
dialog: string | null;
|
||||
duration_sec: number | null;
|
||||
workflow_id: number | null;
|
||||
env_image_path: string | null;
|
||||
location_id: number | null;
|
||||
video_path: string | null;
|
||||
chain_from_prev?: number | boolean | null;
|
||||
video_settings?: SceneVideoSettings | null;
|
||||
character_ids: number[];
|
||||
characters: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
role: string | null;
|
||||
portrait_path: string | null;
|
||||
sheet_path: string | null;
|
||||
}>;
|
||||
clips: Clip[];
|
||||
}
|
||||
|
||||
/** Shot unit: one scene expands into 1+ clips; each renders one ~5-10s video. */
|
||||
export interface Clip {
|
||||
id: number;
|
||||
scene_id: number;
|
||||
project_id: number;
|
||||
order_index: number;
|
||||
description: string | null;
|
||||
shot_size: string | null;
|
||||
dialog_lines_covered: number[] | null;
|
||||
duration_sec: number | null;
|
||||
workflow_id: number | null;
|
||||
clip_path: string | null;
|
||||
chain_from_prev?: number | boolean | null;
|
||||
video_settings?: SceneVideoSettings | null;
|
||||
/** '#3.2' — scene 3, clip 2 (display label). */
|
||||
label?: string;
|
||||
id: number;
|
||||
scene_id: number;
|
||||
project_id: number;
|
||||
order_index: number;
|
||||
description: string | null;
|
||||
shot_size: string | null;
|
||||
dialog_lines_covered: number[] | null;
|
||||
duration_sec: number | null;
|
||||
workflow_id: number | null;
|
||||
clip_path: string | null;
|
||||
chain_from_prev?: number | boolean | null;
|
||||
video_settings?: SceneVideoSettings | null;
|
||||
/** '#3.2' — scene 3, clip 2 (display label). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
@@ -8,92 +8,96 @@
|
||||
* - upload progress per nodeId
|
||||
* - metadata (name, kind) for uploaded files
|
||||
*/
|
||||
import { playgroundApi, type UploadKind } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { playgroundApi, type UploadKind } from "$lib/api";
|
||||
import { toast } from "$lib/toast";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
export interface UploadState {
|
||||
/** nodeId → uploading filename (present while upload is in-flight) */
|
||||
uploading: Record<string, string>;
|
||||
/** path → { name, kind } for previously uploaded files */
|
||||
uploadedMeta: Record<string, { name: string; kind: UploadKind }>;
|
||||
/** nodeId → uploading filename (present while upload is in-flight) */
|
||||
uploading: Record<string, string>;
|
||||
/** path → { name, kind } for previously uploaded files */
|
||||
uploadedMeta: Record<string, { name: string; kind: UploadKind }>;
|
||||
}
|
||||
|
||||
export function createUploadManager() {
|
||||
let uploading = $state<Record<string, string>>({});
|
||||
let uploadedMeta = $state<Record<string, { name: string; kind: UploadKind }>>({});
|
||||
let uploading = $state<Record<string, string>>({});
|
||||
let uploadedMeta = $state<Record<string, { name: string; kind: UploadKind }>>(
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
get uploading() {
|
||||
return uploading;
|
||||
},
|
||||
get uploadedMeta() {
|
||||
return uploadedMeta;
|
||||
},
|
||||
return {
|
||||
get uploading() {
|
||||
return uploading;
|
||||
},
|
||||
get uploadedMeta() {
|
||||
return uploadedMeta;
|
||||
},
|
||||
|
||||
isUploading(nodeId: string): boolean {
|
||||
return !!uploading[nodeId];
|
||||
},
|
||||
isUploading(nodeId: string): boolean {
|
||||
return !!uploading[nodeId];
|
||||
},
|
||||
|
||||
uploadMetaFor(path: string): { name: string; kind: UploadKind } {
|
||||
const meta = uploadedMeta[path];
|
||||
if (meta) return meta;
|
||||
const base = path.split(/[/\\]/).pop() ?? path;
|
||||
const ext = base.slice(base.lastIndexOf('.')).toLowerCase();
|
||||
if (['.mp4', '.webm', '.mov', '.mkv'].includes(ext))
|
||||
return { name: base, kind: 'video' };
|
||||
if (['.mp3', '.wav', '.flac', '.ogg', '.m4a'].includes(ext))
|
||||
return { name: base, kind: 'audio' };
|
||||
return { name: base, kind: 'image' };
|
||||
},
|
||||
uploadMetaFor(path: string): { name: string; kind: UploadKind } {
|
||||
const meta = uploadedMeta[path];
|
||||
if (meta) return meta;
|
||||
const base = path.split(/[/\\]/).pop() ?? path;
|
||||
const ext = base.slice(base.lastIndexOf(".")).toLowerCase();
|
||||
if ([".mp4", ".webm", ".mov", ".mkv"].includes(ext))
|
||||
return { name: base, kind: "video" };
|
||||
if ([".mp3", ".wav", ".flac", ".ogg", ".m4a"].includes(ext))
|
||||
return { name: base, kind: "audio" };
|
||||
return { name: base, kind: "image" };
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload a file and return the server path.
|
||||
* Manages uploading state and metadata internally.
|
||||
* Throws on failure (caller handles toast if needed).
|
||||
*/
|
||||
async upload(nodeId: string, file: File): Promise<string> {
|
||||
uploading = { ...uploading, [nodeId]: file.name };
|
||||
try {
|
||||
const res = await playgroundApi.upload(file);
|
||||
uploadedMeta = {
|
||||
...uploadedMeta,
|
||||
[res.path]: { name: res.name, kind: res.kind },
|
||||
};
|
||||
return res.path;
|
||||
} finally {
|
||||
const next = { ...uploading };
|
||||
delete next[nodeId];
|
||||
uploading = next;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Upload a file and return the server path.
|
||||
* Manages uploading state and metadata internally.
|
||||
* Throws on failure (caller handles toast if needed).
|
||||
*/
|
||||
async upload(nodeId: string, file: File): Promise<string> {
|
||||
uploading = { ...uploading, [nodeId]: file.name };
|
||||
try {
|
||||
const res = await playgroundApi.upload(file);
|
||||
uploadedMeta = {
|
||||
...uploadedMeta,
|
||||
[res.path]: { name: res.name, kind: res.kind },
|
||||
};
|
||||
return res.path;
|
||||
} finally {
|
||||
const next = { ...uploading };
|
||||
delete next[nodeId];
|
||||
uploading = next;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Convenience: upload + toast on error, return path or null.
|
||||
*/
|
||||
async uploadSafe(nodeId: string, file: File): Promise<string | null> {
|
||||
try {
|
||||
return await this.upload(nodeId, file);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('toast.uploadFailed'));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Convenience: upload + toast on error, return path or null.
|
||||
*/
|
||||
async uploadSafe(nodeId: string, file: File): Promise<string | null> {
|
||||
try {
|
||||
return await this.upload(nodeId, file);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("toast.uploadFailed"),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type UploadManager = ReturnType<typeof createUploadManager>;
|
||||
|
||||
export function truncateMiddle(name: string, max = 32): string {
|
||||
if (name.length <= max) return name;
|
||||
const head = Math.ceil((max - 1) / 2);
|
||||
const tail = Math.floor((max - 1) / 2);
|
||||
return `${name.slice(0, head)}…${name.slice(name.length - tail)}`;
|
||||
if (name.length <= max) return name;
|
||||
const head = Math.ceil((max - 1) / 2);
|
||||
const tail = Math.floor((max - 1) / 2);
|
||||
return `${name.slice(0, head)}…${name.slice(name.length - tail)}`;
|
||||
}
|
||||
|
||||
export function acceptForKind(kind: string): string {
|
||||
if (kind === 'audio') return 'audio/*';
|
||||
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
|
||||
if (kind === 'document') return '.txt,.md,.docx';
|
||||
return 'image/*';
|
||||
if (kind === "audio") return "audio/*";
|
||||
if (kind === "video") return "video/*,.mp4,.webm,.mov,.mkv";
|
||||
if (kind === "document") return ".txt,.md,.docx";
|
||||
return "image/*";
|
||||
}
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { jobsApi, type Job } from '$lib/api';
|
||||
import type { CalliopeEvent, EventConnectionState } from '$lib/events';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import JobRow from './JobRow.svelte';
|
||||
import Button from './ui/Button.svelte';
|
||||
import Icon from './ui/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
logs: CalliopeEvent[];
|
||||
projectId: number;
|
||||
/** Real SSE connection state from events.ts — drives the live pip. */
|
||||
connState: EventConnectionState;
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { logs, projectId, connState, collapsed = false, onToggle }: Props = $props();
|
||||
|
||||
type Tone = 'info' | 'ok' | 'warn' | 'err' | 'work';
|
||||
|
||||
type LogEntry = {
|
||||
key: string;
|
||||
ts: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
tone: Tone;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function str(v: unknown): string {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatEvent(ev: CalliopeEvent, index: number): LogEntry | null {
|
||||
// Poll spam — progress lives in the jobProgress store, not the log
|
||||
if (ev.type === 'job.progress') return null;
|
||||
|
||||
const d = ev.data ?? {};
|
||||
const msg = str(d.message).trim();
|
||||
const kind = str(d.kind);
|
||||
const jobId = d.job_id != null ? `#${d.job_id}` : '';
|
||||
const err = str(d.error).trim();
|
||||
const paths = Array.isArray(d.paths) ? d.paths.length : 0;
|
||||
const outputs = Array.isArray(d.outputs) ? d.outputs.length : 0;
|
||||
const time = ev.ts?.includes('T') ? ev.ts.slice(11, 19) : ev.ts?.slice(0, 8) || '';
|
||||
|
||||
let title = ev.type;
|
||||
let detail = msg;
|
||||
let tone: Tone = 'info';
|
||||
|
||||
switch (ev.type) {
|
||||
case 'agent.thinking':
|
||||
title = t('activity.agentWorking');
|
||||
detail = msg || t('activity.working');
|
||||
tone = 'work';
|
||||
break;
|
||||
case 'story.ready':
|
||||
title = t('activity.storyReady');
|
||||
detail = msg || t('activity.storylineDrafted');
|
||||
tone = 'ok';
|
||||
break;
|
||||
case 'job.created':
|
||||
title = t('activity.queued');
|
||||
detail = msg || [kind, jobId].filter(Boolean).join(' ');
|
||||
tone = 'info';
|
||||
break;
|
||||
case 'job.started':
|
||||
title = t('activity.running');
|
||||
detail = msg || [kind, jobId].filter(Boolean).join(' ');
|
||||
tone = 'work';
|
||||
break;
|
||||
case 'job.completed':
|
||||
title = t('activity.finished');
|
||||
detail =
|
||||
msg ||
|
||||
[kind, jobId, outputs ? t('activity.files', { count: outputs }) : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
tone = 'ok';
|
||||
break;
|
||||
case 'job.failed':
|
||||
title = t('activity.failed');
|
||||
detail = err || msg || [kind, jobId].filter(Boolean).join(' ');
|
||||
tone = 'err';
|
||||
break;
|
||||
case 'asset.ready':
|
||||
title = t('activity.assetReady');
|
||||
detail = msg || (paths ? t('activity.filesSaved', { count: paths }) : t('activity.outputSaved'));
|
||||
tone = 'ok';
|
||||
break;
|
||||
case 'job.deleted':
|
||||
title = t('activity.removed');
|
||||
detail = msg || jobId || t('activity.jobDeleted');
|
||||
tone = 'warn';
|
||||
break;
|
||||
default:
|
||||
title = ev.type.replace(/\./g, ' · ');
|
||||
detail = msg || err || kind;
|
||||
tone = 'info';
|
||||
}
|
||||
|
||||
return {
|
||||
key: `${ev.ts}-${ev.type}-${index}`,
|
||||
ts: time,
|
||||
title,
|
||||
detail,
|
||||
tone,
|
||||
type: ev.type,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = $derived.by(() => {
|
||||
const raw = logs
|
||||
.slice(-120)
|
||||
.map((ev, i) => formatEvent(ev, i))
|
||||
.filter((e): e is LogEntry => e != null);
|
||||
|
||||
// Collapse consecutive identical agent lines
|
||||
const collapsed: LogEntry[] = [];
|
||||
for (const e of raw) {
|
||||
const prev = collapsed[collapsed.length - 1];
|
||||
if (prev && prev.type === e.type && prev.detail === e.detail) {
|
||||
continue;
|
||||
}
|
||||
collapsed.push(e);
|
||||
}
|
||||
return collapsed.slice(-60).reverse();
|
||||
});
|
||||
|
||||
// Same query key as QueueStage — refreshed by SSE invalidation from the
|
||||
// project page (job.* events), not by polling.
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs', projectId],
|
||||
queryFn: () => jobsApi.list(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
const allJobs = $derived(($jobsQuery.data ?? []) as Job[]);
|
||||
|
||||
function activeWeight(job: Job): number {
|
||||
if (job.status === 'running') return 0;
|
||||
if (job.status === 'pending') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Active jobs first, then most recent — capped so the log keeps room.
|
||||
const queueRows = $derived(
|
||||
[...allJobs].sort((a, b) => activeWeight(a) - activeWeight(b) || b.id - a.id).slice(0, 6),
|
||||
);
|
||||
|
||||
const stats = $derived({
|
||||
running: allJobs.filter((j) => j.status === 'running').length,
|
||||
queued: allJobs.filter((j) => j.status === 'pending').length,
|
||||
done: allJobs.filter((j) => j.status === 'done').length,
|
||||
failed: allJobs.filter((j) => j.status === 'failed').length,
|
||||
});
|
||||
const busyCount = $derived(stats.running + stats.queued);
|
||||
|
||||
const connected = $derived(connState === 'open');
|
||||
</script>
|
||||
|
||||
<aside class="activity" class:collapsed aria-label={t('activity.title')}>
|
||||
{#if collapsed}
|
||||
<button
|
||||
type="button"
|
||||
class="rail-toggle"
|
||||
onclick={onToggle}
|
||||
aria-expanded="false"
|
||||
title={t('activity.show')}
|
||||
>
|
||||
<span class="pip rail-pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
|
||||
{#if busyCount > 0}
|
||||
<span class="rail-count" title={t('activity.busyCount', { count: busyCount })}>{busyCount}</span>
|
||||
{/if}
|
||||
<span class="rail-label">{t('activity.title')}</span>
|
||||
<span class="rail-chevron" aria-hidden="true">‹</span>
|
||||
</button>
|
||||
{:else}
|
||||
<header class="head">
|
||||
<div class="head-title">
|
||||
<span class="pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
|
||||
<strong>{t('activity.title')}</strong>
|
||||
{#if connState === 'reconnecting'}
|
||||
<span class="conn-state warn">{t('activity.reconnecting')}</span>
|
||||
{:else if connState === 'connecting'}
|
||||
<span class="conn-state">{t('activity.connecting')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onclick={onToggle} title={t('activity.hide')}>
|
||||
{t('activity.hide')} <Icon name="chevron-right" size={13} />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<section class="queue" aria-label={t('activity.queue')}>
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">{t('activity.queue')}</p>
|
||||
</div>
|
||||
{#if queueRows.length === 0}
|
||||
<p class="queue-empty">{t('activity.queueIdle')}</p>
|
||||
{:else}
|
||||
<div class="job-list">
|
||||
{#each queueRows as job (job.id)}
|
||||
<JobRow {job} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="stats-strip" aria-label={t('activity.queueStats')}>
|
||||
<span><span class="sd sd-running" aria-hidden="true"></span>{stats.running} {t('activity.running').toLowerCase()}</span>
|
||||
<span><span class="sd sd-queued" aria-hidden="true"></span>{stats.queued} {t('activity.queued').toLowerCase()}</span>
|
||||
<span><span class="sd sd-done" aria-hidden="true"></span>{stats.done} {t('activity.done')}</span>
|
||||
<span><span class="sd sd-failed" aria-hidden="true"></span>{stats.failed} {t('activity.failed').toLowerCase()}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="log-section" aria-label={t('activity.agentLog')}>
|
||||
<p class="eyebrow">{t('activity.agentLog')}</p>
|
||||
{#if entries.length === 0}
|
||||
<div class="empty">
|
||||
<p>{t('activity.quiet')}</p>
|
||||
<p class="hint">{t('activity.quietHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="log">
|
||||
{#each entries as ev (ev.key)}
|
||||
<li class="entry tone-{ev.tone}">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<div class="meta">
|
||||
<div class="row">
|
||||
<span class="title">{ev.title}</span>
|
||||
<time class="ts" datetime={ev.ts}>{ev.ts}</time>
|
||||
</div>
|
||||
{#if ev.detail}
|
||||
<p class="detail">{ev.detail}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.activity {
|
||||
--panel-w: 320px;
|
||||
--rail-w: 44px;
|
||||
width: var(--panel-w);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--border);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(139, 92, 246, 0.06) 0%, transparent 120px),
|
||||
var(--bg-surface);
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.activity.collapsed {
|
||||
width: var(--rail-w);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.rail-toggle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 16px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rail-toggle:hover {
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rail-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.rail-label {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rail-chevron {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rail-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pip {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pip.live {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
animation: live-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pip.reconnecting {
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.18);
|
||||
}
|
||||
|
||||
@keyframes live-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.65;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.activity {
|
||||
transition: none;
|
||||
}
|
||||
.pip.live {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.head-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.head-title strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.conn-state {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conn-state.warn {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 10px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.queue {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.job-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 216px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.queue-empty {
|
||||
margin: 0 6px 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stats-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
margin: 10px 6px 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stats-strip > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sd {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sd-running {
|
||||
background: var(--info);
|
||||
}
|
||||
.sd-queued {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.sd-done {
|
||||
background: var(--success);
|
||||
}
|
||||
.sd-failed {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.log-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 8px 6px;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty .hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.log {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 4px 8px 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 8px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.entry:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.tone-ok .dot {
|
||||
background: var(--success);
|
||||
}
|
||||
.tone-err .dot {
|
||||
background: var(--error);
|
||||
}
|
||||
.tone-warn .dot {
|
||||
background: var(--warning);
|
||||
}
|
||||
.tone-work .dot {
|
||||
background: var(--accent);
|
||||
}
|
||||
.tone-info .dot {
|
||||
background: var(--info);
|
||||
}
|
||||
|
||||
.meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ts {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tone-err .detail {
|
||||
color: #fca5a5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
@@ -15,8 +16,8 @@ interface Props {
|
||||
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<a class="logo" href="/canvas" title="Calliope Lab">
|
||||
Calliope <span>Lab</span>
|
||||
<a class="logo" href={resolve('/')} title={t('shell.brand')}>
|
||||
{t('shell.brand')}
|
||||
</a>
|
||||
{#if crumb}
|
||||
<span class="crumb">{crumb}</span>
|
||||
@@ -27,13 +28,13 @@ interface Props {
|
||||
</div>
|
||||
{/if}
|
||||
<nav class="top-nav" aria-label={t('nav.primary')}>
|
||||
<a class="nav-link" class:active={active === 'canvas'} href="/canvas">{t('nav.canvas')}</a>
|
||||
<a class="nav-link" class:active={active === 'projects'} href="/projects">{t('nav.projects')}</a>
|
||||
<a class="nav-link" class:active={active === 'build-scene'} href="/build-scene">{t('nav.buildScene')}</a>
|
||||
<a class="nav-link" class:active={active === 'library'} href="/library">{t('nav.library')}</a>
|
||||
<a class="nav-link" class:active={active === 'agents'} href="/agents">{t('nav.agents')}</a>
|
||||
<a class="nav-link" class:active={active === 'models'} href="/models">{t('nav.models')}</a>
|
||||
<a class="nav-link" class:active={active === 'settings'} href="/settings">{t('nav.settings')}</a>
|
||||
<a class="nav-link" class:active={active === 'canvas'} href={resolve('/canvas')}>{t('nav.canvas')}</a>
|
||||
<a class="nav-link" class:active={active === 'projects'} href={resolve('/')}>{t('nav.projects')}</a>
|
||||
<a class="nav-link" class:active={active === 'build-scene'} href={resolve('/build-scene')}>{t('nav.buildScene')}</a>
|
||||
<a class="nav-link" class:active={active === 'library'} href={resolve('/library')}>{t('nav.library')}</a>
|
||||
<a class="nav-link" class:active={active === 'agents'} href={resolve('/agents')}>{t('nav.agents')}</a>
|
||||
<a class="nav-link" class:active={active === 'models'} href={resolve('/models')}>{t('nav.models')}</a>
|
||||
<a class="nav-link" class:active={active === 'settings'} href={resolve('/settings')}>{t('nav.settings')}</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@@ -69,18 +70,14 @@ interface Props {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-family: var(--font);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logo span {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.crumb {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
@@ -122,7 +119,7 @@ interface Props {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 760px) {
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
|
||||
interface Props {
|
||||
src: string | null;
|
||||
alt: string;
|
||||
placeholder: string;
|
||||
wide?: boolean;
|
||||
/** Taller 4:3 media-first crop (asset card grids). */
|
||||
tall?: boolean;
|
||||
/** Latest generation-job state for this asset, if the parent tracks jobs. */
|
||||
jobState?: 'generating' | 'failed' | null;
|
||||
onPreview?: (src: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
src,
|
||||
alt,
|
||||
placeholder,
|
||||
wide = false,
|
||||
tall = false,
|
||||
jobState = null,
|
||||
onPreview,
|
||||
}: Props = $props();
|
||||
|
||||
let available = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
void src;
|
||||
available = false;
|
||||
});
|
||||
|
||||
// Job state (live from the queue) wins over the media-derived state.
|
||||
const chip = $derived.by((): { status: string; label: string } => {
|
||||
if (jobState === 'generating') return { status: 'generating', label: t('common.generating') };
|
||||
if (jobState === 'failed') return { status: 'failed', label: t('common.failed') };
|
||||
if (available) return { status: 'ready', label: t('common.ready') };
|
||||
if (src) return { status: 'missing', label: t('common.missing') };
|
||||
return { status: 'pending', label: t('common.pending') };
|
||||
});
|
||||
|
||||
function open() {
|
||||
if (available && src && onPreview) onPreview(src);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="thumb"
|
||||
class:wide
|
||||
class:tall
|
||||
class:clickable={available && !!onPreview}
|
||||
role={available && onPreview ? 'button' : undefined}
|
||||
tabindex={available && onPreview ? 0 : undefined}
|
||||
onclick={open}
|
||||
onkeydown={available && onPreview ? onKey : undefined}
|
||||
>
|
||||
<SafeMedia
|
||||
class="fill"
|
||||
{src}
|
||||
{alt}
|
||||
label={placeholder}
|
||||
onAvailable={() => (available = true)}
|
||||
onUnavailable={() => (available = false)}
|
||||
/>
|
||||
<span class="thumb-chip">
|
||||
<StatusChip status={chip.status} label={chip.label} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.thumb {
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.thumb.wide {
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
.thumb.tall {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
.thumb.clickable {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.thumb.clickable:hover {
|
||||
border-color: var(--accent, #a78bfa);
|
||||
}
|
||||
.thumb.clickable:focus-visible {
|
||||
outline: 2px solid var(--accent, #a78bfa);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.thumb :global(.fill) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.thumb :global(.safe-media-ph.fill) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.thumb-chip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { createMutation, createQuery } from '@tanstack/svelte-query';
|
||||
import {
|
||||
playgroundApi,
|
||||
@@ -136,10 +137,7 @@
|
||||
|
||||
const projectList = $derived(($projectsQuery.data ?? []) as Project[]);
|
||||
|
||||
// Stage-URL contract: images land on Assets, clips on Video.
|
||||
const stageLink = $derived(
|
||||
attached ? `/project/${attached.projectId}?stage=${isVideo ? 'video' : 'assets'}` : '',
|
||||
);
|
||||
// Five-stage workbench URL contract: images land on 素材资产, clips on 镜头脚本.
|
||||
</script>
|
||||
|
||||
<div class="attach">
|
||||
@@ -147,7 +145,7 @@
|
||||
<div class="attached" role="status">
|
||||
<span class="attached-check" aria-hidden="true"><Icon name="check" size={14} /></span>
|
||||
<span class="attached-text">{t('attach.addedTo')} <strong>{attached.title}</strong></span>
|
||||
<a class="attached-link" href={stageLink}>{t('attach.viewInProject')}</a>
|
||||
<a class="attached-link" href={resolve(`/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}`)}>{t('attach.viewInProject')}</a>
|
||||
<button
|
||||
class="attached-dismiss"
|
||||
type="button"
|
||||
@@ -173,7 +171,7 @@
|
||||
<span class="field-label">{t('attach.projectField')}</span>
|
||||
<select class="field-select" bind:value={projectId}>
|
||||
<option value="">{t('attach.selectProject')}</option>
|
||||
{#each projectList as p}
|
||||
{#each projectList as p (p.id)}
|
||||
<option value={p.id}>{p.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -194,7 +192,7 @@
|
||||
<span class="field-label">{t('attach.characterField')}</span>
|
||||
<select class="field-select" bind:value={characterId} disabled={projectId === '' || loadingTargets}>
|
||||
<option value="">{t('attach.selectCharacter')}</option>
|
||||
{#each characters as c}
|
||||
{#each characters as c (c.id)}
|
||||
<option value={c.id}>{c.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -207,7 +205,7 @@
|
||||
<span class="field-label">{t('attach.locationField')}</span>
|
||||
<select class="field-select" bind:value={locationId} disabled={projectId === '' || loadingTargets}>
|
||||
<option value="">{t('attach.selectLocation')}</option>
|
||||
{#each locations as loc}
|
||||
{#each locations as loc (loc.id)}
|
||||
<option value={loc.id}>{loc.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -232,7 +230,7 @@
|
||||
<span class="field-label">{t('attach.sceneField')}</span>
|
||||
<select class="field-select" bind:value={sceneId} disabled={projectId === '' || loadingTargets}>
|
||||
<option value="">{t('attach.selectScene')}</option>
|
||||
{#each scenes as s}
|
||||
{#each scenes as s (s.id)}
|
||||
<option value={s.id}>#{s.order_index} {s.heading || t('attach.scene')}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -267,13 +265,13 @@
|
||||
.attached {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: var(--ok-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent);
|
||||
border-radius: var(--r-md);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.attached-check {
|
||||
display: flex;
|
||||
@@ -282,8 +280,8 @@
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
color: #052e12;
|
||||
background: var(--ok);
|
||||
color: var(--bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.attached-text {
|
||||
@@ -293,23 +291,26 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.attached-text strong {
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 普通链接走 ghost(§1.2):默认 --text-2,悬停 --text,不用金色文字链接。 */
|
||||
.attached-link {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.attached-link:hover {
|
||||
color: var(--text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.attached-link:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.attached-dismiss {
|
||||
display: flex;
|
||||
@@ -319,15 +320,15 @@
|
||||
height: 22px;
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.attached-dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.attached-dismiss:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -336,20 +337,20 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-5);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-1);
|
||||
}
|
||||
.err {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
margin: var(--sp-1) 0 0;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--err);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
allowUpload?: boolean;
|
||||
/** Force required-field error rings visible (e.g. after a blocked Generate). */
|
||||
showErrors?: boolean;
|
||||
/** Show each field's workflow value (原值) and mark fields that differ from it. */
|
||||
showOrig?: boolean;
|
||||
/** Extra per-field messages keyed by nodeId (e.g. range checks); shown with showErrors. */
|
||||
errors?: Record<string, string>;
|
||||
/** Emits labels of required-but-empty inputs whenever the set changes. */
|
||||
onValidityChange?: (missing: string[]) => void;
|
||||
onChange?: (values: Record<string, string | number>) => void;
|
||||
@@ -30,10 +34,23 @@
|
||||
assetOptions = [],
|
||||
allowUpload = false,
|
||||
showErrors = false,
|
||||
showOrig = false,
|
||||
errors = {},
|
||||
onValidityChange,
|
||||
onChange,
|
||||
}: Props = $props();
|
||||
|
||||
function hasOrig(inp: ComfyDynamicInput): boolean {
|
||||
return inp.defaultValue !== undefined && inp.defaultValue !== '';
|
||||
}
|
||||
|
||||
function isChanged(inp: ComfyDynamicInput): boolean {
|
||||
if (!showOrig) return false;
|
||||
const v = values[inp.nodeId];
|
||||
if (isBlank(v)) return hasOrig(inp);
|
||||
return !hasOrig(inp) || String(v) !== String(inp.defaultValue);
|
||||
}
|
||||
|
||||
// Unique per mounted form so label/id pairs never collide across instances.
|
||||
const uid = Math.random().toString(36).slice(2, 8);
|
||||
let touched = $state<Record<string, boolean>>({});
|
||||
@@ -80,6 +97,11 @@
|
||||
return isMissing(inp) && (showErrors || !!touched[inp.nodeId]);
|
||||
}
|
||||
|
||||
function extraError(inp: ComfyDynamicInput): string | null {
|
||||
const msg = errors[inp.nodeId]?.trim();
|
||||
return msg && !isMissing(inp) && (showErrors || !!touched[inp.nodeId]) ? msg : null;
|
||||
}
|
||||
|
||||
function thumbFor(current: string): string | null {
|
||||
if (!current) return null;
|
||||
const opt = assetOptions.find((o) => o.path === current);
|
||||
@@ -207,6 +229,7 @@
|
||||
|
||||
{#if inputs.length === 0}
|
||||
<p class="muted">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- This fixed key comes only from the seven bundled translation dictionaries, without interpolation or remote overrides. Retain the existing HTML-capable translation contract. -->
|
||||
{@html t('cdf.noInputs')}
|
||||
</p>
|
||||
{:else}
|
||||
@@ -224,7 +247,9 @@
|
||||
{#each inputs as inp (inp.nodeId)}
|
||||
{@const current = String(values[inp.nodeId] ?? '')}
|
||||
{@const id = inputId(inp.nodeId)}
|
||||
{@const errored = showError(inp)}
|
||||
{@const extra = extraError(inp)}
|
||||
{@const errored = showError(inp) || !!extra}
|
||||
{@const changed = isChanged(inp)}
|
||||
<div class="field">
|
||||
<label class="label" for={id}>
|
||||
{inp.label}
|
||||
@@ -235,6 +260,9 @@
|
||||
{#if inp.role}<span class="role">{inp.role}</span>{/if}
|
||||
<span class="kind">{inp.kind}</span>
|
||||
{/if}
|
||||
{#if showOrig && hasOrig(inp)}
|
||||
<span class="orig">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{#if readonly}
|
||||
<div class="readonly">{values[inp.nodeId] ?? inp.defaultValue ?? '—'}</div>
|
||||
@@ -243,6 +271,7 @@
|
||||
{id}
|
||||
rows="3"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={current}
|
||||
@@ -254,6 +283,7 @@
|
||||
{id}
|
||||
type="number"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={values[inp.nodeId] ?? ''}
|
||||
@@ -264,6 +294,7 @@
|
||||
<select
|
||||
{id}
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
value={current || String(inp.defaultValue ?? '')}
|
||||
onchange={(e) => setValue(inp.nodeId, e.currentTarget.value)}
|
||||
onblur={() => markTouched(inp.nodeId)}
|
||||
@@ -351,7 +382,8 @@
|
||||
}}
|
||||
>
|
||||
<option value="">{t('cdf.chooseAsset')}</option>
|
||||
{#each assetOptions as opt}
|
||||
<!-- Asset paths may repeat; option selection belongs to the parent value. Keep positional reuse. -->
|
||||
{#each assetOptions as opt, i (i)}
|
||||
<option value={opt.path}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -392,7 +424,8 @@
|
||||
}}
|
||||
>
|
||||
<option value="">{t('cdf.chooseAsset')}</option>
|
||||
{#each assetOptions as opt}
|
||||
<!-- Asset paths may repeat; option selection belongs to the parent value. Keep positional reuse. -->
|
||||
{#each assetOptions as opt, i (i)}
|
||||
<option value={opt.path}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -423,7 +456,8 @@
|
||||
}}
|
||||
>
|
||||
<option value="">{t('cdf.chooseAsset')}</option>
|
||||
{#each assetOptions as opt}
|
||||
<!-- Asset paths may repeat; option selection belongs to the parent value. Keep positional reuse. -->
|
||||
{#each assetOptions as opt, i (i)}
|
||||
<option value={opt.path}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -445,6 +479,7 @@
|
||||
{id}
|
||||
type="text"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={current}
|
||||
@@ -453,7 +488,7 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if errored}
|
||||
<span class="error-msg" role="alert">{t('cdf.requiredMsg', { label: inp.label })}</span>
|
||||
<span class="error-msg" role="alert">{extra ?? t('cdf.requiredMsg', { label: inp.label })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -491,7 +526,7 @@
|
||||
}
|
||||
.role {
|
||||
font-weight: 600;
|
||||
color: var(--accent, #a78bfa);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
margin-left: 6px;
|
||||
font-family: ui-monospace, monospace;
|
||||
@@ -516,15 +551,27 @@
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
input.changed,
|
||||
textarea.changed,
|
||||
select.changed {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
input.invalid,
|
||||
textarea.invalid,
|
||||
select.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.orig {
|
||||
float: right;
|
||||
font-weight: 400;
|
||||
font-size: var(--fs-mini);
|
||||
font-family: var(--mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
input.invalid:focus-visible,
|
||||
textarea.invalid:focus-visible,
|
||||
select.invalid:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.25);
|
||||
box-shadow: 0 0 0 3px var(--err-soft);
|
||||
}
|
||||
.error-msg {
|
||||
font-size: 11px;
|
||||
@@ -564,7 +611,7 @@
|
||||
}
|
||||
.clear:hover {
|
||||
color: var(--error);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.clear:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
background: rgba(18, 18, 24, 0.78);
|
||||
background: var(--overlay);
|
||||
backdrop-filter: blur(2px);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
|
||||
interface Props {
|
||||
src: string | null;
|
||||
@@ -61,6 +63,17 @@
|
||||
}
|
||||
return alt ? `${alt.trim().replace(/\s+/g, '-').toLowerCase()}.png` : 'media';
|
||||
});
|
||||
|
||||
let frameBusy = $state(false);
|
||||
async function exportTail() {
|
||||
if (!src || frameBusy) return;
|
||||
frameBusy = true;
|
||||
try {
|
||||
await exportLastFrame(src);
|
||||
} finally {
|
||||
frameBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={src ? onKeydown : undefined} />
|
||||
@@ -83,17 +96,40 @@
|
||||
<button
|
||||
class="tool"
|
||||
type="button"
|
||||
aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
title={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
title={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
onclick={() => (zoomed = !zoomed)}
|
||||
>
|
||||
<Icon name="zoom-in" size={17} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if isVideo}
|
||||
<button
|
||||
class="tool"
|
||||
type="button"
|
||||
aria-label={frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
|
||||
title={frameBusy
|
||||
? t('lastFrame.exporting')
|
||||
: `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
|
||||
disabled={frameBusy}
|
||||
aria-busy={frameBusy}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
exportTail();
|
||||
}}
|
||||
>
|
||||
{#if frameBusy}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<Icon name="image" size={17} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<a
|
||||
class="tool"
|
||||
href={src}
|
||||
download={downloadName}
|
||||
rel="external"
|
||||
aria-label={t('lightbox.downloadMedia')}
|
||||
title={t('lightbox.download')}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
@@ -146,9 +182,8 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
{alt}
|
||||
kind="video"
|
||||
label={t('lightbox.videoUnavailable')}
|
||||
autoplay
|
||||
controls
|
||||
preload="auto"
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -174,7 +209,7 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
{:else}
|
||||
{t('lightbox.clickToZoom')} · {t('lightbox.closeHint')} ·
|
||||
{/if}
|
||||
<a href={src} target="_blank" rel="noopener" onclick={(e) => e.stopPropagation()}
|
||||
<a href={src} target="_blank" rel="noopener external" onclick={(e) => e.stopPropagation()}
|
||||
>{t('lightbox.openOriginal')}</a
|
||||
>
|
||||
</p>
|
||||
@@ -204,7 +239,7 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
}
|
||||
.counter {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
color: color-mix(in srgb, var(--on-media) 65%, transparent);
|
||||
margin-right: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -216,13 +251,13 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, var(--on-media) 12%, transparent);
|
||||
color: var(--on-media);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.tool:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
background: color-mix(in srgb, var(--on-media) 22%, transparent);
|
||||
}
|
||||
.tool:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -239,13 +274,13 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
background: color-mix(in srgb, var(--on-media) 10%, transparent);
|
||||
color: var(--on-media);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.nav:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
background: color-mix(in srgb, var(--on-media) 22%, transparent);
|
||||
}
|
||||
.nav:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -301,16 +336,16 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
.caption {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
color: color-mix(in srgb, var(--on-media) 85%, transparent);
|
||||
max-width: min(96vw, 720px);
|
||||
text-align: center;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
color: color-mix(in srgb, var(--on-media) 55%, transparent);
|
||||
}
|
||||
.hint a {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
color: color-mix(in srgb, var(--on-media) 85%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { jobsApi, type Job } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { toast } from '$lib/toast';
|
||||
import Button from './ui/Button.svelte';
|
||||
import Icon from './ui/Icon.svelte';
|
||||
import type { IconName } from './ui/icons';
|
||||
import ProgressBar from './ui/ProgressBar.svelte';
|
||||
import StatusChip from './ui/StatusChip.svelte';
|
||||
|
||||
interface Props {
|
||||
job: Job;
|
||||
/** Optional override for the leading label (defaults to "Kind #id"). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { job, label }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
let busy = $state(false);
|
||||
|
||||
const kindIcon: IconName = $derived(
|
||||
job.kind === 'video'
|
||||
? 'video'
|
||||
: job.kind === 'image'
|
||||
? 'image'
|
||||
: job.kind === 'export'
|
||||
? 'film'
|
||||
: 'queue',
|
||||
);
|
||||
const kindLabel = $derived(
|
||||
job.kind === 'export'
|
||||
? t('job.exportFilm')
|
||||
: job.kind.charAt(0).toUpperCase() + job.kind.slice(1),
|
||||
);
|
||||
const title = $derived(label ?? t('job.title', { kind: kindLabel, id: String(job.id) }));
|
||||
const active = $derived(job.status === 'pending' || job.status === 'running');
|
||||
const entry = $derived(progressFor(job.id));
|
||||
|
||||
async function refresh() {
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
busy = true;
|
||||
try {
|
||||
await jobsApi.cancel(job.id);
|
||||
await refresh();
|
||||
toast.info(t('job.cancelled', { id: String(job.id) }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('job.cancelFailed'));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
busy = true;
|
||||
try {
|
||||
await jobsApi.retry(job.id);
|
||||
await refresh();
|
||||
toast.success(t('job.requeued', { id: String(job.id) }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('job.retryFailed'));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="job-row">
|
||||
<span class="kind" aria-hidden="true"><Icon name={kindIcon} size={14} /></span>
|
||||
<div class="main">
|
||||
<div class="top">
|
||||
<span class="label" {title}>{title}</span>
|
||||
<StatusChip status={job.status} />
|
||||
</div>
|
||||
{#if active}
|
||||
<div class="bar">
|
||||
<ProgressBar
|
||||
size="sm"
|
||||
value={entry?.progress ?? 0}
|
||||
indeterminate={job.status === 'running' && entry == null}
|
||||
label={entry?.message}
|
||||
/>
|
||||
</div>
|
||||
{:else if job.status === 'failed' && job.error}
|
||||
<p class="err" title={job.error}>{job.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if active}
|
||||
<Button size="sm" variant="ghost" loading={busy} title={t('job.cancelJob')} onclick={cancel}>
|
||||
<Icon name="close" size={13} />
|
||||
</Button>
|
||||
{:else if job.status === 'failed'}
|
||||
<Button size="sm" variant="ghost" loading={busy} title={t('job.retryJob')} onclick={retry}>
|
||||
<Icon name="retry" size={13} />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.job-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.kind {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 20px;
|
||||
}
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bar {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.err {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--error);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { language, setLanguage, type Language } from '$lib/i18n.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { language, setLanguage, type Language } from "$lib/i18n.svelte";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
let { segmented = false }: { segmented?: boolean } = $props();
|
||||
const languages: [Language, string][] = [
|
||||
["zh", "中文"],
|
||||
["en", "English"],
|
||||
["ja", "日本語"],
|
||||
["ko", "한국어"],
|
||||
["de", "Deutsch"],
|
||||
["es", "Español"],
|
||||
["fr", "Français"],
|
||||
];
|
||||
</script>
|
||||
|
||||
<select
|
||||
class="lang-switcher"
|
||||
value={language.current}
|
||||
aria-label={t('common.language')}
|
||||
onchange={(e) => setLanguage(e.currentTarget.value as Language)}
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="ja">日本語</option>
|
||||
<option value="ko">한국어</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
{#if segmented}<div class="mg-options" aria-label={t("common.language")}>
|
||||
{#each languages as [code, label] (code)}<button
|
||||
class="mg-option"
|
||||
class:on={language.current === code}
|
||||
aria-pressed={language.current === code}
|
||||
onclick={() => setLanguage(code)}>{label}</button
|
||||
>{/each}
|
||||
</div>{:else}
|
||||
<select
|
||||
class="lang-switcher"
|
||||
value={language.current}
|
||||
aria-label={t("common.language")}
|
||||
onchange={(e) => setLanguage(e.currentTarget.value as Language)}
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="ja">日本語</option>
|
||||
<option value="ko">한국어</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.lang-switcher {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
min-height: 34px;
|
||||
}
|
||||
.lang-switcher:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
.lang-switcher {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
min-height: 34px;
|
||||
}
|
||||
.lang-switcher:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
|
||||
const items = $derived(
|
||||
[
|
||||
{ id: 'story', key: 'nav.story', icon: 'story' },
|
||||
{ id: 'assets', key: 'nav.assets', icon: 'assets' },
|
||||
{ id: 'script', key: 'nav.script', icon: 'script' },
|
||||
{ id: 'video', key: 'nav.video', icon: 'video' },
|
||||
] as { id: string; key: string; icon: IconName }[],
|
||||
);
|
||||
|
||||
interface Props {
|
||||
active?: string;
|
||||
onSelect?: (id: string) => void;
|
||||
/** Optional per-stage counts — a nonzero value shows a small pill on the icon. */
|
||||
badges?: Record<string, number>;
|
||||
}
|
||||
|
||||
let { active = 'story', onSelect, badges = {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<nav class="nav" aria-label={t('nav.projectStages')}>
|
||||
<button
|
||||
class="nav-item home"
|
||||
title={t('nav.projects')}
|
||||
aria-label={t('nav.backProjects')}
|
||||
onclick={() => goto('/projects')}
|
||||
>
|
||||
<span class="icon"><Icon name="home" size={20} /></span>
|
||||
<span class="label">{t('nav.home')}</span>
|
||||
</button>
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
{#each items as item (item.id)}
|
||||
{@const count = badges[item.id] ?? 0}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={active === item.id}
|
||||
title={t(item.key)}
|
||||
aria-current={active === item.id ? 'page' : undefined}
|
||||
onclick={() => onSelect?.(item.id)}
|
||||
>
|
||||
<span class="icon">
|
||||
<Icon name={item.icon} size={20} />
|
||||
{#if count > 0}
|
||||
<span class="badge" aria-label={t('nav.countActive', { count })}>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="label">{t(item.key)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.nav {
|
||||
width: 64px;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-md) 0;
|
||||
gap: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.divider {
|
||||
width: 32px;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 2px 0 4px;
|
||||
}
|
||||
.nav-item {
|
||||
width: 56px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
box-shadow: 0 0 16px var(--accent-glow);
|
||||
}
|
||||
.nav-item.home:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.nav-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
line-height: 1;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -12px;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 0 2px var(--bg-surface);
|
||||
}
|
||||
.nav-item.active .badge {
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,7 @@
|
||||
import { createUploadManager } from '$lib/comfy/useUpload.svelte';
|
||||
import { normalizeInputRole } from '$lib/comfy/parser';
|
||||
import type { AssetOption } from '$lib/assetPicker';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import '$lib/api';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import PillSelect from './omni/PillSelect.svelte';
|
||||
@@ -504,9 +504,9 @@
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
border-radius: 9999px;
|
||||
background: var(--success, #22c55e);
|
||||
background: var(--ok);
|
||||
border: none;
|
||||
color: #000;
|
||||
color: var(--accent-ink);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-body);
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { assetUrl, projects, type Project } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
const { project }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
const stats = $derived(
|
||||
project.stats ?? {
|
||||
scene_count: 0,
|
||||
character_count: 0,
|
||||
asset_ready_count: 0,
|
||||
asset_total_count: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// Explicit project cover wins; otherwise fall back to the first asset image.
|
||||
// The project prop is stable for the lifetime of a card, so the eager
|
||||
// queryKey capture below is intentional.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const assetsQuery = createQuery({
|
||||
queryKey: ['assets', project.id],
|
||||
queryFn: () => projects.getAssets(project.id),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const thumbUrl = $derived.by(() => {
|
||||
if (project.cover_path) return assetUrl(project.cover_path);
|
||||
const data = $assetsQuery.data;
|
||||
if (!data) return null;
|
||||
const char = data.characters.find((c) => c.portrait_path || c.sheet_path);
|
||||
const path =
|
||||
char?.portrait_path ??
|
||||
char?.sheet_path ??
|
||||
data.locations.find((l) => l.reference_image_path)?.reference_image_path ??
|
||||
null;
|
||||
return assetUrl(path);
|
||||
});
|
||||
|
||||
// A broken cover (deleted file, 404) falls back to the gradient tile.
|
||||
let thumbFailedFor = $state<string | null>(null);
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
|
||||
if (mins < 1) return t('projectCard.timeJustNow');
|
||||
if (mins < 60) return t('projectCard.timeM', { count: mins });
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return t('projectCard.timeH', { count: hours });
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 30) return t('projectCard.timeD', { count: days });
|
||||
const months = Math.round(days / 30);
|
||||
if (months < 12) return t('projectCard.timeMo', { count: months });
|
||||
return t('projectCard.timeY', { count: Math.round(months / 12) });
|
||||
}
|
||||
|
||||
const updatedLabel = $derived(relativeTime(project.updated_at));
|
||||
const updatedTitle = $derived(new Date(project.updated_at).toLocaleString());
|
||||
|
||||
// Overflow menu (⋯) — closes on outside click, focus leaving, or Esc.
|
||||
let menuOpen = $state(false);
|
||||
let menuEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') menuOpen = false;
|
||||
};
|
||||
const onFocusIn = (e: FocusEvent) => {
|
||||
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
|
||||
};
|
||||
window.addEventListener('click', onDocClick);
|
||||
window.addEventListener('keydown', onKey);
|
||||
document.addEventListener('focusin', onFocusIn);
|
||||
return () => {
|
||||
window.removeEventListener('click', onDocClick);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('focusin', onFocusIn);
|
||||
};
|
||||
});
|
||||
|
||||
let renameOpen = $state(false);
|
||||
let renameValue = $state('');
|
||||
|
||||
const renameMutation = createMutation({
|
||||
mutationFn: (title: string) => projects.update(project.id, { title }),
|
||||
onSuccess: (updated) => {
|
||||
client.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success(t('projectCard.renamedTo', { title: updated.title }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.renameFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
function openRename() {
|
||||
menuOpen = false;
|
||||
renameValue = project.title;
|
||||
renameOpen = true;
|
||||
}
|
||||
|
||||
function submitRename() {
|
||||
const title = renameValue.trim();
|
||||
renameOpen = false;
|
||||
if (!title || title === project.title) return;
|
||||
$renameMutation.mutate(title);
|
||||
}
|
||||
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
const deleteMutation = createMutation({
|
||||
mutationFn: () => projects.delete(project.id),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success(t('projectCard.deleted', { title: project.title }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.deleteFailed'));
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<article class="project-card">
|
||||
<button
|
||||
type="button"
|
||||
class="card-main"
|
||||
aria-label={t('projectCard.open', { title: project.title })}
|
||||
onclick={() => goto(`/project/${project.id}`)}
|
||||
>
|
||||
<div class="card-thumb">
|
||||
{#if thumbUrl && thumbFailedFor !== thumbUrl}
|
||||
<img
|
||||
class="thumb-img"
|
||||
src={thumbUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror={() => (thumbFailedFor = thumbUrl)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="card-thumb-icon"><Icon name="video" size={26} /></div>
|
||||
{/if}
|
||||
<div class="card-status">
|
||||
<StatusChip status={project.status} label={t(`projectCard.status.${project.status}`)} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
{project.title}
|
||||
<span class="open-arrow">→</span>
|
||||
</div>
|
||||
<p class="card-desc">{project.idea || t('projectCard.noIdea')}</p>
|
||||
<div class="card-meta">
|
||||
<div class="card-stats">
|
||||
<span class="card-stat">
|
||||
<Icon name="script" size={13} />
|
||||
{t('projectCard.scenes', { count: stats.scene_count })}
|
||||
</span>
|
||||
<span class="card-stat">
|
||||
<Icon name="assets" size={13} />
|
||||
{t('projectCard.characters', { count: stats.character_count })}
|
||||
</span>
|
||||
<span class="card-stat">
|
||||
<Icon name="image" size={13} />
|
||||
{t('projectCard.assets', {
|
||||
ready: stats.asset_ready_count,
|
||||
total: stats.asset_total_count,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<span title={updatedTitle}>{updatedLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="menu-wrap" bind:this={menuEl}>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-btn"
|
||||
aria-label={t('projectCard.actions', { title: project.title })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div class="menu" role="menu" aria-label={t('projectCard.menuLabel')}>
|
||||
<button type="button" role="menuitem" class="menu-item" onclick={openRename}>
|
||||
<Icon name="edit" size={14} />
|
||||
{t('common.rename')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="menu-item"
|
||||
onclick={() => {
|
||||
menuOpen = false;
|
||||
goto(`/project/${project.id}?stage=assets`);
|
||||
}}
|
||||
>
|
||||
<Icon name="image" size={14} />
|
||||
{t('projectCard.changeCover')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="menu-item danger"
|
||||
onclick={() => {
|
||||
menuOpen = false;
|
||||
deleteOpen = true;
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<Modal bind:open={renameOpen} title={t('projectCard.renameTitle')}>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitRename();
|
||||
}}
|
||||
>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('common.title')}</span>
|
||||
<input class="field-input" bind:value={renameValue} required />
|
||||
</label>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (renameOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!renameValue.trim() || $renameMutation.isPending}
|
||||
onclick={submitRename}
|
||||
>
|
||||
{t('common.rename')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('projectCard.deleteTitle')}
|
||||
message={t('projectCard.deleteMessage', { title: project.title })}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onconfirm={() => $deleteMutation.mutate()}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.project-card {
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.project-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.project-card:hover .open-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.card-main {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-main:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.card-thumb {
|
||||
height: 170px;
|
||||
background: linear-gradient(135deg, var(--bg-elevated) 0%, #1a1625 100%);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-thumb::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(139, 92, 246, 0.12) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, rgba(59, 130, 246, 0.08) 0%, transparent 40%);
|
||||
}
|
||||
.thumb-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.card-thumb-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.card-status {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
.card-body {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-xs);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.open-arrow {
|
||||
color: var(--accent);
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
}
|
||||
.card-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 var(--space-md);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.menu-wrap {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 3;
|
||||
}
|
||||
.menu-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(10, 10, 12, 0.55);
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.project-card:hover .menu-btn,
|
||||
.menu-btn:focus-visible,
|
||||
.menu-btn[aria-expanded='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
.menu-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.menu-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
left: 0;
|
||||
min-width: 140px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.menu-item.danger {
|
||||
color: var(--error);
|
||||
}
|
||||
.menu-item.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.project-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,10 +95,10 @@
|
||||
rgba(255, 255, 255, 0.03) 6px,
|
||||
rgba(255, 255, 255, 0.03) 12px
|
||||
),
|
||||
var(--bg-elevated, #16161d);
|
||||
border: 1px dashed var(--border, #2a2a35);
|
||||
var(--bg-elevated);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: inherit;
|
||||
color: var(--text-muted, #8b8b9a);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
@@ -1,867 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { assetUrl, jobsApi, projects, type Job, type Scene, type Clip } from '$lib/api';
|
||||
import { estimateTargetSeconds } from '$lib/durationBudget';
|
||||
import { agentDeepLink } from '$lib/agentTasks';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
let { projectId }: Props = $props();
|
||||
const client = useQueryClient();
|
||||
let editing = $state<Scene | null>(null);
|
||||
let editOpen = $state(false);
|
||||
let highlightId = $state<number | null>(null);
|
||||
let adding = $state(false);
|
||||
let deletingId = $state<number | null>(null);
|
||||
let sceneToDelete = $state<Scene | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
const scenesQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['scenes', projectId],
|
||||
queryFn: () => projects.getScenes(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
// Shared cache key with QueueStage — clip job state shows up on scene cards
|
||||
// too. Refreshed via SSE invalidation from the project page, not polling.
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs', projectId],
|
||||
queryFn: () => jobsApi.list(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
// Cache-shared with the parent's story query — only used for the target-length hint.
|
||||
const storyQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['story', projectId],
|
||||
queryFn: () => projects.getStory(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
// Cache-shared with AssetsStage — resolves scene location chips.
|
||||
const assetsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['assets', projectId],
|
||||
queryFn: () => projects.getAssets(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
const scenes = $derived($scenesQuery.data?.scenes ?? []);
|
||||
const sceneCount = $derived(scenes.length);
|
||||
const totalClips = $derived(scenes.reduce((n, s) => n + (s.clips?.length ?? 0), 0));
|
||||
const totalSec = $derived($scenesQuery.data?.estimated_duration_sec ?? 0);
|
||||
const targetSec = $derived.by(() => {
|
||||
const target = $storyQuery.data?.project?.target_duration;
|
||||
return target ? estimateTargetSeconds(target) : 0;
|
||||
});
|
||||
|
||||
const busy = $derived(adding || deletingId != null);
|
||||
|
||||
// ── Break into shots (coverage expansion) ────────────────────────────
|
||||
let expandingIds = $state<number[]>([]);
|
||||
let expandingAll = $state(false);
|
||||
let expandedIds = $state<number[]>([]);
|
||||
|
||||
function isExpanded(scene: Scene): boolean {
|
||||
return (scene.clips?.length ?? 0) > 1 || expandedIds.includes(scene.id);
|
||||
}
|
||||
|
||||
async function breakIntoShots(scene: Scene) {
|
||||
if (expandingIds.length > 0 || expandingAll) return;
|
||||
expandingIds = [...expandingIds, scene.id];
|
||||
try {
|
||||
const res = await projects.expandClips(projectId, { scene_ids: [scene.id] });
|
||||
expandedIds = [...new Set([...expandedIds, scene.id])];
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(
|
||||
t('script.toast.breakIntoShots', {
|
||||
num: scene.order_index,
|
||||
clips: res.scenes?.[0]?.clip_count ?? '?',
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.breakFail'));
|
||||
} finally {
|
||||
expandingIds = expandingIds.filter((id) => id !== scene.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function breakAllIntoShots() {
|
||||
if (expandingIds.length > 0 || expandingAll) return;
|
||||
if (
|
||||
!window.confirm(t('script.toast.breakAllConfirm', { count: sceneCount }))
|
||||
)
|
||||
return;
|
||||
expandingAll = true;
|
||||
try {
|
||||
const res = await projects.expandClips(projectId, { all: true });
|
||||
expandedIds = scenes.map((s) => s.id);
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.boardExpanded', { count: res.total_clips }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.breakAllFail'));
|
||||
} finally {
|
||||
expandingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClip(clip: Clip, scene: Scene) {
|
||||
try {
|
||||
await projects.deleteClip(projectId, clip.id);
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.deletedShot', { num: `${scene.order_index}.${clip.order_index}` }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.deleteClipFail'));
|
||||
}
|
||||
}
|
||||
|
||||
const saveMutation = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateScene(projectId, editing!.id, {
|
||||
heading: editing!.heading,
|
||||
action: editing!.action,
|
||||
dialog: editing!.dialog,
|
||||
duration_sec: editing!.duration_sec,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editing = null;
|
||||
editOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.sceneSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.saveSceneFail'));
|
||||
},
|
||||
});
|
||||
|
||||
// Chain-from-previous toggle — persists on the scene; consumed at render time.
|
||||
let chainPendingId = $state<number | null>(null);
|
||||
async function toggleChain(scene: Scene) {
|
||||
if (chainPendingId != null) return;
|
||||
chainPendingId = scene.id;
|
||||
try {
|
||||
await projects.updateScene(projectId, scene.id, {
|
||||
chain_from_prev: !scene.chain_from_prev,
|
||||
});
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.updateSceneFail'));
|
||||
} finally {
|
||||
chainPendingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function jobForScene(sceneId: number): Job | undefined {
|
||||
const jobs = ($jobsQuery.data ?? []).filter(
|
||||
(j) => j.scene_id === sceneId && j.kind === 'video',
|
||||
);
|
||||
if (jobs.length === 0) return undefined;
|
||||
return [...jobs].sort((a, b) => b.id - a.id)[0];
|
||||
}
|
||||
|
||||
function jobForClip(clipId: number): Job | undefined {
|
||||
const jobs = ($jobsQuery.data ?? []).filter(
|
||||
(j) => (j as Job & { clip_id?: number | null }).clip_id === clipId && j.kind === 'video',
|
||||
);
|
||||
if (jobs.length === 0) return undefined;
|
||||
return [...jobs].sort((a, b) => b.id - a.id)[0];
|
||||
}
|
||||
|
||||
/** Per-scene production status — only uses fields the API actually exposes. */
|
||||
function sceneStatus(scene: Scene): { status: string; label: string } {
|
||||
const job = jobForScene(scene.id);
|
||||
if (job && (job.status === 'pending' || job.status === 'running')) {
|
||||
return { status: 'generating', label: t('script.status.rendering') };
|
||||
}
|
||||
if (job?.status === 'failed') return { status: 'failed', label: t('script.status.renderFailed') };
|
||||
if (scene.video_path || job?.status === 'done') {
|
||||
return { status: 'ready', label: t('script.status.clipReady') };
|
||||
}
|
||||
return { status: 'idle', label: t('script.status.noClip') };
|
||||
}
|
||||
|
||||
function locationName(scene: Scene): string | null {
|
||||
if (scene.location_id == null) return null;
|
||||
const locs = $assetsQuery.data?.locations ?? [];
|
||||
return locs.find((l) => l.id === scene.location_id)?.name ?? null;
|
||||
}
|
||||
|
||||
function avatarFor(char: { portrait_path: string | null; sheet_path: string | null }) {
|
||||
return assetUrl(char.portrait_path ?? char.sheet_path);
|
||||
}
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0].toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Hide a broken avatar image (portrait moved/deleted on disk), revealing initials. */
|
||||
function hideBrokenAvatar(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement | null;
|
||||
if (img) img.style.display = 'none';
|
||||
}
|
||||
|
||||
function formatClock(sec: number): string {
|
||||
const s = Math.max(0, Math.round(sec));
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${r.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function move(sceneId: number, dir: -1 | 1) {
|
||||
if (busy) return;
|
||||
const list = $scenesQuery.data?.scenes ?? [];
|
||||
const idx = list.findIndex((s) => s.id === sceneId);
|
||||
const swap = idx + dir;
|
||||
if (idx < 0 || swap < 0 || swap >= list.length) return;
|
||||
const ids = list.map((s) => s.id);
|
||||
[ids[idx], ids[swap]] = [ids[swap], ids[idx]];
|
||||
await projects.reorderScenes(projectId, ids);
|
||||
client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
}
|
||||
|
||||
/** Hand off script regeneration to a fresh, project-linked agent chat. */
|
||||
function requestRegenerate() {
|
||||
if (busy) return;
|
||||
goto(agentDeepLink(projectId, 'script'));
|
||||
}
|
||||
|
||||
function openEdit(scene: Scene) {
|
||||
editing = { ...scene };
|
||||
editOpen = true;
|
||||
}
|
||||
|
||||
async function addScene() {
|
||||
if (busy) return;
|
||||
adding = true;
|
||||
try {
|
||||
const existing = $scenesQuery.data?.scenes ?? [];
|
||||
const nextIndex = existing.length + 1;
|
||||
const created = await projects.createScene(projectId, {
|
||||
order_index: nextIndex,
|
||||
heading: `NEW SCENE ${nextIndex}`,
|
||||
action: '',
|
||||
dialog: '',
|
||||
duration_sec: 5,
|
||||
});
|
||||
// Keep order_index contiguous 1..n so Regenerate sees the real board size
|
||||
const ids = [...existing.map((s) => s.id), created.id];
|
||||
await projects.reorderScenes(projectId, ids);
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
await client.refetchQueries({ queryKey: ['scenes', projectId] });
|
||||
editing = { ...created, order_index: nextIndex, heading: `NEW SCENE ${nextIndex}` };
|
||||
editOpen = true;
|
||||
highlightId = created.id;
|
||||
await tick();
|
||||
document
|
||||
.getElementById(`scene-${created.id}`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
toast.success(t('script.toast.sceneAdded', { num: nextIndex }));
|
||||
window.setTimeout(() => {
|
||||
if (highlightId === created.id) highlightId = null;
|
||||
}, 2800);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.addSceneFail'));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestDelete(scene: Scene) {
|
||||
if (busy) return;
|
||||
sceneToDelete = scene;
|
||||
deleteOpen = true;
|
||||
}
|
||||
|
||||
async function confirmDeleteScene() {
|
||||
const scene = sceneToDelete;
|
||||
sceneToDelete = null;
|
||||
if (!scene || busy) return;
|
||||
const label = scene.heading?.trim() || `Scene #${scene.order_index}`;
|
||||
deletingId = scene.id;
|
||||
try {
|
||||
await projects.deleteScene(projectId, scene.id);
|
||||
if (editing?.id === scene.id) {
|
||||
editing = null;
|
||||
editOpen = false;
|
||||
}
|
||||
const remaining = ($scenesQuery.data?.scenes ?? [])
|
||||
.filter((s) => s.id !== scene.id)
|
||||
.map((s) => s.id);
|
||||
if (remaining.length > 0) {
|
||||
await projects.reorderScenes(projectId, remaining);
|
||||
}
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.deleted', { name: label }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.deleteFailed'));
|
||||
} finally {
|
||||
deletingId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="stage-header">
|
||||
<div>
|
||||
<h2>3. Script</h2>
|
||||
<p class="muted">
|
||||
Estimated duration: {formatClock(totalSec)}
|
||||
{#if targetSec > 0}
|
||||
/ target ~{formatClock(targetSec)}
|
||||
{/if}
|
||||
{#if sceneCount > 0}
|
||||
· {sceneCount} scenes
|
||||
{#if totalClips > 0}
|
||||
/ {totalClips} shot clips
|
||||
{/if}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="stage-actions">
|
||||
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
|
||||
<Icon name="plus" size={14} /> {t('script.addScene')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy || expandingAll || expandingIds.length > 0 || sceneCount === 0}
|
||||
loading={expandingAll}
|
||||
onclick={breakAllIntoShots}
|
||||
title={t('script.breakAllTitle')}
|
||||
>
|
||||
<Icon name="film" size={14} /> {t('script.breakAllIntoShots')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={busy} onclick={requestRegenerate}>
|
||||
<Icon name="sparkle" size={15} /> {t('script.regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="script-panel">
|
||||
{#if $scenesQuery.isLoading}
|
||||
<div class="card">{t('script.loadingScenes')}</div>
|
||||
{:else if sceneCount === 0}
|
||||
<EmptyState
|
||||
title={t('script.noScenesTitle')}
|
||||
body={t('script.noScenesBody')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Icon name="script" size={28} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
|
||||
<Icon name="plus" size={14} /> {t('script.addScene')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
{#each scenes as scene, i (scene.id)}
|
||||
{@const st = sceneStatus(scene)}
|
||||
{@const locName = locationName(scene)}
|
||||
<article
|
||||
id="scene-{scene.id}"
|
||||
class="card scene"
|
||||
class:fresh={highlightId === scene.id}
|
||||
class:deleting={deletingId === scene.id}
|
||||
>
|
||||
<div class="scene-head">
|
||||
<div class="scene-title">
|
||||
<span class="grip" aria-hidden="true"><Icon name="drag" size={14} /></span>
|
||||
<span class="num">#{scene.order_index}</span>
|
||||
<strong>{scene.heading || t('script.untitledScene')}</strong>
|
||||
{#if highlightId === scene.id}
|
||||
<span class="fresh-tag">{t('script.justAdded')}</span>
|
||||
{/if}
|
||||
<StatusChip status={st.status} label={st.label} />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('script.moveSceneUp')}
|
||||
title={t('script.moveSceneUp')}
|
||||
disabled={busy || i === 0}
|
||||
onclick={() => move(scene.id, -1)}
|
||||
>
|
||||
<Icon name="chevron-up" size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('script.moveSceneDown')}
|
||||
title={t('script.moveSceneDown')}
|
||||
disabled={busy || i === sceneCount - 1}
|
||||
onclick={() => move(scene.id, 1)}
|
||||
>
|
||||
<Icon name="chevron-down" size={15} />
|
||||
</button>
|
||||
<Button variant="secondary" size="sm" disabled={busy} onclick={() => openEdit(scene)}>
|
||||
{t('script.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
loading={deletingId === scene.id}
|
||||
onclick={() => requestDelete(scene)}
|
||||
>
|
||||
{t('script.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if scene.action}
|
||||
<p class="muted">{scene.action}</p>
|
||||
{:else}
|
||||
<p class="muted empty-line">{t('script.noActionYet')}</p>
|
||||
{/if}
|
||||
{#if scene.dialog}
|
||||
<pre class="dialog">{scene.dialog}</pre>
|
||||
{/if}
|
||||
<div class="clip-block">
|
||||
<div class="clip-head">
|
||||
<span class="clip-title">
|
||||
<Icon name="film" size={12} />
|
||||
{(scene.clips ?? []).length}
|
||||
{((scene.clips ?? []).length === 1 ? t('script.shotClipSingular') : t('script.shotClipPlural', { n: (scene.clips ?? []).length }))}
|
||||
{#if (scene.clips ?? []).some((c) => c.clip_path)}
|
||||
· {t('script.renderedCount', { count: (scene.clips ?? []).filter((c) => c.clip_path).length })}
|
||||
{/if}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={busy || expandingIds.length > 0 || expandingAll}
|
||||
loading={expandingIds.includes(scene.id)}
|
||||
onclick={() => breakIntoShots(scene)}
|
||||
title={isExpanded(scene) ? t('script.rebreakTitle') : t('script.breakTitle')}
|
||||
>
|
||||
{isExpanded(scene) ? t('script.rebreak') : t('script.break')}
|
||||
</Button>
|
||||
</div>
|
||||
{#if isExpanded(scene) && (scene.clips ?? []).length > 1}
|
||||
<ul class="clip-list">
|
||||
{#each scene.clips ?? [] as clip (clip.id)}
|
||||
{@const clipJob = jobForClip(clip.id)}
|
||||
<li class="clip-row">
|
||||
<span class="clip-num" class:ready={Boolean(clip.clip_path)}>
|
||||
#{scene.order_index}.{clip.order_index}
|
||||
</span>
|
||||
<span class="clip-desc">{clip.description || t('script.untitledShot')}</span>
|
||||
{#if clip.shot_size}
|
||||
<span class="chip">{clip.shot_size}</span>
|
||||
{/if}
|
||||
{#if clipJob && (clipJob.status === 'pending' || clipJob.status === 'running')}
|
||||
<span class="chip chip-render">{t('script.rendering')}</span>
|
||||
{:else if clip.clip_path}
|
||||
<span class="chip chip-ok">✓ {t('script.clipDone')}</span>
|
||||
{/if}
|
||||
{#if clip.duration_sec}
|
||||
<span class="clip-dur">{clip.duration_sec}s</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('script.deleteShotAria', { id: `${scene.order_index}.${clip.order_index}` })}
|
||||
title={t('script.deleteShotTitle')}
|
||||
onclick={() => deleteClip(clip, scene)}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="chips">
|
||||
{#each scene.characters ?? [] as c (c.id)}
|
||||
{@const avatar = avatarFor(c)}
|
||||
<span class="chip">
|
||||
<span class="avatar">
|
||||
<span class="initials" aria-hidden="true">{initialsOf(c.name)}</span>
|
||||
{#if avatar}
|
||||
<img src={avatar} alt="" loading="lazy" onerror={hideBrokenAvatar} />
|
||||
{/if}
|
||||
</span>
|
||||
{c.name}
|
||||
</span>
|
||||
{/each}
|
||||
{#if locName}
|
||||
<span class="chip"><Icon name="folder" size={12} /> {locName}</span>
|
||||
{/if}
|
||||
{#if scene.duration_sec}
|
||||
<span class="chip"><Icon name="clock" size={12} /> {formatClock(scene.duration_sec)}</span>
|
||||
{/if}
|
||||
{#if i > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="chip chip-toggle"
|
||||
class:chip-on={Boolean(scene.chain_from_prev)}
|
||||
disabled={chainPendingId === scene.id}
|
||||
title={t('script.chainTitle')}
|
||||
onclick={() => toggleChain(scene)}
|
||||
>
|
||||
<Icon name="film" size={12} />
|
||||
{Boolean(scene.chain_from_prev) ? t('script.chainOn') : t('script.chainOff')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
bind:open={editOpen}
|
||||
title={editing ? t('script.editSceneNumbered', { n: editing.order_index }) : t('script.editScene')}
|
||||
onclose={() => (editing = null)}
|
||||
>
|
||||
{#if editing}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.headingField')}</span>
|
||||
<input class="field-input" bind:value={editing.heading} placeholder={t('script.headingPlaceholder')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.actionField')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={editing.action}
|
||||
rows="4"
|
||||
placeholder={t('script.actionPlaceholder')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.dialogField')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={editing.dialog}
|
||||
rows="4"
|
||||
placeholder={t('script.dialogPlaceholder')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.durationField')}</span>
|
||||
<input class="field-input" type="number" bind:value={editing.duration_sec} min="1" />
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (editOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={$saveMutation.isPending}
|
||||
onclick={() => $saveMutation.mutate()}
|
||||
>
|
||||
{t('script.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('script.deleteSceneTitle')}
|
||||
message={sceneToDelete
|
||||
? t('script.deleteSceneMessage', {
|
||||
heading: sceneToDelete.heading?.trim() || t('script.sceneNumber', { n: sceneToDelete.order_index }),
|
||||
})
|
||||
: ''}
|
||||
confirmLabel={t('script.delete')}
|
||||
danger
|
||||
onconfirm={() => void confirmDeleteScene()}
|
||||
oncancel={() => (sceneToDelete = null)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.stage-header {
|
||||
/* Pins inside the stage scroll container so Add Scene / Regenerate
|
||||
stay reachable while scrolling long scripts. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
padding: 8px 0 12px;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.stage-header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stage-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.script-panel {
|
||||
position: relative;
|
||||
min-height: 220px;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: var(--space-md);
|
||||
scroll-margin-top: 84px;
|
||||
scroll-margin-bottom: 24px;
|
||||
}
|
||||
.scene {
|
||||
transition:
|
||||
border-color 0.25s ease,
|
||||
box-shadow 0.25s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
.scene.fresh {
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--accent),
|
||||
0 0 24px var(--accent-glow);
|
||||
animation: scene-in 0.45s ease-out;
|
||||
}
|
||||
.scene.deleting {
|
||||
opacity: 0.45;
|
||||
}
|
||||
@keyframes scene-in {
|
||||
from {
|
||||
opacity: 0.35;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scene.fresh {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.scene-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.scene-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.grip {
|
||||
display: inline-flex;
|
||||
color: var(--text-muted);
|
||||
cursor: grab;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.num {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.fresh-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
background: var(--accent-glow);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.icon-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.empty-line {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dialog {
|
||||
background: var(--bg-elevated);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.chip-toggle {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.chip-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: #52525b;
|
||||
}
|
||||
.chip-toggle:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.chip-on {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-elevated));
|
||||
}
|
||||
.clip-block {
|
||||
margin-top: 10px;
|
||||
border-top: 1px dashed var(--border);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.clip-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.clip-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.clip-list {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.clip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.clip-num {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.clip-num.ready {
|
||||
color: var(--accent);
|
||||
}
|
||||
.clip-desc {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.clip-dur {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-ok {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.chip-render {
|
||||
color: var(--warning, #eab308);
|
||||
border-color: var(--warning, #eab308);
|
||||
}
|
||||
.avatar {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.avatar img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.initials {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,947 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
projects,
|
||||
type Beat,
|
||||
type Character,
|
||||
type Item,
|
||||
type Location,
|
||||
type StoryData,
|
||||
} from '$lib/api';
|
||||
import {
|
||||
buildStoryDraftPromptPreview,
|
||||
estimateTargetSeconds,
|
||||
recommendBeatCount,
|
||||
recommendSceneCount,
|
||||
} from '$lib/durationBudget';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { agentDeepLink } from '$lib/agentTasks';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
story: StoryData;
|
||||
configured: boolean;
|
||||
onGoAssets?: () => void;
|
||||
}
|
||||
|
||||
let { projectId, story, configured, onGoAssets }: Props = $props();
|
||||
const client = useQueryClient();
|
||||
|
||||
const GENRES = [
|
||||
'Adventure / Mystery',
|
||||
'Drama',
|
||||
'Sci-Fi',
|
||||
'Fantasy',
|
||||
'Horror',
|
||||
'Romance',
|
||||
'Thriller',
|
||||
];
|
||||
const LENGTHS_HINT = $derived(t('story.lengthsHint'));
|
||||
const TONES = [
|
||||
'Cinematic, atmospheric',
|
||||
'Dark, tense',
|
||||
'Whimsical, warm',
|
||||
'Gritty, realistic',
|
||||
'Epic, sweeping',
|
||||
];
|
||||
|
||||
const GENRE_KEYS: Record<string, string> = {
|
||||
'Adventure / Mystery': 'genres.adventure',
|
||||
Drama: 'genres.drama',
|
||||
'Sci-Fi': 'genres.scifi',
|
||||
Fantasy: 'genres.fantasy',
|
||||
Horror: 'genres.horror',
|
||||
Romance: 'genres.romance',
|
||||
Thriller: 'genres.thriller',
|
||||
};
|
||||
const TONE_KEYS: Record<string, string> = {
|
||||
'Cinematic, atmospheric': 'tones.cinematic',
|
||||
'Dark, tense': 'tones.dark',
|
||||
'Whimsical, warm': 'tones.whimsical',
|
||||
'Gritty, realistic': 'tones.gritty',
|
||||
'Epic, sweeping': 'tones.epic',
|
||||
};
|
||||
function genreLabel(v: string): string {
|
||||
return t(GENRE_KEYS[v] ?? v);
|
||||
}
|
||||
function toneLabel(v: string): string {
|
||||
return t(TONE_KEYS[v] ?? v);
|
||||
}
|
||||
|
||||
const EXAMPLE = {
|
||||
idea: 'A lone cartographer discovers a map that rewrites itself every midnight, leading her into a forgotten city beneath the desert.',
|
||||
genre: 'Adventure / Mystery',
|
||||
tone: 'Cinematic, atmospheric',
|
||||
target_duration: '2 minutes',
|
||||
};
|
||||
|
||||
let editingBeat = $state<Beat | null>(null);
|
||||
let editingChar = $state<Character | null>(null);
|
||||
let editingLoc = $state<Location | null>(null);
|
||||
let editingItem = $state<Item | null>(null);
|
||||
let beatModalOpen = $state(false);
|
||||
let charModalOpen = $state(false);
|
||||
let locModalOpen = $state(false);
|
||||
let itemModalOpen = $state(false);
|
||||
let confirmExampleOpen = $state(false);
|
||||
let deletingBeat = $state<Beat | null>(null);
|
||||
let beatDeleteOpen = $state(false);
|
||||
let addingBeat = $state(false);
|
||||
// Static defaults — the $effect below syncs all drafts from the story prop.
|
||||
let ideaDraft = $state('');
|
||||
let genreDraft = $state(GENRES[0]);
|
||||
let toneDraft = $state(TONES[0]);
|
||||
let lengthDraft = $state('2 minutes');
|
||||
let showDraftPrompt = $state(false);
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
let saveState = $state<SaveState>('idle');
|
||||
let savedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const beatBudget = $derived(recommendBeatCount(lengthDraft));
|
||||
const sceneBudget = $derived(recommendSceneCount(lengthDraft));
|
||||
const secsBudget = $derived(estimateTargetSeconds(lengthDraft));
|
||||
const draftPromptPreview = $derived(
|
||||
buildStoryDraftPromptPreview({
|
||||
title: story.project.title,
|
||||
idea: ideaDraft,
|
||||
genre: genreDraft,
|
||||
tone: toneDraft,
|
||||
targetDuration: lengthDraft,
|
||||
}),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
ideaDraft = story.project.idea ?? '';
|
||||
genreDraft = story.project.genre || GENRES[0];
|
||||
toneDraft = story.project.tone || TONES[0];
|
||||
lengthDraft = story.project.target_duration || '2 minutes';
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (savedTimer) clearTimeout(savedTimer);
|
||||
};
|
||||
});
|
||||
|
||||
function armSavedTimer() {
|
||||
if (savedTimer) clearTimeout(savedTimer);
|
||||
savedTimer = setTimeout(() => {
|
||||
if (saveState === 'saved') saveState = 'idle';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const saveProject = createMutation({
|
||||
mutationFn: (payload: {
|
||||
idea?: string;
|
||||
genre?: string;
|
||||
tone?: string;
|
||||
target_duration?: string;
|
||||
}) => projects.update(projectId, payload),
|
||||
onMutate: () => {
|
||||
saveState = 'saving';
|
||||
},
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
saveState = 'saved';
|
||||
armSavedTimer();
|
||||
},
|
||||
onError: (err) => {
|
||||
saveState = 'error';
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveSettingsFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
/** Autosave path — returns false on failure (mutation onError already toasted). */
|
||||
async function persistSettings(): Promise<boolean> {
|
||||
try {
|
||||
await $saveProject.mutateAsync({
|
||||
idea: ideaDraft,
|
||||
genre: genreDraft || undefined,
|
||||
tone: toneDraft || undefined,
|
||||
target_duration: lengthDraft || undefined,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExample() {
|
||||
ideaDraft = EXAMPLE.idea;
|
||||
genreDraft = EXAMPLE.genre;
|
||||
toneDraft = EXAMPLE.tone;
|
||||
lengthDraft = EXAMPLE.target_duration;
|
||||
const saved = await persistSettings();
|
||||
if (saved) toast.success(t('story.exampleLoaded'));
|
||||
}
|
||||
|
||||
function requestLoadExample() {
|
||||
if (ideaDraft.trim()) confirmExampleOpen = true;
|
||||
else void loadExample();
|
||||
}
|
||||
|
||||
/** Hand off to a fresh, project-linked agent chat with the composer pre-filled. */
|
||||
async function draftStoryline() {
|
||||
if (!configured) return;
|
||||
const saved = await persistSettings();
|
||||
if (!saved) return; // persistSettings already toasted the failure
|
||||
goto(agentDeepLink(projectId, 'story'));
|
||||
}
|
||||
|
||||
function openBeat(beat: Beat) {
|
||||
editingBeat = { ...beat };
|
||||
beatModalOpen = true;
|
||||
}
|
||||
|
||||
async function addBeat() {
|
||||
if (addingBeat) return;
|
||||
addingBeat = true;
|
||||
try {
|
||||
const nextIndex =
|
||||
story.beats.reduce((max, b) => Math.max(max, b.order_index), 0) + 1;
|
||||
// No api.ts binding exists yet for POST /beats — call the endpoint directly.
|
||||
const res = await fetch(`/api/projects/${projectId}/beats`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
order_index: nextIndex,
|
||||
title: t('story.newBeat', { n: nextIndex }),
|
||||
description: '',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => 'unknown error');
|
||||
throw new Error(`${res.status}: ${body}`);
|
||||
}
|
||||
const created = (await res.json()) as Beat;
|
||||
await client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.beatAdded', { index: nextIndex }));
|
||||
openBeat(created);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('story.addBeatFailed'));
|
||||
} finally {
|
||||
addingBeat = false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestDeleteBeat(beat: Beat) {
|
||||
deletingBeat = beat;
|
||||
beatDeleteOpen = true;
|
||||
}
|
||||
|
||||
const deleteBeatMutation = createMutation({
|
||||
mutationFn: (beatId: number) => projects.deleteBeat(projectId, beatId),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.beatDeleted'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.deleteBeatFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveBeat = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateBeat(projectId, editingBeat!.id, {
|
||||
title: editingBeat!.title,
|
||||
description: editingBeat!.description,
|
||||
order_index: editingBeat!.order_index,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingBeat = null;
|
||||
beatModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.beatSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveBeatFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveChar = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateCharacter(projectId, editingChar!.id, {
|
||||
name: editingChar!.name,
|
||||
role: editingChar!.role,
|
||||
age: editingChar!.age,
|
||||
appearance: editingChar!.appearance,
|
||||
personality: editingChar!.personality,
|
||||
consistency_prompt: editingChar!.consistency_prompt,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingChar = null;
|
||||
charModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.characterSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveCharacterFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveLoc = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateLocation(projectId, editingLoc!.id, {
|
||||
name: editingLoc!.name,
|
||||
description: editingLoc!.description,
|
||||
consistency_prompt: editingLoc!.consistency_prompt,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingLoc = null;
|
||||
locModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.locationSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveLocationFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveItem = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateItem(projectId, editingItem!.id, {
|
||||
name: editingItem!.name,
|
||||
description: editingItem!.description,
|
||||
consistency_prompt: editingItem!.consistency_prompt,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingItem = null;
|
||||
itemModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.itemSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveItemFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
function goToAssets() {
|
||||
goto('?stage=assets', { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
function beatCountLabel(n: number): string {
|
||||
return t('story.beats', { n });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet saveIndicator()}
|
||||
{#if saveState === 'saving'}
|
||||
<span class="save-ind"><Spinner size="sm" /> {t('common.saving')}</span>
|
||||
{:else if saveState === 'saved'}
|
||||
<span class="save-ind saved"><Icon name="check" size={13} /> {t('common.saved')}</span>
|
||||
{:else if saveState === 'error'}
|
||||
<span class="save-ind err">
|
||||
<Icon name="alert" size={13} /> {t('common.saveFailed')}
|
||||
<Button variant="ghost" size="sm" onclick={() => void persistSettings()}>{t('common.retry')}</Button>
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<header class="stage-header">
|
||||
<h2>{t('story.stageTitle')}</h2>
|
||||
<div class="stage-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={$saveProject.isPending}
|
||||
onclick={requestLoadExample}
|
||||
>
|
||||
{t('story.loadExample')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!configured} onclick={() => void draftStoryline()}>
|
||||
<Icon name="sparkle" size={15} /> {t('story.draftStoryline')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if !configured}
|
||||
<div class="banner">{t('story.llmBanner')}</div>
|
||||
{/if}
|
||||
|
||||
<div class="stack-col">
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.ideaTitle')}</h3>
|
||||
{@render saveIndicator()}
|
||||
{/snippet}
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={ideaDraft}
|
||||
rows="5"
|
||||
placeholder={t('story.ideaPlaceholder')}
|
||||
onblur={() => void persistSettings()}
|
||||
></textarea>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.settingsTitle')}</h3>
|
||||
{@render saveIndicator()}
|
||||
{/snippet}
|
||||
<div class="grid-3">
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.genre')}</span>
|
||||
<select
|
||||
class="field-select"
|
||||
bind:value={genreDraft}
|
||||
onchange={() => void persistSettings()}
|
||||
>
|
||||
{#each GENRES as g}
|
||||
<option value={g}>{genreLabel(g)}</option>
|
||||
{/each}
|
||||
{#if genreDraft && !GENRES.includes(genreDraft)}
|
||||
<option value={genreDraft}>{genreLabel(genreDraft)}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.targetLength')}</span>
|
||||
<input
|
||||
class="field-input"
|
||||
type="text"
|
||||
bind:value={lengthDraft}
|
||||
placeholder={LENGTHS_HINT}
|
||||
onblur={() => void persistSettings()}
|
||||
/>
|
||||
<span class="field-hint beat-hint">
|
||||
{t('story.lengthHint', { secs: secsBudget, beats: beatBudget, scenes: sceneBudget })}
|
||||
</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.tone')}</span>
|
||||
<select
|
||||
class="field-select"
|
||||
bind:value={toneDraft}
|
||||
onchange={() => void persistSettings()}
|
||||
>
|
||||
{#each TONES as tone}
|
||||
<option value={tone}>{toneLabel(tone)}</option>
|
||||
{/each}
|
||||
{#if toneDraft && !TONES.includes(toneDraft)}
|
||||
<option value={toneDraft}>{toneLabel(toneDraft)}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.draftPromptTitle')}</h3>
|
||||
<Button variant="ghost" size="sm" onclick={() => (showDraftPrompt = !showDraftPrompt)}>
|
||||
{showDraftPrompt ? t('story.hidePrompt') : t('story.showPrompt')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#if showDraftPrompt}
|
||||
<pre class="prompt-preview">{draftPromptPreview}</pre>
|
||||
<p class="field-hint">
|
||||
{t('story.promptExplain')}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="field-hint prompt-collapsed">
|
||||
{t('story.promptCollapsedHint')}
|
||||
</p>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.beatsTitle')}</h3>
|
||||
<div class="head-actions">
|
||||
{#if story.beats.length}
|
||||
<StatusChip status="ready" label={beatCountLabel(story.beats.length)} />
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
|
||||
<Icon name="plus" size={14} /> {t('story.addBeat')}
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#if story.beats.length === 0}
|
||||
<EmptyState
|
||||
title={t('story.noBeats')}
|
||||
body={t('story.noBeatsBody')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Icon name="story" size={28} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
|
||||
<Icon name="plus" size={14} /> {t('story.addBeatManual')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="beat-list">
|
||||
{#each story.beats as beat (beat.id)}
|
||||
<div class="beat-row">
|
||||
<button type="button" class="beat" onclick={() => openBeat(beat)}>
|
||||
<div class="beat-number">{beat.order_index}</div>
|
||||
<div class="beat-body">
|
||||
<h4>{beat.title}</h4>
|
||||
<p>{beat.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
<div class="beat-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={t('story.deleteBeat', { index: beat.order_index })}
|
||||
onclick={() => requestDeleteBeat(beat)}
|
||||
>
|
||||
<Icon name="trash" size={14} /><span class="sr-only">{t('story.deleteBeat', { index: beat.order_index })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="beats-footer">
|
||||
<Button variant="primary" onclick={goToAssets}>
|
||||
{t('story.continueAssets')} <Icon name="chevron-right" size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
{#if story.characters.length || story.locations.length || story.items.length}
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.extracted')}</h3>
|
||||
<Button variant="ghost" size="sm" onclick={goToAssets}>
|
||||
{t('story.openAssets')} <Icon name="chevron-right" size={14} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="chips">
|
||||
{#each story.characters as char (char.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => {
|
||||
editingChar = { ...char };
|
||||
charModalOpen = true;
|
||||
}}
|
||||
title={char.appearance ?? ''}
|
||||
>
|
||||
<span class="chip-tag">{t('story.charTag')}</span>
|
||||
{char.name}
|
||||
</button>
|
||||
{/each}
|
||||
{#each story.locations as loc (loc.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => {
|
||||
editingLoc = { ...loc };
|
||||
locModalOpen = true;
|
||||
}}
|
||||
title={loc.description ?? ''}
|
||||
>
|
||||
<span class="chip-tag">{t('story.envTag')}</span>
|
||||
{loc.name}
|
||||
</button>
|
||||
{/each}
|
||||
{#each story.items as item (item.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => {
|
||||
editingItem = { ...item };
|
||||
itemModalOpen = true;
|
||||
}}
|
||||
title={item.description ?? ''}
|
||||
>
|
||||
<span class="chip-tag">{t('story.itemTag')}</span>
|
||||
{item.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:open={beatModalOpen} title={t('story.editBeatTitle')} onclose={() => (editingBeat = null)}>
|
||||
{#if editingBeat}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldTitle')}</span>
|
||||
<input class="field-input" bind:value={editingBeat.title} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldDescription')}</span>
|
||||
<textarea class="field-textarea" rows="4" bind:value={editingBeat.description}></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (beatModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveBeat.isPending} onclick={() => $saveBeat.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={charModalOpen} title={t('story.editCharTitle')} onclose={() => (editingChar = null)}>
|
||||
{#if editingChar}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldName')}</span>
|
||||
<input class="field-input" bind:value={editingChar.name} placeholder={t('story.fieldName')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldRole')}</span>
|
||||
<input class="field-input" bind:value={editingChar.role} placeholder={t('story.fieldRole')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldAge')}</span>
|
||||
<input class="field-input" bind:value={editingChar.age} placeholder={t('story.fieldAge')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldAppearance')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
bind:value={editingChar.appearance}
|
||||
placeholder={t('story.fieldAppearance')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldPersonality')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingChar.personality}
|
||||
placeholder={t('story.fieldPersonality')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldConsistency')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingChar.consistency_prompt}
|
||||
placeholder={t('story.fieldConsistency')}
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (charModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveChar.isPending} onclick={() => $saveChar.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={locModalOpen} title={t('story.editLocTitle')} onclose={() => (editingLoc = null)}>
|
||||
{#if editingLoc}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldName')}</span>
|
||||
<input class="field-input" bind:value={editingLoc.name} placeholder={t('story.fieldName')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldDescription')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
bind:value={editingLoc.description}
|
||||
placeholder={t('story.fieldDescription')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldConsistency')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingLoc.consistency_prompt}
|
||||
placeholder={t('story.fieldConsistency')}
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (locModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveLoc.isPending} onclick={() => $saveLoc.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={itemModalOpen} title={t('story.editItemTitle')} onclose={() => (editingItem = null)}>
|
||||
{#if editingItem}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldName')}</span>
|
||||
<input class="field-input" bind:value={editingItem.name} placeholder={t('story.fieldName')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldDescription')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
bind:value={editingItem.description}
|
||||
placeholder={t('story.fieldDescription')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldConsistency')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingItem.consistency_prompt}
|
||||
placeholder={t('story.fieldConsistency')}
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (itemModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveItem.isPending} onclick={() => $saveItem.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmExampleOpen}
|
||||
title={t('story.confirmExampleTitle')}
|
||||
message={t('story.confirmExampleMsg')}
|
||||
confirmLabel={t('story.loadExample')}
|
||||
onconfirm={() => void loadExample()}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={beatDeleteOpen}
|
||||
title={t('story.confirmDeleteBeatTitle')}
|
||||
message={deletingBeat
|
||||
? t('story.confirmDeleteBeatMsg', { index: deletingBeat.order_index, title: deletingBeat.title })
|
||||
: ''}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onconfirm={() => {
|
||||
const id = deletingBeat?.id;
|
||||
deletingBeat = null;
|
||||
if (id != null) $deleteBeatMutation.mutate(id);
|
||||
}}
|
||||
oncancel={() => (deletingBeat = null)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.stage-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.stage-header h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stage-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.banner {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
color: var(--warning);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stack-col {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.card-h {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.save-ind {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.save-ind.saved {
|
||||
color: var(--success);
|
||||
animation: saved-fade 2s ease forwards;
|
||||
}
|
||||
.save-ind.err {
|
||||
color: var(--error);
|
||||
}
|
||||
@keyframes saved-fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
12% {
|
||||
opacity: 1;
|
||||
}
|
||||
78% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.grid-3 .field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.beat-hint {
|
||||
display: block;
|
||||
}
|
||||
.beat-hint strong {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.prompt-preview {
|
||||
margin: 0 0 var(--space-sm);
|
||||
padding: 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
color: var(--text-secondary);
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
.prompt-collapsed {
|
||||
margin: 0;
|
||||
}
|
||||
.beat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.beat-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.beat {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: var(--space-md);
|
||||
align-items: flex-start;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.beat:hover {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.beat:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.beat-actions {
|
||||
flex-shrink: 0;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.beat-number {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.beat-body h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.beat-body p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.beats-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-md);
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.chip-tag {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -37,8 +37,13 @@
|
||||
<span class="dot"></span>
|
||||
<Icon name={kindIcon[toast.kind]} size={15} />
|
||||
</span>
|
||||
<span class="msg">{toast.message}</span>
|
||||
<button
|
||||
<span class="msg">{toast.message}</span>
|
||||
{#if toast.action}
|
||||
<button type="button" class="action" onclick={() => (toast.action?.run(), dismissToast(toast.id))}>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="dismiss"
|
||||
aria-label={t('toast.dismiss')}
|
||||
@@ -52,15 +57,17 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* §四.5 Toast:右下角堆叠,220ms 上移入场;成功绿点 / 失败红点。 */
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
right: 24px;
|
||||
bottom: var(--toast-bottom, 24px);
|
||||
/* Above every overlay, incl. the 10000 lightbox, so feedback stays visible. */
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
max-width: min(400px, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
@@ -69,23 +76,10 @@
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
animation: toast-in 0.22s ease-out;
|
||||
}
|
||||
.toast.success {
|
||||
border-color: rgba(34, 197, 94, 0.45);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.45),
|
||||
0 0 0 1px rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.toast.error {
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
.toast.info {
|
||||
border-color: rgba(59, 130, 246, 0.45);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--float-bg);
|
||||
box-shadow: var(--shadow-float);
|
||||
animation: toast-in var(--d-enter) var(--ease-out);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
@@ -101,21 +95,44 @@
|
||||
background: currentColor;
|
||||
}
|
||||
.toast.success .badge {
|
||||
color: var(--success);
|
||||
color: var(--ok);
|
||||
}
|
||||
.toast.error .badge {
|
||||
color: var(--error);
|
||||
color: var(--err);
|
||||
}
|
||||
.toast.info .badge {
|
||||
color: var(--info);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.msg {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* §四.5: Toast 动作(如撤销)为金色文字 */
|
||||
.action {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.action:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
}
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.dismiss {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -127,10 +144,12 @@
|
||||
padding: 0 2px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
.dismiss:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -139,7 +158,7 @@
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -151,10 +170,12 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
:global(:root[data-reduce='1']) .toast {
|
||||
animation: none;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.toast-host {
|
||||
top: auto;
|
||||
bottom: 20px;
|
||||
bottom: calc(var(--tabbar-h) + var(--sp-5) + env(safe-area-inset-bottom));
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
max-width: none;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,827 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import {
|
||||
apiErrorMessage,
|
||||
assetUrl,
|
||||
playgroundApi,
|
||||
jobsApi,
|
||||
workflows,
|
||||
type ComfyEndpoint,
|
||||
type Job,
|
||||
type Workflow,
|
||||
} from '$lib/api';
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ProgressBar from '$lib/components/ui/ProgressBar.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import { mediaFileProblem, type MediaSlotKind } from '$lib/comfy/imageFile';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 测试工作流 (HEL-643 L3, E 批抽屉化): fill the workflow's REAL input
|
||||
* slots, submit one REAL run to the chosen ComfyUI through the ordinary
|
||||
* job queue, and call it a pass only when at least one downloadable
|
||||
* output of the right media kind came back. Test artifacts are isolated
|
||||
* from projects. Presentation follows HEL-649 §3.8: right-side drawer,
|
||||
* cyan progress + mono stage log while running, result thumbnails in a
|
||||
* horizontal row.
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
workflow: Workflow | null;
|
||||
endpoints: ComfyEndpoint[];
|
||||
onchanged?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
workflow,
|
||||
endpoints,
|
||||
onchanged,
|
||||
}: Props = $props();
|
||||
|
||||
useQueryClient();
|
||||
|
||||
let endpointId = $state('');
|
||||
let values = $state<Record<string, string | number>>({});
|
||||
let listValues = $state<Record<string, string[]>>({});
|
||||
let sizes = $state<Record<string, number>>({});
|
||||
let uploadingName = $state<Record<string, string>>({});
|
||||
let rejects = $state<Record<string, { name: string; message: string }[]>>({});
|
||||
let submitError = $state('');
|
||||
let confirmOpen = $state(false);
|
||||
let submitting = $state(false);
|
||||
let testJobId = $state<number | null>(null);
|
||||
let errorExpanded = $state(false);
|
||||
let logBox = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const wf = $derived(workflow);
|
||||
const schema = $derived(wf?.input_schema ?? []);
|
||||
const listEntries = $derived(
|
||||
schema.filter(
|
||||
(i) =>
|
||||
i.kind === 'image_list' ||
|
||||
i.kind === 'video_list' ||
|
||||
i.kind === 'audio_list',
|
||||
),
|
||||
);
|
||||
const formEntries = $derived(
|
||||
schema.filter(
|
||||
(i) =>
|
||||
i.kind !== 'image_list' &&
|
||||
i.kind !== 'video_list' &&
|
||||
i.kind !== 'audio_list',
|
||||
),
|
||||
);
|
||||
const hasH3Hint = $derived(
|
||||
listEntries.some((e) => e.media_hint === 'minimax_h3_ref2va'),
|
||||
);
|
||||
const outputKind = $derived<'image' | 'video'>(
|
||||
wf?.kind === 'video' ? 'video' : 'image',
|
||||
);
|
||||
|
||||
const testsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['workflow-tests', wf?.id ?? 0],
|
||||
queryFn: () => workflows.tests(wf!.id),
|
||||
enabled: !!wf && open,
|
||||
refetchInterval: 2000,
|
||||
})),
|
||||
);
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs'],
|
||||
queryFn: () => jobsApi.list(),
|
||||
enabled: !!wf && open,
|
||||
refetchInterval: 5000,
|
||||
})),
|
||||
);
|
||||
const queuedCount = $derived(
|
||||
($jobsQuery.data ?? []).filter(
|
||||
(j) => j.status === 'pending' || j.status === 'running',
|
||||
).length,
|
||||
);
|
||||
|
||||
const history = $derived(($testsQuery.data ?? []) as Job[]);
|
||||
const activeJob = $derived.by(() => {
|
||||
if (testJobId != null) {
|
||||
const mine = history.find((j) => j.id === testJobId);
|
||||
if (mine && (mine.status === 'pending' || mine.status === 'running'))
|
||||
return mine;
|
||||
}
|
||||
return (
|
||||
history.find((j) => j.status === 'pending' || j.status === 'running') ??
|
||||
null
|
||||
);
|
||||
});
|
||||
const finishedJob = $derived.by(() => {
|
||||
if (testJobId != null)
|
||||
return history.find((j) => j.id === testJobId) ?? null;
|
||||
return null;
|
||||
});
|
||||
|
||||
const queueBusy = $derived(!!activeJob || submitting);
|
||||
const liveProgress = $derived(
|
||||
activeJob ? progressFor(activeJob.id) : undefined,
|
||||
);
|
||||
const activeInfo = $derived(activeJob ? jobStatusInfo(activeJob) : null);
|
||||
|
||||
// Rolling mono stage log (§3.8): one line per distinct worker message.
|
||||
let stageLog = $state<string[]>([]);
|
||||
$effect(() => {
|
||||
if (!activeJob || !activeInfo) {
|
||||
stageLog = [];
|
||||
return;
|
||||
}
|
||||
const line = liveProgress?.message || activeInfo.detail;
|
||||
if (!line) return;
|
||||
if (stageLog[stageLog.length - 1] !== line) stageLog = [...stageLog, line];
|
||||
});
|
||||
$effect(() => {
|
||||
void stageLog.length;
|
||||
if (logBox) logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open || !wf) return;
|
||||
endpointId = wf.endpoint_id ?? '';
|
||||
values = Object.fromEntries(
|
||||
schema
|
||||
.filter((i) => i.defaultValue !== undefined && i.defaultValue !== null)
|
||||
.map((i) => [i.nodeId, i.defaultValue as string | number]),
|
||||
);
|
||||
listValues = {};
|
||||
sizes = {};
|
||||
rejects = {};
|
||||
uploadingName = {};
|
||||
submitError = '';
|
||||
errorExpanded = false;
|
||||
stageLog = [];
|
||||
testJobId =
|
||||
wf.last_test &&
|
||||
(wf.last_test.status === 'pending' || wf.last_test.status === 'running')
|
||||
? wf.last_test.id
|
||||
: null;
|
||||
});
|
||||
|
||||
function kindOf(entry: { kind: string }): MediaSlotKind {
|
||||
if (entry.kind === 'video_list') return 'video';
|
||||
if (entry.kind === 'audio_list') return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function unitOf(kind: MediaSlotKind): string {
|
||||
return kind === 'image' ? '张' : '段';
|
||||
}
|
||||
|
||||
function setRejects(key: string, next: { name: string; message: string }[]) {
|
||||
rejects = { ...rejects, [key]: next };
|
||||
}
|
||||
|
||||
async function uploadOne(file: File): Promise<string | null> {
|
||||
const res = await playgroundApi.upload(file);
|
||||
sizes = { ...sizes, [res.path]: file.size };
|
||||
return res.path;
|
||||
}
|
||||
|
||||
async function mediaAdd(entry: (typeof listEntries)[number], files: File[]) {
|
||||
const kind = kindOf(entry);
|
||||
const key = entry.nodeId;
|
||||
const max = entry.max ?? 9;
|
||||
const current = listValues[key] ?? [];
|
||||
const ok: string[] = [];
|
||||
const bad: { name: string; message: string }[] = [];
|
||||
for (const f of files) {
|
||||
const problem = mediaFileProblem(kind, f);
|
||||
if (problem) bad.push({ name: f.name, message: problem });
|
||||
else ok.push(f.name);
|
||||
}
|
||||
if (current.length + ok.length > max) {
|
||||
toast.error(
|
||||
t('studio.errRefMax', { max, n: current.length, k: ok.length }),
|
||||
);
|
||||
if (bad.length) setRejects(key, [...(rejects[key] ?? []), ...bad]);
|
||||
return;
|
||||
}
|
||||
setRejects(key, [...(rejects[key] ?? []), ...bad]);
|
||||
for (const f of files) {
|
||||
if (!ok.includes(f.name)) continue;
|
||||
uploadingName = { ...uploadingName, [key]: f.name };
|
||||
try {
|
||||
const path = await uploadOne(f);
|
||||
if (path)
|
||||
listValues = {
|
||||
...listValues,
|
||||
[key]: [...(listValues[key] ?? []), path],
|
||||
};
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
uploadingName = { ...uploadingName, [key]: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mediaReplace(
|
||||
entry: (typeof listEntries)[number],
|
||||
index: number,
|
||||
file: File,
|
||||
) {
|
||||
const kind = kindOf(entry);
|
||||
const key = entry.nodeId;
|
||||
const problem = mediaFileProblem(kind, file);
|
||||
if (problem) {
|
||||
setRejects(key, [
|
||||
...(rejects[key] ?? []),
|
||||
{ name: file.name, message: problem },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
uploadingName = { ...uploadingName, [key]: file.name };
|
||||
try {
|
||||
const path = await uploadOne(file);
|
||||
if (path) {
|
||||
const next = [...(listValues[key] ?? [])];
|
||||
next[index] = path;
|
||||
listValues = { ...listValues, [key]: next };
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
uploadingName = { ...uploadingName, [key]: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function mediaRemove(entry: (typeof listEntries)[number], index: number) {
|
||||
const key = entry.nodeId;
|
||||
listValues = {
|
||||
...listValues,
|
||||
[key]: (listValues[key] ?? []).filter((_, i) => i !== index),
|
||||
};
|
||||
}
|
||||
|
||||
/** Mirrors the backend's validate_input_values for immediate feedback. */
|
||||
function validate(): string[] {
|
||||
const errs: string[] = [];
|
||||
for (const e of schema) {
|
||||
const label = e.label;
|
||||
if (
|
||||
e.kind === 'image_list' ||
|
||||
e.kind === 'video_list' ||
|
||||
e.kind === 'audio_list'
|
||||
) {
|
||||
const n = (listValues[e.nodeId] ?? []).length;
|
||||
const unit = unitOf(kindOf(e));
|
||||
if (e.required && n < (e.min ?? 1))
|
||||
errs.push(t('wft.errListMin', { n: e.min ?? 1, unit, label }));
|
||||
} else if (e.auto && e.required && e.role === 'prompt') {
|
||||
const v = values[e.nodeId];
|
||||
if (
|
||||
!(typeof v === 'string' && v.trim()) &&
|
||||
!String(e.defaultValue ?? '').trim()
|
||||
) {
|
||||
errs.push(t('wft.errRequired', { label }));
|
||||
}
|
||||
} else if (
|
||||
e.required &&
|
||||
(e.kind === 'image' || e.kind === 'video' || e.kind === 'audio') &&
|
||||
e.role !== 'mask'
|
||||
) {
|
||||
if (!values[e.nodeId]) errs.push(t('wft.errRequired', { label }));
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
async function submitTest() {
|
||||
if (!wf) return;
|
||||
const errs = validate();
|
||||
if (errs.length) {
|
||||
submitError = errs.join(';');
|
||||
return;
|
||||
}
|
||||
submitError = '';
|
||||
confirmOpen = true;
|
||||
}
|
||||
|
||||
async function reallyRunTest() {
|
||||
if (!wf) return;
|
||||
submitting = true;
|
||||
try {
|
||||
const inputValues: Record<string, unknown> = { ...values };
|
||||
for (const [k, v] of Object.entries(listValues)) {
|
||||
if (v.length) inputValues[k] = v;
|
||||
}
|
||||
const res = await workflows.test(wf.id, {
|
||||
...(endpointId ? { endpoint_id: endpointId } : {}),
|
||||
input_values: inputValues as Record<string, string | number | string[]>,
|
||||
});
|
||||
testJobId = res.job.id;
|
||||
if (res.reused) toast.info(t('wft.reusedToast'));
|
||||
void $testsQuery.refetch();
|
||||
} catch (err) {
|
||||
submitError = apiErrorMessage(err);
|
||||
} finally {
|
||||
submitting = false;
|
||||
confirmOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelTest() {
|
||||
if (!activeJob) return;
|
||||
try {
|
||||
await jobsApi.cancel(activeJob.id);
|
||||
void $testsQuery.refetch();
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function clearRecords() {
|
||||
if (!wf) return;
|
||||
try {
|
||||
await workflows.clearTests(wf.id);
|
||||
testJobId = null;
|
||||
await $testsQuery.refetch();
|
||||
onchanged?.();
|
||||
toast.success(t('wft.cleared'));
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function resultLine(job: Job): string {
|
||||
if (job.status === 'done' && (job.output_paths?.length ?? 0) > 0) {
|
||||
return t('wft.passedShort', { n: job.output_paths.length });
|
||||
}
|
||||
if (job.error === 'cancelled') return t('wft.cancelledShort');
|
||||
return job.error ? job.error.slice(0, 120) : t('wft.failedShort');
|
||||
}
|
||||
|
||||
function timeOf(at: string | null): string {
|
||||
if (!at) return '—';
|
||||
const ts = Date.parse(at.replace(' ', 'T') + 'Z');
|
||||
const d = new Date(Number.isFinite(ts) ? ts : at);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const endpointLabel = $derived(
|
||||
endpoints.find((e) => e.id === endpointId)?.name ??
|
||||
t('conn.wfConnectionActive'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<Drawer bind:open title={t('wft.title')} width={420}>
|
||||
{#if wf}
|
||||
<div class="wft">
|
||||
<p class="wft-sub">
|
||||
{wf.name}
|
||||
<span class="mono wft-id">#{wf.id}</span>
|
||||
</p>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">{t('wft.connection')}</span>
|
||||
<select
|
||||
class="a-select full"
|
||||
bind:value={endpointId}
|
||||
disabled={queueBusy}
|
||||
>
|
||||
<option value="">{t('conn.wfConnectionActive')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="field-hint">{t('wft.costNote')}</span>
|
||||
</label>
|
||||
|
||||
{#if hasH3Hint}
|
||||
<div class="wft-note" role="note">
|
||||
<Icon name="info" size={14} />
|
||||
<p>{t('wfMedia.h3Limits')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeJob && activeInfo}
|
||||
<div class="wft-active">
|
||||
<div class="wft-active-head">
|
||||
<span class="chip run">
|
||||
<span class="chip-dot" aria-hidden="true"></span>
|
||||
{t('wft.running')}
|
||||
</span>
|
||||
<span class="wft-active-detail">{activeInfo.detail}</span>
|
||||
<Button variant="secondary" size="sm" onclick={cancelTest}
|
||||
>{t('wft.cancel')}</Button
|
||||
>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={liveProgress?.progress ?? 0}
|
||||
indeterminate={activeJob.status === 'pending' && !liveProgress}
|
||||
label={activeInfo.label}
|
||||
/>
|
||||
{#if stageLog.length}
|
||||
<div class="wft-log mono" bind:this={logBox} aria-live="polite">
|
||||
{#each stageLog as line, i (i)}
|
||||
<p>{line}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if finishedJob && finishedJob.status === 'done' && (finishedJob.output_paths?.length ?? 0) > 0}
|
||||
<div class="wft-result ok">
|
||||
<p class="wft-verdict">
|
||||
{t('wft.passed', { n: finishedJob.output_paths.length })}
|
||||
</p>
|
||||
<div class="wft-outputs">
|
||||
{#each finishedJob.output_paths as path (path)}
|
||||
<a
|
||||
class="wft-out"
|
||||
href={assetUrl(path)}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noreferrer external"
|
||||
title={t('wft.download')}
|
||||
>
|
||||
<SafeMedia
|
||||
kind={outputKind}
|
||||
src={assetUrl(path)}
|
||||
controls={outputKind === 'video'}
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if finishedJob && finishedJob.status !== 'pending' && finishedJob.status !== 'running'}
|
||||
<div class="wft-result bad">
|
||||
<p class="wft-verdict">{t('wft.failed')}</p>
|
||||
<p class="wft-error">
|
||||
{finishedJob.error === 'cancelled'
|
||||
? t('wft.cancelledShort')
|
||||
: errorExpanded || (finishedJob.error ?? '').length <= 160
|
||||
? finishedJob.error
|
||||
: (finishedJob.error ?? '').slice(0, 160) + '…'}
|
||||
</p>
|
||||
{#if finishedJob.error && finishedJob.error !== 'cancelled' && finishedJob.error.length > 160}
|
||||
<button
|
||||
type="button"
|
||||
class="wft-expand"
|
||||
onclick={() => (errorExpanded = !errorExpanded)}
|
||||
>
|
||||
{errorExpanded ? t('wfd.hideDetails') : t('wfd.showDetails')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !queueBusy}
|
||||
<div class="wft-form">
|
||||
{#each listEntries as entry (entry.nodeId)}
|
||||
{@const kind = kindOf(entry)}
|
||||
<RefImageGrid
|
||||
label={entry.label}
|
||||
mediaKind={kind}
|
||||
paths={listValues[entry.nodeId] ?? []}
|
||||
max={entry.max ?? 9}
|
||||
{sizes}
|
||||
uploadingName={uploadingName[entry.nodeId] || undefined}
|
||||
rejects={rejects[entry.nodeId] ?? []}
|
||||
onAdd={(files) => mediaAdd(entry, files)}
|
||||
onReplace={(i, file) => mediaReplace(entry, i, file)}
|
||||
onRemove={(i) => mediaRemove(entry, i)}
|
||||
onDismissReject={(i) =>
|
||||
setRejects(
|
||||
entry.nodeId,
|
||||
(rejects[entry.nodeId] ?? []).filter((_, j) => j !== i),
|
||||
)}
|
||||
/>
|
||||
{/each}
|
||||
{#if formEntries.length}
|
||||
<ComfyDynamicForm
|
||||
inputs={formEntries}
|
||||
bind:values
|
||||
allowUpload
|
||||
showErrors={!!submitError}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if submitError}<span class="field-error" role="alert"
|
||||
>{submitError}</span
|
||||
>{/if}
|
||||
<div class="wft-actions">
|
||||
<Button variant="primary" loading={submitting} onclick={submitTest}>
|
||||
{t('wft.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="wft-history">
|
||||
<div class="wft-history-head">
|
||||
<span class="field-label"
|
||||
>{t('wft.history', { n: history.length })}</span
|
||||
>
|
||||
{#if history.length}
|
||||
<button type="button" class="link-danger" onclick={clearRecords}>
|
||||
{t('wft.clear')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if history.length === 0}
|
||||
<p class="wft-empty">{t('wft.never')}</p>
|
||||
{:else}
|
||||
<ul class="wft-rows">
|
||||
{#each history as job (job.id)}
|
||||
<li class:active={job.id === activeJob?.id}>
|
||||
<span class="mono wft-row-id">#{job.id}</span>
|
||||
<span class="wft-row-time">{timeOf(job.created_at)}</span>
|
||||
<span
|
||||
class="wft-row-result"
|
||||
class:ok={job.status === 'done' &&
|
||||
(job.output_paths?.length ?? 0) > 0}
|
||||
>
|
||||
{resultLine(job)}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Drawer>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmOpen}
|
||||
title={t('wft.confirmTitle')}
|
||||
message={t('wft.confirmBody', { endpoint: endpointLabel, n: queuedCount })}
|
||||
confirmLabel={t('wft.submit')}
|
||||
onconfirm={reallyRunTest}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.wft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wft-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-id {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.a-select.full {
|
||||
width: 100%;
|
||||
}
|
||||
.wft-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--run-soft);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--run-soft);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wft-note :global(.icon) {
|
||||
color: var(--run);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.wft-note p {
|
||||
margin: 0;
|
||||
}
|
||||
.wft-active,
|
||||
.wft-result {
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-active {
|
||||
border-color: var(--run-soft);
|
||||
}
|
||||
.wft-active-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip.run {
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.chip-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--run);
|
||||
animation: wft-breathe 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes wft-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
:global([data-reduce='1']) .chip-dot {
|
||||
animation: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chip-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.wft-active-detail {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-log {
|
||||
max-height: 110px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-log p {
|
||||
margin: 0;
|
||||
}
|
||||
.wft-result.ok {
|
||||
border-color: var(--ok);
|
||||
}
|
||||
.wft-result.bad {
|
||||
border-color: var(--err);
|
||||
}
|
||||
.wft-verdict {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.wft-result.ok .wft-verdict {
|
||||
color: var(--ok);
|
||||
}
|
||||
.wft-result.bad .wft-verdict {
|
||||
color: var(--err);
|
||||
}
|
||||
.wft-error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--err);
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
.wft-expand {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.wft-expand:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
.wft-outputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.wft-out {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: 148px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.wft-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.wft-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-history {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.wft-history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-history-head .field-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.link-danger {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--err);
|
||||
cursor: pointer;
|
||||
border-radius: var(--r-sm);
|
||||
opacity: 0.85;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.link-danger:hover {
|
||||
opacity: 1;
|
||||
background: var(--err-soft);
|
||||
}
|
||||
}
|
||||
.wft-empty {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wft-rows {
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wft-rows li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.wft-rows li.active {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.wft-row-id {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.wft-row-time {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wft-row-result {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wft-row-result.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
</style>
|
||||
@@ -132,7 +132,7 @@
|
||||
border-color: color-mix(in srgb, var(--success) 30%, var(--border));
|
||||
}
|
||||
.artifact.failed {
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
@@ -249,12 +249,12 @@
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
|
||||
background: var(--media-scrim);
|
||||
box-shadow: 0 0 0 1px var(--on-media-line);
|
||||
}
|
||||
.play-badge :global(svg) {
|
||||
position: absolute;
|
||||
color: #fff;
|
||||
color: var(--on-media);
|
||||
fill: currentColor;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6));
|
||||
margin-left: 2px;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
studio?: boolean;
|
||||
messages: AgentMessage[];
|
||||
/** In-flight tool calls for the current turn (from agent.tool SSE). */
|
||||
liveTools: { tool: string; args?: Record<string, unknown> | null; result?: unknown; phase: 'running' | 'done' | 'error' }[];
|
||||
@@ -31,6 +32,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
studio = false,
|
||||
messages,
|
||||
liveTools,
|
||||
streaming,
|
||||
@@ -46,6 +48,21 @@
|
||||
let listEl = $state<HTMLDivElement | null>(null);
|
||||
let pinned = $state(true);
|
||||
|
||||
// A user message starts a turn; keep different named assistants distinct.
|
||||
const studioGroups = $derived.by(() => {
|
||||
const groups: AgentMessage[][] = [];
|
||||
for (const message of messages) {
|
||||
if (!['user', 'assistant', 'tool'].includes(message.role)) continue;
|
||||
const previous = groups.at(-1);
|
||||
const author = previous?.find(m => m.role === 'assistant')?.agent_name;
|
||||
if (!previous || message.role === 'user' || previous[0].role === 'user' ||
|
||||
(message.role === 'assistant' && author !== undefined && message.agent_name !== author)) {
|
||||
groups.push([message]);
|
||||
} else previous.push(message);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
// Derived tool rows carry no status — infer failure from the result shape,
|
||||
// same as the live SSE path does.
|
||||
function toolPhase(m: AgentMessage): 'done' | 'error' {
|
||||
@@ -61,6 +78,7 @@
|
||||
|
||||
/** Later tool results (wait_for_jobs) often have the finished status + paths. */
|
||||
const trailJobsById = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Each derivation rebuilds this tool-result lookup from messages; only the completed Map escapes, with no later mutation.
|
||||
const map = new Map<number, ArtifactJob>();
|
||||
for (const row of messages) {
|
||||
if (row.role !== 'tool' || !row.tool_result || typeof row.tool_result !== 'object') continue;
|
||||
@@ -188,6 +206,35 @@
|
||||
</script>
|
||||
|
||||
<div class="chat" bind:this={listEl} onscroll={onScroll}>
|
||||
{#if studio}
|
||||
{#if loading}<p class="studio-empty">{t('agentChat.loading')}</p>{/if}
|
||||
{#each studioGroups as group (group[0].id)}
|
||||
<div class="studio-msg" class:me={group[0].role==='user'}>
|
||||
<div class="studio-avatar" title={group[0].role==='user'?'我':agentDisplayName(group[0].agent_name)}>{#if group[0].role==='user'}我{:else}<Icon name="project-agent" size={14}/>{/if}</div>
|
||||
<div class="studio-message-body">
|
||||
{#each group as m (m.id)}
|
||||
{#if m.role==='tool'}
|
||||
<AgentToolCard studio name={m.tool_name??'tool'} args={m.tool_args} result={m.tool_result} phase={toolPhase(m)}/>
|
||||
{#each artifactJobs(m) as job(job.id)}<AgentArtifactCard {job}/>{/each}
|
||||
{:else}
|
||||
{#if m.reasoning}<ReasoningPanel reasoning={m.reasoning}/>{/if}
|
||||
<div class="studio-bubble" data-message-id={m.id} class:failed={m.status==='error'||m.status==='cancelled'}>{#if m.status==='steering'}<small>{t('agentChat.steering')}</small>{/if}{#each m.attachments??[] as a (a.path)}<a href={assetUrl(a.path)??'#'} target="_blank" rel="noopener external">{a.name}</a>{/each}{#each bubbleParts(m.content,(m.mentions??[]) as WorkflowMention[]) as part,i (`${i}-${part.type}`)}{#if part.type==='chip'}<span class="wf-chip">{part.text}</span>{:else}{part.text}{/if}{/each}</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if lastQuestion && !running}<div class="question-card"><p>{lastQuestion.question}</p><div class="question-options">{#each lastQuestion.options as opt(opt)}<button type="button" class="question-option" onclick={()=>onAnswer?.(opt,lastQuestion.scope,lastQuestion.questionSeq)}>{opt}</button>{/each}</div></div>{/if}
|
||||
{#if running && (liveTools.length || streamingReasoning || streaming)}
|
||||
<div class="studio-msg"><div class="studio-avatar"><Icon name="project-agent" size={14}/></div><div class="studio-message-body">
|
||||
{#each liveTools as tool,i(i)}<AgentToolCard studio name={tool.tool} args={tool.args} result={tool.result} phase={tool.phase}/>{/each}
|
||||
{#if streamingReasoning}<ReasoningPanel reasoning={streamingReasoning}/>{/if}
|
||||
{#if streaming}<div class="studio-bubble">{streaming}</div>{/if}
|
||||
</div></div>
|
||||
{/if}
|
||||
{#if !loading&&!messages.length&&!running}<p class="studio-empty">{t('agentChat.emptyHint')}</p>{/if}
|
||||
{:else}
|
||||
|
||||
{#if loading}
|
||||
<div class="empty">
|
||||
<p class="empty-hint">{t('agentChat.loading')}</p>
|
||||
@@ -328,9 +375,20 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.studio-msg {display:flex;gap:10px;max-width:760px;}
|
||||
.studio-msg.me {align-self:flex-end;flex-direction:row-reverse;}
|
||||
.studio-avatar {width:28px;height:28px;border-radius:var(--agent-radius-8);flex:none;display:grid;place-items:center;background:color-mix(in srgb,var(--sc-agent) 18%,transparent);color:var(--sc-agent);}
|
||||
.studio-msg.me .studio-avatar {background:var(--bg-3);color:var(--tx-2);}
|
||||
.studio-message-body {flex:var(--agent-message-flex);display:flex;flex-direction:column;gap:10px;min-width:0;}
|
||||
.studio-bubble {padding:10px 13px;border-radius:var(--agent-bubble-radius);background:var(--bg-2);border:1px solid var(--line);font-size:13px;line-height:1.65;white-space:pre-wrap;word-break:break-word;}
|
||||
.studio-msg.me .studio-bubble {background:color-mix(in srgb,var(--gold) 10%,transparent);border-color:color-mix(in srgb,var(--gold) 25%,transparent);}
|
||||
.studio-bubble.failed {color:var(--err);border-color:var(--err);}
|
||||
.studio-empty {color:var(--tx-3);font-size:12px;}
|
||||
|
||||
.chat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -341,7 +399,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 4px 4px 12px;
|
||||
padding: var(--chat-padding, 4px 4px 12px);
|
||||
}
|
||||
.empty {
|
||||
margin: auto;
|
||||
@@ -357,16 +415,13 @@
|
||||
.empty-mark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--r-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
background:
|
||||
radial-gradient(circle at 30% 25%, rgba(139, 92, 246, 0.35) 0%, transparent 60%),
|
||||
var(--bg-elevated);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
box-shadow: 0 0 32px var(--accent-glow);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.empty-title {
|
||||
@@ -391,21 +446,24 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
.suggestion {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-2);
|
||||
border-radius: var(--r-pill);
|
||||
padding: var(--sp-3) var(--sp-6);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
border-color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.suggestion:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface));
|
||||
color: var(--text);
|
||||
border-color: var(--line-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.suggestion:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -430,11 +488,11 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-size: var(--fs-mini);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--agent, var(--accent));
|
||||
color: var(--agent, var(--text-2));
|
||||
margin-bottom: 4px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
@@ -443,7 +501,7 @@
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--accent)) 25%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--text-2)) 25%, transparent);
|
||||
}
|
||||
.reasoning-wrap {
|
||||
margin-bottom: 4px;
|
||||
@@ -451,28 +509,29 @@
|
||||
}
|
||||
.bubble {
|
||||
max-width: 86%;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 10px 14px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* 对话气泡按规范 §3.2/§3.6:用户 --bg-3,Agent --bg-2 + --line 描边。 */
|
||||
.user-bubble {
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--bg-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
.user-bubble.steering {
|
||||
border-style: dashed;
|
||||
}
|
||||
.steer-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-size: var(--fs-mini);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
color: var(--text-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.user-thumbs {
|
||||
@@ -486,36 +545,37 @@
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.user-thumb.file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.user-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/* 工作流提及为次级内容:中性底 + --text,不占金色。 */
|
||||
.wf-chip {
|
||||
display: inline;
|
||||
padding: 1px 7px;
|
||||
margin: 0 1px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg-elevated));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
color: var(--accent);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-4);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.assistant-bubble {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--agent, var(--accent));
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--agent, var(--text-2));
|
||||
color: var(--text);
|
||||
}
|
||||
.question-card {
|
||||
display: flex;
|
||||
@@ -525,15 +585,15 @@
|
||||
max-width: 420px;
|
||||
margin: 2px 0 6px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--accent) 7%, var(--bg-surface));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.question-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.question-options {
|
||||
@@ -542,40 +602,39 @@
|
||||
gap: 6px;
|
||||
}
|
||||
.question-option {
|
||||
border: 1px solid color-mix(in srgb, var(--agent, var(--accent)) 45%, transparent);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease;
|
||||
background-color var(--d-press) ease,
|
||||
border-color var(--d-press) ease;
|
||||
}
|
||||
.question-option:hover:enabled {
|
||||
background: color-mix(in srgb, var(--agent, var(--accent)) 18%, var(--bg-elevated));
|
||||
border-color: var(--agent, var(--accent));
|
||||
background: var(--bg-4);
|
||||
border-color: var(--agent, var(--text-2));
|
||||
}
|
||||
.question-option:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.question-option.affirmative {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.question-hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.assistant-bubble.err {
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
border-left-color: rgba(239, 68, 68, 0.6);
|
||||
color: var(--error);
|
||||
border-color: var(--err-line);
|
||||
border-left-color: var(--err);
|
||||
color: var(--err);
|
||||
}
|
||||
.bubble.live {
|
||||
display: flex;
|
||||
@@ -604,7 +663,7 @@
|
||||
width: 7px;
|
||||
height: 14px;
|
||||
margin-left: 2px;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: blink 1s step-end infinite;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
@@ -617,14 +676,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
/* 进行中指示 = 信号青(§1.3),不占金色。 */
|
||||
.pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user