Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -0,0 +1,128 @@
|
||||
"""Regenerate ``src/calliope/comfyui/ui_widget_orders.py`` (HEL-681).
|
||||
|
||||
Offline widget-order registry for the UI→API converter. Sources, in priority:
|
||||
|
||||
1. ``widgets_values_named`` instances across ``example_ComfyUI_workflows``
|
||||
(including subgraph inner nodes) — the frontend serializes named widgets
|
||||
in widget order, so the key order IS the serialization order;
|
||||
2. the repo's 10 ground-truth API exports (root ``*_API.json``) — scalar
|
||||
input names in export order (control_after_generate entries are absent
|
||||
there; the converter's walk skips them positionally).
|
||||
|
||||
Run from anywhere inside the repo:
|
||||
python scripts/build_widget_orders.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path(__file__).resolve().parents[1]
|
||||
REPO = BACKEND.parent
|
||||
EXAMPLES = REPO / "example_ComfyUI_workflows"
|
||||
OUT = BACKEND / "src" / "calliope" / "comfyui" / "ui_widget_orders.py"
|
||||
|
||||
BUTTONS = {"preview", "audioUI", "compare_view", "ToggleableColorPicker", "imagebrowser"}
|
||||
MAX_SIGNATURES = 3
|
||||
MAX_ORDER_LEN = 48
|
||||
|
||||
# Hand-curated fallbacks for node classes the corpus never serializes with
|
||||
# names (classic core/custom widgets whose order is stable and public).
|
||||
CURATED: dict[str, list[list[str]]] = {
|
||||
"PrimitiveNode": [["value"]],
|
||||
"Seed (rgthree)": [["seed"]],
|
||||
}
|
||||
|
||||
|
||||
def _iter_ui_nodes() -> list[dict]:
|
||||
nodes: list[dict] = []
|
||||
for path in sorted(EXAMPLES.rglob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
|
||||
continue
|
||||
nodes.extend(n for n in data["nodes"] if isinstance(n, dict))
|
||||
for sub in data.get("definitions", {}).get("subgraphs", []) or []:
|
||||
if isinstance(sub, dict):
|
||||
nodes.extend(n for n in sub.get("nodes", []) or [] if isinstance(n, dict))
|
||||
return nodes
|
||||
|
||||
|
||||
def _iter_api_workflows() -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for path in sorted(EXAMPLES.glob("*_API.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if isinstance(data, dict) and "nodes" not in data:
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
learned: dict[str, collections.Counter[tuple[str, ...]]] = collections.defaultdict(
|
||||
collections.Counter
|
||||
)
|
||||
for node in _iter_ui_nodes():
|
||||
named = node.get("widgets_values_named")
|
||||
if not isinstance(named, dict) or not named:
|
||||
continue
|
||||
keys = tuple(
|
||||
str(k) for k in named.keys() if k and k not in BUTTONS
|
||||
)
|
||||
if keys and len(keys) <= MAX_ORDER_LEN:
|
||||
learned[str(node.get("type") or "")][keys] += 1
|
||||
|
||||
for workflow in _iter_api_workflows():
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
names = [
|
||||
str(k)
|
||||
for k, v in (node.get("inputs") or {}).items()
|
||||
if not isinstance(v, list) and k and k not in BUTTONS
|
||||
]
|
||||
if len(names) > MAX_ORDER_LEN:
|
||||
continue
|
||||
# Display nodes (PreviewAny…) carry zero scalar inputs — that
|
||||
# empty order is real signal: their positional arrays are pure
|
||||
# button leftovers and map to {}.
|
||||
learned[str(node.get("class_type") or "")][tuple(names)] += 1
|
||||
|
||||
registry: dict[str, list[list[str]]] = {}
|
||||
for node_type, counter in sorted(learned.items()):
|
||||
if not node_type:
|
||||
continue
|
||||
orders: list[list[str]] = []
|
||||
for signature, _count in counter.most_common(MAX_SIGNATURES):
|
||||
orders.append(list(signature))
|
||||
if orders:
|
||||
registry[node_type] = orders
|
||||
for node_type, orders in CURATED.items():
|
||||
registry.setdefault(node_type, orders)
|
||||
|
||||
body = json.dumps(registry, ensure_ascii=False, indent=1, sort_keys=True)
|
||||
OUT.write_text(
|
||||
'"""GENERATED by scripts/build_widget_orders.py — do not edit by hand.\n'
|
||||
"\n"
|
||||
"Widget-order registry for the UI→API converter: class_type → candidate\n"
|
||||
"widget name orders, learned from the repo's named-widget UI files and\n"
|
||||
"ground-truth API exports (HEL-681).\n"
|
||||
'"""\n'
|
||||
"\n"
|
||||
"from __future__ import annotations\n"
|
||||
"\n"
|
||||
f"WIDGET_ORDERS: dict[str, list[list[str]]] = {body}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
total = sum(len(o) for orders in registry.values() for o in orders)
|
||||
print(f"wrote {OUT.name}: {len(registry)} node types, {total} orders total")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Generate the HEL-681 dry-run report over the repo's UI workflow library.
|
||||
|
||||
Walks ``example_ComfyUI_workflows`` (or ``--root``), runs every file through
|
||||
the production converter + lint + parser pipeline (exactly what
|
||||
``POST /api/workflows/convert`` does, minus HTTP), and writes a JSON detail
|
||||
file plus a human-readable Markdown summary. Read-only: nothing is imported,
|
||||
no DB is touched.
|
||||
|
||||
Usage:
|
||||
python scripts/ui_import_dryrun.py [--root DIR] [--out PREFIX]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(BACKEND / "src"))
|
||||
|
||||
from calliope.comfyui.lint import workflow_warnings # noqa: E402
|
||||
from calliope.comfyui.metadata import suggested_metadata # noqa: E402
|
||||
from calliope.comfyui.parser import classify_usage # noqa: E402
|
||||
from calliope.comfyui.ui_convert import convert_ui_workflow # noqa: E402
|
||||
|
||||
REPO = BACKEND.parent
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", default=str(REPO / "example_ComfyUI_workflows"))
|
||||
parser.add_argument("--out", default=str(REPO / "dryrun_report"))
|
||||
args = parser.parse_args()
|
||||
root = Path(args.root)
|
||||
out_prefix = Path(args.out)
|
||||
|
||||
entries = []
|
||||
for path in sorted(root.rglob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
entries.append(
|
||||
{
|
||||
"source_path": str(path.relative_to(root)),
|
||||
"status": "fail",
|
||||
"issues": [{"code": "unreadable", "message": str(exc)}],
|
||||
}
|
||||
)
|
||||
continue
|
||||
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
|
||||
continue # API-format / non-workflow files are out of scope
|
||||
rel = str(path.relative_to(root))
|
||||
result = convert_ui_workflow(data)
|
||||
entry: dict = {
|
||||
"source_path": rel,
|
||||
"status": result.status,
|
||||
"needs_confirmation": result.needs_confirmation,
|
||||
"issues": [
|
||||
{"node_id": i.node_id, "code": i.code, "level": i.level, "message": i.message}
|
||||
for i in result.issues
|
||||
],
|
||||
}
|
||||
if result.workflow is not None:
|
||||
entry["node_count"] = len(result.workflow)
|
||||
entry["suggested"] = suggested_metadata(result.workflow, rel, data)
|
||||
errors = [w for w in workflow_warnings(result.workflow) if w["level"] == "error"]
|
||||
entry["lint_errors"] = [e["code"] for e in errors]
|
||||
entry["importable"] = not result.needs_confirmation and not errors
|
||||
else:
|
||||
entry["importable"] = False
|
||||
entries.append(entry)
|
||||
|
||||
counts = collections.Counter(e["status"] for e in entries)
|
||||
code_counts = collections.Counter(
|
||||
i["code"] for e in entries for i in e["issues"]
|
||||
)
|
||||
category_counts = collections.Counter(
|
||||
(e.get("suggested") or {}).get("category") or "?" for e in entries
|
||||
)
|
||||
family_counts = collections.Counter(
|
||||
(e.get("suggested") or {}).get("model_family") or "?" for e in entries
|
||||
)
|
||||
importable = sum(1 for e in entries if e.get("importable"))
|
||||
needs_confirmation = sum(1 for e in entries if e["needs_confirmation"])
|
||||
lint_blocked = [e for e in entries if e.get("lint_errors")]
|
||||
|
||||
detail = {
|
||||
"root": str(root),
|
||||
"total": len(entries),
|
||||
"status_counts": dict(counts),
|
||||
"issue_code_counts": dict(code_counts),
|
||||
"importable": importable,
|
||||
"needs_confirmation": needs_confirmation,
|
||||
"lint_blocked": [e["source_path"] for e in lint_blocked],
|
||||
"files": entries,
|
||||
}
|
||||
out_prefix.parent.mkdir(parents=True, exist_ok=True)
|
||||
(out_prefix.with_suffix(".json")).write_text(
|
||||
json.dumps(detail, ensure_ascii=False, indent=1), encoding="utf-8"
|
||||
)
|
||||
|
||||
lines = [
|
||||
"# HEL-681 工作流 UI→API 转换 dry-run 报告",
|
||||
"",
|
||||
f"- 扫描目录:`{root}`(根目录 10 个 API 格式旧文件不在本管线范围)",
|
||||
f"- UI 格式文件总数:**{len(entries)}**",
|
||||
f"- 转换结果:ok **{counts.get('ok', 0)}** / warn **{counts.get('warn', 0)}** / fail **{counts.get('fail', 0)}**",
|
||||
f"- 其中「需人工确认」(控件无法可靠映射,禁止直接导入):**{needs_confirmation}**",
|
||||
f"- 通过转换+lint 后可直接导入:**{importable}**",
|
||||
f"- 转换成功但被现有 lint 拦截(导入时按阻止处理):**{len(lint_blocked)}**",
|
||||
"",
|
||||
"## 分类分布(自动提取)",
|
||||
"",
|
||||
"| 分类 | 数量 |",
|
||||
"|---|---|",
|
||||
]
|
||||
for cat, n in sorted(category_counts.items()):
|
||||
lines.append(f"| {cat} | {n} |")
|
||||
lines += ["", "## 模型家族分布(自动提取)", "", "| 家族 | 数量 |", "|---|---|"]
|
||||
for fam, n in sorted(family_counts.items(), key=lambda kv: -kv[1]):
|
||||
lines.append(f"| {fam} | {n} |")
|
||||
lines += ["", "## 问题代码统计", "", "| 代码 | 次数 | 含义 |", "|---|---|---|"]
|
||||
meaning = {
|
||||
"muted_input_dropped": "上游节点被静音,输入按未连接处理",
|
||||
"variable_upstream_dropped": "Set/Get 变量上游被旁路,取值按未连接处理",
|
||||
"get_without_set": "GetNode 没有同名 SetNode",
|
||||
"widget_unmapped": "控件值无法可靠映射到输入名(需人工确认)",
|
||||
"widget_order_mismatch": "控件数量与已知顺序不符(需人工确认)",
|
||||
"no_output_node": "图中无已知输出节点,全部活跃节点保留待确认",
|
||||
"cyclic_variable": "Set/Get 循环(FOR 循环流),该连线按未连接处理",
|
||||
"dangling_link": "连线指向不存在的节点",
|
||||
}
|
||||
for code, n in code_counts.most_common():
|
||||
lines.append(f"| `{code}` | {n} | {meaning.get(code, '')} |")
|
||||
lines += ["", "## 需人工确认文件清单", ""]
|
||||
for e in entries:
|
||||
if e["needs_confirmation"]:
|
||||
reasons = sorted({i["code"] for i in e["issues"]})[:3]
|
||||
lines.append(f"- `{e['source_path']}`({', '.join(reasons)})")
|
||||
lines += ["", "## lint 拦截文件清单", ""]
|
||||
for e in lint_blocked:
|
||||
lines.append(f"- `{e['source_path']}`({', '.join(e['lint_errors'])})")
|
||||
lines += [
|
||||
"",
|
||||
"## 逐文件结果",
|
||||
"",
|
||||
"| 文件 | 状态 | 节点数 | 分类 | 模型家族 | 用途 | 问题 |",
|
||||
"|---|---|---|---|---|---|---|",
|
||||
]
|
||||
for e in entries:
|
||||
sug = e.get("suggested") or {}
|
||||
issues = collections.Counter(i["code"] for i in e["issues"])
|
||||
issue_text = ", ".join(f"{c}×{n}" for c, n in issues.most_common(2)) or "-"
|
||||
usage = sug.get("usage") or ("待定" if sug.get("usage_ambiguous") else "-")
|
||||
lines.append(
|
||||
f"| `{e['source_path']}` | {e['status']} | {e.get('node_count', '-')} "
|
||||
f"| {sug.get('category') or '-'} | {sug.get('model_family') or '-'} "
|
||||
f"| {usage} | {issue_text} |"
|
||||
)
|
||||
(out_prefix.with_suffix(".md")).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
print(f"{len(entries)} files: {dict(counts)}, importable={importable}, nc={needs_confirmation}")
|
||||
print(f"report → {out_prefix}.md / {out_prefix}.json")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Suggested workflow metadata for imports (HEL-681 / S2 data layer).
|
||||
|
||||
Everything here derives from two honest sources: the repo directory the file
|
||||
came from (category) and the converted API graph itself (usage, media in/out,
|
||||
model family, note text). Per HEL-675's inventory, clean display names and
|
||||
tags stay human work — the suggestion is a starting point, never a guess that
|
||||
hides uncertainty.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.parser import (
|
||||
classify_usage,
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
|
||||
# Repo top-level directory → (category key, display label). Directory names
|
||||
# are mapping sources only — they never surface to users verbatim.
|
||||
CATEGORY_DIRS: dict[str, tuple[str, str]] = {
|
||||
"0-基础处理:图像合并-音频分离-视频截取合并-图像视频放大": ("basic", "基础处理"),
|
||||
"1-语音生成": ("audio_tts", "语音生成"),
|
||||
"2-音乐生成": ("music", "音乐生成"),
|
||||
"3-图像生成": ("image", "图像生成"),
|
||||
"4-视频生成": ("video", "视频生成"),
|
||||
"5-数字人": ("digital_human", "数字人"),
|
||||
"6-动作迁移": ("motion_transfer", "动作迁移"),
|
||||
}
|
||||
|
||||
# Evidence patterns → model family. Shared VAE/text-encoder files must NOT
|
||||
# count (HEL-675: Anima/Z-Image graphs reference qwen_image_vae and get
|
||||
# mislabeled Qwen — `qwen.?image(?!_vae)` is deliberate).
|
||||
_FAMILY_PATTERNS: tuple[tuple[str, str], ...] = (
|
||||
(r"minimax[_\-]?(music|_audio)|MiniMaxMusic", "MiniMax-Music"),
|
||||
(r"minimax.?h3|minimax_h3|h3[-_]realism|fl2v[aa]?", "MiniMax-H3"),
|
||||
(r"ltx[-_.]?2", "LTX-2.5"),
|
||||
(r"ltx", "LTX"),
|
||||
(r"krea", "Krea2"),
|
||||
(r"qwen.?image(?!_vae)|qwen_image_2|qwen2vl|qwen3vl|Qwen3?[-.]?ASR|qwen3?[-_.]?tts", "Qwen"),
|
||||
(r"z[._-]?image", "Z-Image"),
|
||||
(r"flux", "Flux"),
|
||||
(r"\bwan", "Wan"),
|
||||
(r"ace[_-]?step", "ACE-Step"),
|
||||
(r"index[_-]?tts", "IndexTTS2"),
|
||||
(r"anima[-_.]?(turbo|klein)", "Anima"),
|
||||
(r"sdpose", "SDPose"),
|
||||
(r"gemma", "Gemma"),
|
||||
(r"instantmesh|instant_mesh", "InstantMesh"),
|
||||
(r"hidream", "HiDream"),
|
||||
)
|
||||
_FAMILY_RE = [(re.compile(pat, re.IGNORECASE), fam) for pat, fam in _FAMILY_PATTERNS]
|
||||
|
||||
# Main-model loaders dominate the vote: a UNET/checkpoint reference says far
|
||||
# more about the workflow's family than a shared VAE or text encoder does.
|
||||
_PRIMARY_LOADER_RE = re.compile(r"UNETLoader|CheckpointLoader|unet|diffusion_model", re.IGNORECASE)
|
||||
|
||||
# Directory names that carry family evidence the files' own widget values
|
||||
# lack (HEL-675: 14 krea2 files reference no "krea" string at all).
|
||||
_FAMILY_DIRS = (
|
||||
(re.compile(r"krea2", re.IGNORECASE), "Krea2"),
|
||||
(re.compile(r"Minimax-h3|uncensored", re.IGNORECASE), "MiniMax-H3"),
|
||||
(re.compile(r"Z-Image", re.IGNORECASE), "Z-Image"),
|
||||
(re.compile(r"Qwen-Image", re.IGNORECASE), "Qwen"),
|
||||
(re.compile(r"^Anima$", re.IGNORECASE), "Anima"),
|
||||
(re.compile(r"Flux", re.IGNORECASE), "Flux"),
|
||||
)
|
||||
|
||||
_NOTE_TYPES = frozenset({"Note", "MarkdownNote"})
|
||||
|
||||
|
||||
def category_from_source_path(source_path: str | None) -> tuple[str | None, str | None]:
|
||||
"""(category key, label) derived from the file's repo-relative path."""
|
||||
if not source_path:
|
||||
return None, None
|
||||
head = source_path.replace("\\", "/").split("/", 1)[0]
|
||||
return CATEGORY_DIRS.get(head, (None, None))
|
||||
|
||||
|
||||
def model_family_from(workflow: dict[str, Any], source_path: str | None = None) -> str | None:
|
||||
"""The graph's dominant model family: main-model loader references weigh
|
||||
triple, other node evidence single, and the repo sub-directory adds a
|
||||
strong vote for families the widget values don't spell out."""
|
||||
votes: dict[str, int] = {}
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
class_type = str(node.get("class_type") or "")
|
||||
blob = " ".join(
|
||||
[class_type]
|
||||
+ [str(v) for v in (node.get("inputs") or {}).values() if isinstance(v, str)]
|
||||
)
|
||||
weight = 3 if _PRIMARY_LOADER_RE.search(blob) else 1
|
||||
for pattern, family in _FAMILY_RE:
|
||||
if pattern.search(blob):
|
||||
votes[family] = votes.get(family, 0) + weight
|
||||
break # one vote per node: strongest family that node shows
|
||||
if source_path:
|
||||
parts = source_path.replace("\\", "/").split("/")
|
||||
for part in parts[1:2]:
|
||||
for pattern, family in _FAMILY_DIRS:
|
||||
if pattern.search(part):
|
||||
votes[family] = votes.get(family, 0) + 2
|
||||
if not votes:
|
||||
return None
|
||||
return max(votes.items(), key=lambda kv: kv[1])[0]
|
||||
|
||||
|
||||
def note_description(ui_workflow: dict[str, Any], limit: int = 160) -> str | None:
|
||||
"""First non-trivial author note as description material (never final copy)."""
|
||||
for node in ui_workflow.get("nodes") or []:
|
||||
if not isinstance(node, dict) or str(node.get("type")) not in _NOTE_TYPES:
|
||||
continue
|
||||
values = node.get("widgets_values")
|
||||
text = values[0] if isinstance(values, list) and values else None
|
||||
if isinstance(values, dict):
|
||||
text = values.get("text") or next(iter(values.values()), None)
|
||||
if isinstance(text, str):
|
||||
text = text.strip()
|
||||
if len(text) >= 8:
|
||||
return text[:limit].strip()
|
||||
return None
|
||||
|
||||
|
||||
def suggested_metadata(
|
||||
converted: dict[str, Any],
|
||||
source_path: str | None = None,
|
||||
ui_workflow: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""The import suggestion bundle for one converted workflow."""
|
||||
judgment = classify_usage(converted)
|
||||
media_kinds = ("image", "image_list", "video", "video_list", "audio", "audio_list")
|
||||
media_in = sorted(
|
||||
{
|
||||
entry["kind"].replace("_list", "")
|
||||
for entry in parse_dynamic_inputs(converted)
|
||||
if entry.get("kind") in media_kinds
|
||||
}
|
||||
)
|
||||
media_out = sorted(
|
||||
{
|
||||
str(entry.get("kind"))
|
||||
for entry in parse_dynamic_outputs(converted)
|
||||
if entry.get("kind") in ("image", "video", "audio")
|
||||
}
|
||||
)
|
||||
category, category_label = category_from_source_path(source_path)
|
||||
return {
|
||||
"kind": "video" if "video" in media_out and "image" not in media_out else "image",
|
||||
"usage": judgment["usage"],
|
||||
"usage_ambiguous": judgment["ambiguous"],
|
||||
"category": category,
|
||||
"category_label": category_label,
|
||||
"model_family": model_family_from(converted, source_path),
|
||||
"media_in": media_in,
|
||||
"media_out": media_out,
|
||||
"description": note_description(ui_workflow or {}),
|
||||
}
|
||||
@@ -13,12 +13,14 @@ from calliope.comfyui.registry import (
|
||||
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,
|
||||
@@ -689,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]]
|
||||
@@ -732,6 +736,42 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
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:
|
||||
|
||||
@@ -11,6 +11,7 @@ from calliope.comfyui.registry import (
|
||||
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
|
||||
@@ -142,6 +143,26 @@ def apply_media_group(
|
||||
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],
|
||||
@@ -167,6 +188,10 @@ def patch_workflow(
|
||||
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):
|
||||
|
||||
@@ -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"]
|
||||
@@ -33,6 +33,24 @@ IMAGE_OUTPUT_CLASSES = frozenset(
|
||||
{"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:
|
||||
|
||||
@@ -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
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -410,6 +411,43 @@ def _backfill_workflow_output_map(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
# 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)
|
||||
@@ -468,10 +506,32 @@ async def migrate_db(db_path: Path) -> None:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN output_map TEXT")
|
||||
if "output_map_source" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN output_map_source TEXT")
|
||||
# HEL-681 (S2): library metadata for 150+ scale workflow browsing. Purely
|
||||
# additive columns — old rows stay NULL/0 and every legacy path ignores
|
||||
# them; the lightweight list view (?view=meta) is their only reader.
|
||||
if "category" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN category TEXT")
|
||||
if "model_family" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN model_family TEXT")
|
||||
if "media_io" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN media_io TEXT")
|
||||
if "tags" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN tags TEXT")
|
||||
if "favorite" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0")
|
||||
if "last_used_at" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN last_used_at TIMESTAMP")
|
||||
if "source" not in cols:
|
||||
# where the row came from: 'ui_convert' (HEL-681 batch import) vs
|
||||
# NULL = manual/API import as before.
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN source TEXT")
|
||||
if "source_path" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN source_path TEXT")
|
||||
# Idempotent backfill: pending rows are re-judged on every boot (parser
|
||||
# upgrades can settle them); confirmed rows are never touched.
|
||||
_backfill_workflow_usage(conn)
|
||||
_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")
|
||||
|
||||
@@ -243,6 +243,55 @@ class WorkflowUpdate(BaseModel):
|
||||
# resets the workflow to pending confirmation.
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
output_map_source: Literal["auto", "user"] | None = None
|
||||
# HEL-681 (S2): library metadata fields.
|
||||
favorite: bool | None = None
|
||||
tags: list[str] | None = None
|
||||
category: str | None = None
|
||||
model_family: str | None = None
|
||||
|
||||
|
||||
class WorkflowUIFileItem(BaseModel):
|
||||
"""One UI-format file for the HEL-681 convert/import pipeline."""
|
||||
|
||||
name: str | None = None
|
||||
# repo-relative path (category/model-family evidence); display name
|
||||
# defaults to the file stem.
|
||||
source_path: str | None = None
|
||||
workflow_json: dict[str, Any]
|
||||
# optional human confirmations from the import wizard (S3): a settled
|
||||
# usage and a corrected schema let warn files import enabled.
|
||||
usage: WorkflowUsageKind | None = None
|
||||
input_schema: list[dict[str, Any]] | None = None
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
description: str | None = None
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
class WorkflowConvertRequest(BaseModel):
|
||||
"""Dry-run: per-file ok/warn/fail + suggested metadata, nothing stored."""
|
||||
|
||||
files: list[WorkflowUIFileItem]
|
||||
# Include the converted API JSON per file (default on for small batches;
|
||||
# the 146-file report leaves it off to keep the payload sane).
|
||||
include_workflows: bool = False
|
||||
# L2 preflight against a real ComfyUI (opt-in; needs a connection).
|
||||
endpoint_id: str | None = None
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class WorkflowImportBatchRequest(BaseModel):
|
||||
"""Commit: convert UI files and insert, skipping conflicts. Idempotent."""
|
||||
|
||||
files: list[WorkflowUIFileItem]
|
||||
|
||||
|
||||
class WorkflowImportScanRequest(BaseModel):
|
||||
"""Scan a server-side directory of UI JSONs (repo example library)."""
|
||||
|
||||
root: str = "example_ComfyUI_workflows"
|
||||
# Set to preview what would import (same shape as /convert) — the safe
|
||||
# default for a first run against the 146-file library.
|
||||
dry_run: bool = True
|
||||
|
||||
|
||||
class WorkflowAnalyze(BaseModel):
|
||||
|
||||
@@ -49,6 +49,12 @@ class QueueManager:
|
||||
""",
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload), origin),
|
||||
)
|
||||
# HEL-681: every enqueued job counts as a use (最近使用).
|
||||
if workflow_id is not None:
|
||||
conn.execute(
|
||||
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(workflow_id,),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
return row_to_dict(row)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Standalone ComfyUI playground — generate freely, attach favorites to projects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -31,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
|
||||
@@ -39,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):
|
||||
@@ -83,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()
|
||||
@@ -195,6 +286,17 @@ 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()
|
||||
# Workflow-test jobs (HEL-643) ride the same scratch project but must not
|
||||
@@ -204,7 +306,16 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
|
||||
for j in queue_manager.list_jobs(pid, None, limit)
|
||||
if str(j.get("origin") or "user") != "workflow_test"
|
||||
]
|
||||
return [_job_public(j) for j in jobs]
|
||||
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}")
|
||||
@@ -239,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()
|
||||
@@ -261,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -365,6 +492,14 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
schema = effective_input_schema(
|
||||
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()
|
||||
|
||||
@@ -385,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(
|
||||
@@ -495,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,
|
||||
}
|
||||
|
||||
@@ -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,12 @@ 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", ""),
|
||||
"confirmed_content": (
|
||||
json.loads(row["confirmed_content_json"]) if row["confirmed_content_json"] else None
|
||||
),
|
||||
@@ -571,6 +577,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 +691,9 @@ 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("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 +728,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 +816,10 @@ 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
|
||||
chosen = payload.chosen_style_id if payload else None
|
||||
if stage_key == "style":
|
||||
directions = content.get("directions") or []
|
||||
@@ -815,11 +857,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)
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, HTTPException
|
||||
|
||||
from calliope.comfyui.endpoints import client_for, endpoint_snapshot
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.metadata import suggested_metadata
|
||||
from calliope.comfyui.output_map import (
|
||||
auto_output_map,
|
||||
load_output_map,
|
||||
@@ -23,6 +24,7 @@ from calliope.comfyui.parser import (
|
||||
from calliope.comfyui.preflight import check_workflow_against
|
||||
from calliope.comfyui.profiles import detect_prompt_profile
|
||||
from calliope.comfyui.roles import usage_to_kind
|
||||
from calliope.comfyui.ui_convert import convert_ui_workflow
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, row_to_dict
|
||||
@@ -30,8 +32,12 @@ from calliope.events.bus import event_bus
|
||||
from calliope.models.schemas import (
|
||||
WorkflowAnalyze,
|
||||
WorkflowCheckRequest,
|
||||
WorkflowConvertRequest,
|
||||
WorkflowCreate,
|
||||
WorkflowImportBatchRequest,
|
||||
WorkflowImportScanRequest,
|
||||
WorkflowTestRequest,
|
||||
WorkflowUIFileItem,
|
||||
WorkflowUpdate,
|
||||
)
|
||||
from calliope.queue.manager import queue_manager
|
||||
@@ -44,6 +50,14 @@ from calliope.queue.workflow_test import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# HEL-681: metadata columns the lightweight list view exposes (everything
|
||||
# except the heavy workflow_json / input_schema / output_map payloads).
|
||||
_META_COLUMNS = (
|
||||
"id, name, kind, usage, is_enabled, category, model_family, media_io, tags,"
|
||||
" favorite, last_used_at, description, source, source_path, prompt_profile,"
|
||||
" created_at, endpoint_id, usage_source, output_map_source"
|
||||
)
|
||||
|
||||
|
||||
def _serialize_workflow(row: Any) -> dict[str, Any]:
|
||||
data = row_to_dict(row)
|
||||
@@ -61,9 +75,21 @@ def _serialize_workflow(row: Any) -> dict[str, Any]:
|
||||
data["needs_mapping"] = (
|
||||
mapping_problem(data["workflow_json"], raw_map, data.get("output_map_source")) is not None
|
||||
)
|
||||
# HEL-681: structured library metadata (raw JSON columns → values).
|
||||
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
|
||||
data["tags"] = _load_json_col(data.get("tags"), [])
|
||||
data["favorite"] = bool(data.get("favorite"))
|
||||
return data
|
||||
|
||||
|
||||
def _load_json_col(raw: Any, default: Any) -> Any:
|
||||
try:
|
||||
value = json.loads(raw) if raw else None
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
value = None
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
async def _connection_check(
|
||||
endpoint_id: str | None, workflow_json: dict[str, Any], refresh: bool = False
|
||||
) -> dict[str, Any]:
|
||||
@@ -216,9 +242,31 @@ def _last_test_summary(job: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_workflows() -> list[dict[str, Any]]:
|
||||
async def list_workflows(view: str | None = None) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
if view == "meta":
|
||||
# HEL-681: metadata-only list for 150+ scale browsing — no
|
||||
# workflow_json, no per-row re-parse. `needs_mapping` here is the
|
||||
# cheap pending check (usage/output_map not yet settled); rows
|
||||
# that never generated keep showing as pending exactly like the
|
||||
# full view reports them.
|
||||
rows = conn.execute(
|
||||
f"SELECT {_META_COLUMNS} FROM workflows ORDER BY favorite DESC,"
|
||||
" last_used_at DESC, id DESC"
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
data = row_to_dict(row)
|
||||
data["is_enabled"] = bool(data.get("is_enabled"))
|
||||
data["favorite"] = bool(data.get("favorite"))
|
||||
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
|
||||
data["tags"] = _load_json_col(data.get("tags"), [])
|
||||
data["needs_mapping"] = data.get("usage") is None or data.get(
|
||||
"output_map_source"
|
||||
) is None
|
||||
out.append(data)
|
||||
return out
|
||||
rows = conn.execute("SELECT * FROM workflows ORDER BY id DESC").fetchall()
|
||||
out = [_serialize_workflow(r) for r in rows]
|
||||
# 上次测试 state is read live from the newest test job per workflow —
|
||||
@@ -395,6 +443,12 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "is_enabled" in data and data["is_enabled"] is not None:
|
||||
data["is_enabled"] = 1 if data["is_enabled"] else 0
|
||||
# HEL-681: library metadata patch fields (favorite/tags/category/
|
||||
# model_family) — booleans to ints, lists to JSON text.
|
||||
if "favorite" in data and data["favorite"] is not None:
|
||||
data["favorite"] = 1 if data["favorite"] else 0
|
||||
if data.get("tags") is not None:
|
||||
data["tags"] = json.dumps(data["tags"])
|
||||
workflow_json = json.loads(existing["workflow_json"])
|
||||
# HEL-638: output_map payload. A non-empty list validates against the
|
||||
# stored JSON and becomes the user decision; an explicit empty list
|
||||
@@ -600,3 +654,280 @@ async def delete_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HEL-681 (S2): UI→API conversion dry-run + batch import + repo scan.
|
||||
# The convert endpoint never writes; import-batch converts again server-side
|
||||
# and skips anything uncertain (needs_confirmation / lint-blocked / conflict)
|
||||
# — repeated runs import nothing new, so the batch is idempotent by design.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _convert_one(item: WorkflowUIFileItem) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
"""(report, converted workflow) for one UI file — shared by every
|
||||
convert/import path so the analysis can never drift between them."""
|
||||
result = convert_ui_workflow(item.workflow_json)
|
||||
stem = (
|
||||
item.source_path.replace("\\", "/").rsplit("/", 1)[-1].removesuffix(".json")
|
||||
if item.source_path
|
||||
else None
|
||||
)
|
||||
name = item.name or stem or "未命名工作流"
|
||||
report: dict[str, Any] = {
|
||||
"name": name,
|
||||
"source_path": item.source_path,
|
||||
"status": result.status,
|
||||
"needs_confirmation": result.needs_confirmation,
|
||||
"issues": [
|
||||
{"node_id": i.node_id, "code": i.code, "level": i.level, "message": i.message}
|
||||
for i in result.issues
|
||||
],
|
||||
}
|
||||
if result.workflow is None:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = "转换失败:" + (
|
||||
result.issues[0].message if result.issues else "未知错误"
|
||||
)
|
||||
return report, None
|
||||
suggested = suggested_metadata(result.workflow, item.source_path, item.workflow_json)
|
||||
report["suggested"] = suggested
|
||||
# The import gate re-runs the product's own checks — a converted graph
|
||||
# that the lint would block (missing model link etc.) is reported, not
|
||||
# silently imported.
|
||||
broken = blocking_problem(result.workflow)
|
||||
if broken:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = broken
|
||||
report["status"] = "warn"
|
||||
return report, result.workflow
|
||||
if result.needs_confirmation:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = "存在无法可靠映射的控件,需人工确认后重导"
|
||||
return report, result.workflow
|
||||
report["importable"] = True
|
||||
return report, result.workflow
|
||||
|
||||
|
||||
@router.post("/convert")
|
||||
async def convert_workflows(payload: WorkflowConvertRequest) -> dict[str, Any]:
|
||||
"""Dry-run UI→API conversion for a batch of files: per-file ok/warn/fail,
|
||||
issues, suggested metadata and importability. Nothing is stored."""
|
||||
files = []
|
||||
object_info: dict[str, Any] | None = None
|
||||
client = None
|
||||
if payload.endpoint_id:
|
||||
endpoint = settings.resolve_comfyui_endpoint(payload.endpoint_id)
|
||||
if endpoint is not None:
|
||||
client = client_for(endpoint)
|
||||
try:
|
||||
object_info = await client.object_info(force_refresh=payload.refresh)
|
||||
except Exception:
|
||||
object_info = None
|
||||
try:
|
||||
for item in payload.files:
|
||||
report, converted = _convert_one(item)
|
||||
if object_info is not None and report.get("importable") and converted:
|
||||
findings = check_workflow_against(object_info, converted)
|
||||
errors = [f for f in findings if f.get("level") == "error"]
|
||||
report["preflight"] = {
|
||||
"endpoint_id": payload.endpoint_id,
|
||||
"ok": not errors,
|
||||
"findings": findings,
|
||||
}
|
||||
if errors:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = errors[0].get("message") or "preflight 未通过"
|
||||
if payload.include_workflows:
|
||||
report["workflow_json"] = converted
|
||||
files.append(report)
|
||||
finally:
|
||||
if client is not None:
|
||||
await client.close()
|
||||
counts = {key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")}
|
||||
return {
|
||||
"total": len(files),
|
||||
**counts,
|
||||
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
|
||||
"importable": sum(1 for f in files if f.get("importable")),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def _insert_converted(
|
||||
conn: Any, item: WorkflowUIFileItem, report: dict[str, Any], converted: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Insert one converted workflow row (import-batch's write path).
|
||||
|
||||
Mirrors create_workflow's enable gate: usage must be settled (auto or the
|
||||
wizard's confirmation) and unknown outputs need a mapping; anything less
|
||||
lands disabled exactly like a manual import would.
|
||||
"""
|
||||
suggested = report["suggested"]
|
||||
judgment = classify_usage(converted)
|
||||
usage = item.usage or judgment["usage"]
|
||||
usage_source = "user" if item.usage else "auto"
|
||||
kind = usage_to_kind(usage) or suggested["kind"]
|
||||
profile = detect_prompt_profile(converted)
|
||||
if item.output_map:
|
||||
problems = validate_output_map(converted, item.output_map)
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
output_map = json.dumps(item.output_map)
|
||||
output_map_source = "user"
|
||||
else:
|
||||
auto_map = auto_output_map(converted)
|
||||
output_map = json.dumps(auto_map) if auto_map else None
|
||||
output_map_source = "auto" if auto_map else None
|
||||
mapping_ok = not needs_user_output_map(converted) or bool(item.output_map)
|
||||
workflow_text = json.dumps(converted)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM workflows WHERE name = ? AND kind = ? AND workflow_json = ?",
|
||||
(report["name"], kind, workflow_text),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return {
|
||||
"result": "skipped",
|
||||
"skip_reason": "conflict",
|
||||
"existing_id": existing["id"],
|
||||
}
|
||||
is_enabled = 1 if (usage and mapping_ok) else 0
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema,
|
||||
description, prompt_profile, endpoint_id, is_enabled,
|
||||
usage, usage_source, output_map, output_map_source,
|
||||
category, model_family, media_io, tags, favorite,
|
||||
source, source_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'ui_convert', ?)
|
||||
""",
|
||||
(
|
||||
report["name"],
|
||||
kind,
|
||||
workflow_text,
|
||||
json.dumps(
|
||||
item.input_schema if item.input_schema else parse_dynamic_inputs(converted)
|
||||
),
|
||||
json.dumps(parse_dynamic_outputs(converted)),
|
||||
item.description if item.description is not None else suggested["description"],
|
||||
profile,
|
||||
is_enabled,
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
suggested["category"],
|
||||
suggested["model_family"],
|
||||
json.dumps({"in": suggested["media_in"], "out": suggested["media_out"]}),
|
||||
json.dumps(item.tags or []),
|
||||
item.source_path,
|
||||
),
|
||||
)
|
||||
return {"result": "imported", "id": cur.lastrowid}
|
||||
|
||||
|
||||
async def _run_import(items: list[WorkflowUIFileItem]) -> dict[str, Any]:
|
||||
reports: list[dict[str, Any]] = []
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
for item in items:
|
||||
report, converted = _convert_one(item)
|
||||
if not report.get("importable"):
|
||||
report["result"] = "skipped"
|
||||
reports.append(report)
|
||||
continue
|
||||
try:
|
||||
outcome = _insert_converted(conn, item, report, converted)
|
||||
except HTTPException as exc:
|
||||
report["result"] = "failed"
|
||||
report["skip_reason"] = str(exc.detail)
|
||||
reports.append(report)
|
||||
continue
|
||||
conn.commit()
|
||||
report.update(outcome)
|
||||
reports.append(report)
|
||||
finally:
|
||||
conn.close()
|
||||
imported = [r for r in reports if r.get("result") == "imported"]
|
||||
skipped = [
|
||||
{"name": r["name"], "source_path": r["source_path"], "reason": r.get("skip_reason")}
|
||||
for r in reports
|
||||
if r.get("result") != "imported"
|
||||
]
|
||||
return {
|
||||
"total": len(reports),
|
||||
"imported": len(imported),
|
||||
"skipped": len(skipped),
|
||||
"imported_ids": [r["id"] for r in imported],
|
||||
"skipped_files": skipped,
|
||||
"files": reports,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/import-batch")
|
||||
async def import_workflows_batch(payload: WorkflowImportBatchRequest) -> dict[str, Any]:
|
||||
"""Batch-import UI-format workflows: convert server-side, insert what is
|
||||
cleanly convertible, skip conflicts and anything uncertain. Re-running
|
||||
with the same files creates zero new rows."""
|
||||
return await _run_import(payload.files)
|
||||
|
||||
|
||||
@router.post("/import-scan")
|
||||
async def import_workflows_scan(payload: WorkflowImportScanRequest) -> dict[str, Any]:
|
||||
"""Scan a server-side directory for UI-format workflow JSONs and run the
|
||||
convert (dry_run, default) or import pipeline over every file found."""
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(payload.root)
|
||||
if not root.is_absolute():
|
||||
candidates = [
|
||||
Path.cwd() / root,
|
||||
Path(__file__).resolve().parents[4] / root,
|
||||
]
|
||||
root = next((c for c in candidates if c.is_dir()), root)
|
||||
if not root.is_dir():
|
||||
raise HTTPException(status_code=404, detail=f"目录不存在:{payload.root}")
|
||||
items: list[WorkflowUIFileItem] = []
|
||||
for path in sorted(root.rglob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
|
||||
continue # API-format or non-workflow files: not this pipeline's job
|
||||
items.append(
|
||||
WorkflowUIFileItem(
|
||||
source_path=str(path.relative_to(root)), workflow_json=data
|
||||
)
|
||||
)
|
||||
if payload.dry_run:
|
||||
files = [_convert_one(item)[0] for item in items]
|
||||
counts = {
|
||||
key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")
|
||||
}
|
||||
return {
|
||||
"total": len(files),
|
||||
**counts,
|
||||
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
|
||||
"importable": sum(1 for f in files if f.get("importable")),
|
||||
"files": files,
|
||||
}
|
||||
return await _run_import(items)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/touch")
|
||||
async def touch_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
"""Record a use (最近使用) — called by the generate pipeline on enqueue."""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
conn.execute(
|
||||
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(workflow_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1,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)])
|
||||
@@ -36,6 +36,7 @@ QWEN_EDIT = "qwen21_edit_sage_API.json" # qwen-image-2.1-图片编辑-sage加
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
REF_KEY = "131@ref_images"
|
||||
LAST_KEY = "131@last_frame"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
@@ -61,7 +62,8 @@ def _ref_links(wf: dict, node_id: str = "131") -> list[tuple[str, str]]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_i2v_exposes_one_required_first_frame_and_no_last_frame():
|
||||
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 == [
|
||||
@@ -73,10 +75,36 @@ def test_i2v_exposes_one_required_first_frame_and_no_last_frame():
|
||||
"defaultValue": None,
|
||||
"required": True,
|
||||
"auto": True,
|
||||
}
|
||||
},
|
||||
{
|
||||
"nodeId": LAST_KEY,
|
||||
"label": "尾帧",
|
||||
"role": "last_frame",
|
||||
"kind": "image",
|
||||
"defaultValue": None,
|
||||
"required": False,
|
||||
"auto": True,
|
||||
},
|
||||
]
|
||||
# the title comment about FL2V is not a slot
|
||||
assert not any(i.get("role") == "last_frame" for i in inputs)
|
||||
|
||||
|
||||
def test_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():
|
||||
@@ -201,6 +229,29 @@ def test_single_slot_values_patch_first_frame_and_qwen_input():
|
||||
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"})
|
||||
@@ -265,6 +316,14 @@ def test_validate_rejects_bad_reference_files(uploads_dir):
|
||||
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 == ["请上传输入图"]
|
||||
|
||||
@@ -403,9 +462,67 @@ def test_i2v_chain_uploads_first_frame(client, named_comfy):
|
||||
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)})
|
||||
|
||||
@@ -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,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"]
|
||||
)
|
||||
@@ -82,13 +82,16 @@ def test_a2_qwen_edit_variant_with_new_ids_titles_and_model_stays_image2image():
|
||||
assert len(mask) == 1 # mask never counts as an input image
|
||||
|
||||
|
||||
def test_a3_i2v_variant_renamed_still_image2video_first_frame_only():
|
||||
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))
|
||||
assert [i.get("role") for i in media] == ["first_frame"]
|
||||
# A3: no first/last pair → the pair validation must not fire
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
+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>
|
||||
|
||||
@@ -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) };
|
||||
};
|
||||
@@ -140,6 +140,11 @@ import type {
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
WorkflowMeta,
|
||||
WorkflowUIFileInput,
|
||||
WorkflowConvertFileReport,
|
||||
WorkflowConvertReport,
|
||||
WorkflowImportResult,
|
||||
} from "./comfy/types";
|
||||
export type {
|
||||
Job,
|
||||
@@ -154,6 +159,11 @@ export type {
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
WorkflowMeta,
|
||||
WorkflowUIFileInput,
|
||||
WorkflowConvertFileReport,
|
||||
WorkflowConvertReport,
|
||||
WorkflowImportResult,
|
||||
};
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -462,6 +472,50 @@ export const comfy = {
|
||||
|
||||
export const workflows = {
|
||||
list: () => api<Workflow[]>("/api/workflows"),
|
||||
/** HEL-682: metadata-only list — 150+ rows without any workflow_json. */
|
||||
listMeta: () => api<WorkflowMeta[]>("/api/workflows?view=meta"),
|
||||
/** HEL-682: dry-run UI→API conversion for a batch of files (nothing stored). */
|
||||
convert: (
|
||||
files: WorkflowUIFileInput[],
|
||||
opts?: { endpoint_id?: string | null; refresh?: boolean },
|
||||
) =>
|
||||
api<WorkflowConvertReport>("/api/workflows/convert", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
files,
|
||||
...(opts?.endpoint_id ? { endpoint_id: opts.endpoint_id } : {}),
|
||||
...(opts?.refresh ? { refresh: opts.refresh } : {}),
|
||||
}),
|
||||
}),
|
||||
/** HEL-682: commit a batch import (converts server-side, conflicts skipped). */
|
||||
importBatch: (files: WorkflowUIFileInput[]) =>
|
||||
api<WorkflowImportResult>("/api/workflows/import-batch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ files }),
|
||||
}),
|
||||
/** HEL-682: scan a server-side directory of UI-format JSONs — dry-run report. */
|
||||
importScanDry: (root: string) =>
|
||||
api<WorkflowConvertReport>("/api/workflows/import-scan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ root, dry_run: true }),
|
||||
}),
|
||||
/** HEL-682: scan a server-side directory of UI-format JSONs — commit the import. */
|
||||
importScanCommit: (root: string) =>
|
||||
api<WorkflowImportResult>("/api/workflows/import-scan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ root, dry_run: false }),
|
||||
}),
|
||||
/** HEL-682: record a use (最近使用). */
|
||||
touch: (id: number) =>
|
||||
api<{ ok: boolean }>(`/api/workflows/${id}/touch`, { method: "POST" }),
|
||||
/** HEL-682: delete byte-identical duplicates, keeping the lowest id. */
|
||||
dedupe: () =>
|
||||
api<{
|
||||
ok: boolean;
|
||||
removed: number;
|
||||
kept_ids: number[];
|
||||
removed_ids: number[];
|
||||
}>("/api/workflows/dedupe", { method: "POST" }),
|
||||
analyze: (
|
||||
workflow_json: Record<string, unknown>,
|
||||
endpoint_id?: string | null,
|
||||
@@ -537,6 +591,10 @@ export const workflows = {
|
||||
input_schema: ComfyDynamicInput[];
|
||||
output_map: WorkflowOutputMapEntry[];
|
||||
output_map_source: "auto" | "user";
|
||||
favorite: boolean;
|
||||
tags: string[];
|
||||
category: string;
|
||||
model_family: string;
|
||||
}>,
|
||||
) =>
|
||||
api<Workflow>(`/api/workflows/${id}`, {
|
||||
@@ -638,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",
|
||||
@@ -655,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;
|
||||
@@ -750,6 +834,7 @@ export interface VideoShot {
|
||||
}
|
||||
|
||||
export interface WorkbenchStage {
|
||||
assistant_note?: string;
|
||||
stage_key: WorkbenchStageKey;
|
||||
status: WorkbenchStageStatus;
|
||||
content: Record<string, unknown> | null;
|
||||
@@ -773,6 +858,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: (
|
||||
|
||||
@@ -98,8 +98,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 +117,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 +141,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 +205,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;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,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 +176,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;
|
||||
}
|
||||
@@ -175,6 +175,120 @@ export interface Workflow {
|
||||
last_test?: WorkflowLastTest | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HEL-682: metadata-only row (GET /api/workflows?view=meta) — the whole
|
||||
* point is browsing 150+ workflows without downloading workflow_json.
|
||||
*/
|
||||
export interface WorkflowMediaIO {
|
||||
in: string[];
|
||||
out: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowMeta {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: 'image' | 'video';
|
||||
usage: WorkflowUsage | null;
|
||||
usage_source: 'auto' | 'user' | null;
|
||||
is_enabled: boolean;
|
||||
category: string | null;
|
||||
model_family: string | null;
|
||||
media_io: WorkflowMediaIO;
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
last_used_at: string | null;
|
||||
description: string | null;
|
||||
source: string | null;
|
||||
source_path: string | null;
|
||||
prompt_profile: string;
|
||||
created_at: string;
|
||||
endpoint_id: string | null;
|
||||
output_map_source: 'auto' | 'user' | null;
|
||||
/** Cheap pending check: usage or output mapping not settled yet. */
|
||||
needs_mapping: boolean;
|
||||
}
|
||||
|
||||
/** One UI-format file submitted to the batch convert/import pipeline. */
|
||||
export interface WorkflowUIFileInput {
|
||||
name?: string;
|
||||
source_path?: string;
|
||||
workflow_json: Record<string, unknown>;
|
||||
usage?: WorkflowUsage;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowConvertIssue {
|
||||
node_id: string;
|
||||
code: string;
|
||||
level: 'error' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WorkflowConvertSuggested {
|
||||
kind: 'image' | 'video';
|
||||
usage: WorkflowUsage | null;
|
||||
usage_ambiguous: boolean;
|
||||
category: string | null;
|
||||
category_label: string | null;
|
||||
model_family: string | null;
|
||||
media_in: string[];
|
||||
media_out: string[];
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowPreflightFinding {
|
||||
level: string;
|
||||
code: string;
|
||||
node_id?: string;
|
||||
node_title?: string;
|
||||
message: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowConvertFileReport {
|
||||
name: string;
|
||||
source_path: string | null;
|
||||
status: 'ok' | 'warn' | 'fail';
|
||||
needs_confirmation: boolean;
|
||||
issues: WorkflowConvertIssue[];
|
||||
importable: boolean;
|
||||
skip_reason?: string | null;
|
||||
suggested?: WorkflowConvertSuggested;
|
||||
preflight?: {
|
||||
endpoint_id: string | null;
|
||||
ok: boolean;
|
||||
findings: WorkflowPreflightFinding[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Dry-run report (POST /convert, POST /import-scan with dry_run). */
|
||||
export interface WorkflowConvertReport {
|
||||
total: number;
|
||||
ok: number;
|
||||
warn: number;
|
||||
fail: number;
|
||||
needs_confirmation: number;
|
||||
importable: number;
|
||||
files: WorkflowConvertFileReport[];
|
||||
}
|
||||
|
||||
export interface WorkflowImportFileReport extends WorkflowConvertFileReport {
|
||||
result?: 'imported' | 'skipped' | 'failed';
|
||||
id?: number;
|
||||
existing_id?: number;
|
||||
}
|
||||
|
||||
/** Commit result (POST /import-batch, POST /import-scan without dry_run). */
|
||||
export interface WorkflowImportResult {
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
imported_ids: number[];
|
||||
skipped_files: { name: string; source_path: string | null; reason: string | null }[];
|
||||
files: WorkflowImportFileReport[];
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: number;
|
||||
project_id: number;
|
||||
@@ -197,6 +311,13 @@ export interface Job {
|
||||
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 {
|
||||
|
||||
@@ -522,7 +522,7 @@
|
||||
}
|
||||
.role {
|
||||
font-weight: 600;
|
||||
color: var(--accent, #a78bfa);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
margin-left: 6px;
|
||||
font-family: ui-monospace, monospace;
|
||||
@@ -567,7 +567,7 @@
|
||||
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;
|
||||
@@ -607,7 +607,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;
|
||||
|
||||
@@ -96,8 +96,8 @@
|
||||
<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} />
|
||||
@@ -108,7 +108,9 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
class="tool"
|
||||
type="button"
|
||||
aria-label={frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
|
||||
title={frameBusy ? t('lastFrame.exporting') : `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
|
||||
title={frameBusy
|
||||
? t('lastFrame.exporting')
|
||||
: `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
|
||||
disabled={frameBusy}
|
||||
aria-busy={frameBusy}
|
||||
onclick={(e) => {
|
||||
@@ -179,9 +181,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}
|
||||
@@ -237,7 +238,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;
|
||||
}
|
||||
@@ -249,13 +250,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);
|
||||
@@ -272,13 +273,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);
|
||||
@@ -334,16 +335,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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -668,8 +668,8 @@ let {
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -772,7 +772,7 @@ let {
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-ink);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
@@ -785,7 +785,7 @@ let {
|
||||
.send.stop {
|
||||
background: transparent;
|
||||
color: var(--error);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.send.steer {
|
||||
color: var(--accent);
|
||||
|
||||
@@ -431,6 +431,6 @@
|
||||
}
|
||||
.del:hover {
|
||||
color: var(--error);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
background: var(--err-soft);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
.tool-card.error {
|
||||
border-color: rgba(239, 68, 68, 0.45);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
/**
|
||||
* Agent identity palette: a stable display name + accent color per agent.
|
||||
* Agent identity palette: a stable display name + identity step per agent.
|
||||
*
|
||||
* Identity colors answer "who is speaking" only (avatar / name / bubble
|
||||
* tint) — they never express task status (spec v2 §3.6). The curated set
|
||||
* reuses the spec §3.6 avatar five-color family plus one neutral slate, so
|
||||
* no tailwind rainbow hues leak into the fusion palette; the default
|
||||
* main-loop agent is neutral `--text-2` slate.
|
||||
* Identity answers "who is speaking" only (avatar dot / name / bubble tint)
|
||||
* — it never expresses task status. HEL-685 无彩化:全部改为 app.css 的
|
||||
* 显影灰阶实色阶 --id-1..5(与全站 --avatar-1..5 同一身份语言),
|
||||
* 不再使用彩色身份色;默认主循环智能体保持中性 --id-4。
|
||||
*/
|
||||
|
||||
export const DEFAULT_AGENT_NAME = 'Calliope';
|
||||
|
||||
// Fixed identity map (keyed by the raw agent_name the backend emits).
|
||||
// Step numbers mirror the avatar five-set used across the app
|
||||
// (models/agents/home project cards).
|
||||
const IDENTITIES: Record<string, { label: string; color: string }> = {
|
||||
planner: { label: 'Planner', color: '#5b8def' }, // avatar-2 blue
|
||||
'story-agent': { label: 'Story Agent', color: '#3fb97f' }, // avatar-3 green
|
||||
'script-agent': { label: 'Script Agent', color: '#e8a33d' }, // avatar-1 amber
|
||||
'assets-agent': { label: 'Assets Agent', color: '#b98be0' }, // avatar-4 violet
|
||||
'video-agent': { label: 'Video Agent', color: '#d98e6a' }, // avatar-5 clay
|
||||
'research-agent': { label: 'Research Agent', color: '#8fa3c8' }, // neutral slate
|
||||
planner: { label: 'Planner', color: 'var(--id-2)' },
|
||||
'story-agent': { label: 'Story Agent', color: 'var(--id-3)' },
|
||||
'script-agent': { label: 'Script Agent', color: 'var(--id-1)' },
|
||||
'assets-agent': { label: 'Assets Agent', color: 'var(--id-4)' },
|
||||
'video-agent': { label: 'Video Agent', color: 'var(--id-5)' },
|
||||
'research-agent': { label: 'Research Agent', color: 'var(--id-4)' },
|
||||
// The five named agents (HEL-613) — the runner stamps their zh names on
|
||||
// named-session replies; colors mirror the spec §2.1 avatar five-set.
|
||||
剧本创作: { label: '剧本创作', color: '#e8a33d' },
|
||||
视觉设计: { label: '视觉设计', color: '#5b8def' },
|
||||
分镜设计: { label: '分镜设计', color: '#3fb97f' },
|
||||
素材资产: { label: '素材资产', color: '#b98be0' },
|
||||
镜头脚本: { label: '镜头脚本', color: '#d98e6a' },
|
||||
// named-session replies; steps mirror the avatar five-set.
|
||||
剧本创作: { label: '剧本创作', color: 'var(--id-1)' },
|
||||
视觉设计: { label: '视觉设计', color: 'var(--id-2)' },
|
||||
分镜设计: { label: '分镜设计', color: 'var(--id-3)' },
|
||||
素材资产: { label: '素材资产', color: 'var(--id-4)' },
|
||||
镜头脚本: { label: '镜头脚本', color: 'var(--id-5)' },
|
||||
};
|
||||
|
||||
// Fallback palette for unknown names (stable hash → deterministic color),
|
||||
// narrowed to the avatar identity family + neutral slate (spec v2 §十.4).
|
||||
const FALLBACK_COLORS = ['#5b8def', '#3fb97f', '#e8a33d', '#b98be0', '#d98e6a', '#8fa3c8'];
|
||||
// Fallback palette for unknown names (stable hash → deterministic step),
|
||||
// spanning the same five grayscale identity steps.
|
||||
const FALLBACK_COLORS = [
|
||||
'var(--id-1)',
|
||||
'var(--id-2)',
|
||||
'var(--id-3)',
|
||||
'var(--id-4)',
|
||||
'var(--id-5)',
|
||||
];
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 0;
|
||||
@@ -39,8 +46,8 @@ function hashString(s: string): number {
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Neutral slate (--text-2) — the default agent borrows no signal color. */
|
||||
export const DEFAULT_AGENT_COLOR = '#aab2c5';
|
||||
/** Neutral step (--id-4) — the default agent borrows no signal color. */
|
||||
export const DEFAULT_AGENT_COLOR = 'var(--id-4)';
|
||||
|
||||
export function agentDisplayName(name: string | null | undefined): string {
|
||||
const key = (name || '').trim().toLowerCase();
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
<script lang="ts">
|
||||
import { untrack, type Snippet } from 'svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import {
|
||||
clearOutputMissing,
|
||||
markOutputMissing,
|
||||
missingOutputsOf,
|
||||
} from '$lib/missingOutputs.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
import { usageLabel } from '../generate/schema';
|
||||
import {
|
||||
elapsedSec,
|
||||
fmtClock,
|
||||
fmtDur,
|
||||
inputsOf,
|
||||
isVideoOutput,
|
||||
promptOf,
|
||||
specOf,
|
||||
} from '../generate/takes';
|
||||
|
||||
/**
|
||||
* 检视区 (spec-v2 §3.3): 结果 shows the selected take on the projection
|
||||
* stage with its spec, inputs and workflow; 参数 hosts the full form.
|
||||
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
|
||||
* fullscreen — `overlay` / `open` drive the last two.
|
||||
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
|
||||
* ↑↓/J/K 即时无动画), video playback is always explicit,
|
||||
* failed takes get 重试, and a done take whose files no longer load
|
||||
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
|
||||
*/
|
||||
interface Props {
|
||||
main?: boolean;
|
||||
comparePath?: string | null;
|
||||
tab: 'result' | 'params';
|
||||
job: Job | null;
|
||||
wf: Workflow | undefined;
|
||||
nowMs: number;
|
||||
open: boolean;
|
||||
/** Collapsed or off-screen — nothing inside may take focus. */
|
||||
inactive: boolean;
|
||||
/** Current workflow can take an image output (送入当前图片槽). */
|
||||
canSendToSlot: boolean;
|
||||
/** Animate this result change (pointer pick) — 150ms cross-fade. */
|
||||
crossfade: boolean;
|
||||
/** The stage <video> element, for explicit playback controls. */
|
||||
videoEl?: HTMLVideoElement | null;
|
||||
params: Snippet;
|
||||
onClose: () => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onCancel: (job: Job) => void;
|
||||
onSendToSlot: (path: string) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
main = false,
|
||||
comparePath = null,
|
||||
tab = $bindable(),
|
||||
job,
|
||||
wf,
|
||||
nowMs,
|
||||
open,
|
||||
inactive,
|
||||
canSendToSlot,
|
||||
crossfade,
|
||||
videoEl = $bindable(null),
|
||||
params,
|
||||
onClose,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onSendToSlot,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
let detailsOpen = $state(false);
|
||||
let outIndex = $state(0);
|
||||
let compare = $state(false),
|
||||
compareAt = $state(50),
|
||||
lightbox = $state(false);
|
||||
$effect(() => {
|
||||
void job?.id;
|
||||
detailsOpen = false;
|
||||
compare = false;
|
||||
lightbox = false;
|
||||
});
|
||||
$effect.pre(() => {
|
||||
void job?.id;
|
||||
outIndex = 0;
|
||||
});
|
||||
|
||||
const st = $derived(job ? jobStatusInfo(job) : null);
|
||||
// §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files
|
||||
// cannot be fetched — the failed case stays red.
|
||||
const gonePaths = $derived(missingOutputsOf(job));
|
||||
const fileGone = $derived(gonePaths.length > 0);
|
||||
const outputs = $derived(
|
||||
job && st?.tone === 'ok'
|
||||
? job.output_paths.filter((p) => !gonePaths.includes(p))
|
||||
: [],
|
||||
);
|
||||
const output = $derived(
|
||||
outputs[Math.min(outIndex, outputs.length - 1)] ?? null,
|
||||
);
|
||||
const allOutputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
|
||||
const video = $derived(!!job && !!output && isVideoOutput(job, output));
|
||||
const wfDeleted = $derived(!!job && job.workflow_id == null);
|
||||
const prompt = $derived(job ? promptOf(job, wf) : '');
|
||||
const inputs = $derived(job ? inputsOf(job, wf) : []);
|
||||
const compareSource = $derived(
|
||||
inputs.find((i) => i.kind === 'image')?.path ?? comparePath,
|
||||
);
|
||||
const spec = $derived(job ? specOf(job, wf) : []);
|
||||
const endpoint = $derived(
|
||||
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as
|
||||
string | undefined) ?? '',
|
||||
);
|
||||
const progress = $derived.by(() => {
|
||||
if (!job || job.status !== 'running') return 0;
|
||||
const live = progressFor(job.id);
|
||||
const phase = live?.phase ?? job.phase;
|
||||
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
|
||||
return live?.progress ?? 3;
|
||||
});
|
||||
let dims = $state('');
|
||||
$effect.pre(() => {
|
||||
void output;
|
||||
dims = '';
|
||||
});
|
||||
function noteImg(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
dims = `${img.naturalWidth}×${img.naturalHeight}`;
|
||||
}
|
||||
function noteVideo(e: Event) {
|
||||
const v = e.currentTarget as HTMLVideoElement;
|
||||
dims = `${v.videoWidth}×${v.videoHeight}`;
|
||||
}
|
||||
|
||||
let paused = $state(true);
|
||||
$effect.pre(() => {
|
||||
void output;
|
||||
untrack(() => {
|
||||
videoEl?.pause();
|
||||
paused = true;
|
||||
});
|
||||
});
|
||||
function togglePlayback() {
|
||||
if (!videoEl) return;
|
||||
if (videoEl.paused) videoEl.play().catch(() => {});
|
||||
else videoEl.pause();
|
||||
}
|
||||
|
||||
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
|
||||
|
||||
let frameBusy = $state(false);
|
||||
async function exportTail() {
|
||||
const src = assetUrl(output);
|
||||
if (!src || frameBusy) return;
|
||||
frameBusy = true;
|
||||
try {
|
||||
await exportLastFrame(src);
|
||||
} finally {
|
||||
frameBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="artboard" aria-label="生成结果">
|
||||
<header class="artboard-toolbar">
|
||||
<div class="result-title">
|
||||
<span>第 {job?.id} 次生成</span><small>{wf?.name ?? '历史作品'}</small>
|
||||
</div>
|
||||
<div class="canvas-actions">
|
||||
{#if output && !video}<button
|
||||
class="canvas-btn"
|
||||
onclick={() => (lightbox = true)}
|
||||
aria-label="放大查看">⤢</button
|
||||
>
|
||||
{#if compareSource && compareSource !== output}<button
|
||||
class="canvas-btn"
|
||||
aria-pressed={compare}
|
||||
onclick={() => (compare = !compare)}
|
||||
>◫ {compare ? '结束对比' : '对比'}</button
|
||||
>{/if}
|
||||
{/if}
|
||||
{#if output}<a
|
||||
class="canvas-btn"
|
||||
href={assetUrl(output)}
|
||||
download
|
||||
aria-label="下载生成结果">↓ 下载</a
|
||||
>{/if}
|
||||
<button class="canvas-btn" onclick={() => (detailsOpen = true)}
|
||||
>详情与操作</button
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
<div class="stage" class:comparing={compare}>
|
||||
{#if job && st}{#key mediaKey}
|
||||
<div class="stage-media">
|
||||
{#if output && video}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<div class="print a-photo">
|
||||
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
|
||||
>{t('jobst.done')}</span
|
||||
>{/if}
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
src={assetUrl(output)}
|
||||
controls
|
||||
aria-label="生成的视频,使用播放按钮开始播放"
|
||||
onplay={() => (paused = false)}
|
||||
onpause={() => (paused = true)}
|
||||
onended={() => (paused = true)}
|
||||
playsinline
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
></video>
|
||||
</div>
|
||||
{:else if output}
|
||||
<div class="print a-photo">
|
||||
{#if st.tone === 'ok' && !fileGone}<span class="a-fix"
|
||||
>{t('jobst.done')}</span
|
||||
>{/if}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt={prompt || t('desk.tabResult')}
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImg(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
{#if main && compare && compareSource}<div
|
||||
class="comparison-layer"
|
||||
style:clip-path={`inset(0 ${100 - compareAt}% 0 0)`}
|
||||
>
|
||||
<img src={assetUrl(compareSource)} alt="对比参考画面" />
|
||||
</div>
|
||||
<div
|
||||
class="comparison-divider"
|
||||
style:left={`${compareAt}%`}
|
||||
></div>{/if}
|
||||
</div>
|
||||
{:else if fileGone}
|
||||
<div class="stage-empty gone">
|
||||
<span>{t('desk.missingTitle')}</span>
|
||||
<span class="sub">{t('desk.missingSub')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stage-empty">
|
||||
<span>{st.label}</span>
|
||||
<span class="sub"
|
||||
>{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span
|
||||
>
|
||||
{#if progress > 0}
|
||||
<div class="a-progress stage-bar" style:--p={progress / 100}>
|
||||
<i></i>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}{/if}
|
||||
{#if outputs.length > 1}<div
|
||||
class="output-picker"
|
||||
role="group"
|
||||
aria-label="本次生成的作品"
|
||||
>
|
||||
{#each outputs as p, i}<button
|
||||
aria-pressed={output === p}
|
||||
onclick={() => (outIndex = i)}
|
||||
aria-label={'查看第 ' + (i + 1) + ' 件作品'}>{i + 1}</button
|
||||
>{/each}
|
||||
</div>{/if}
|
||||
</div>
|
||||
<footer class="artboard-footer">
|
||||
{#if video}<button
|
||||
class="canvas-btn play-button"
|
||||
onclick={togglePlayback}
|
||||
aria-label={paused ? '播放视频' : '暂停视频'}
|
||||
>{paused ? '▶ 播放' : 'Ⅱ 暂停'}</button
|
||||
><button
|
||||
class="canvas-btn"
|
||||
onclick={() => videoEl?.requestFullscreen().catch(() => {})}
|
||||
>全屏</button
|
||||
>{/if}
|
||||
<span class="result-spec"
|
||||
>{dims || st?.label} · {job ? fmtDur(elapsedSec(job, nowMs)) : ''}</span
|
||||
>
|
||||
{#if compare && output && !video}<label class="compare-slider"
|
||||
>参考<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
bind:value={compareAt}
|
||||
aria-label="拖动对比画面"
|
||||
/>结果</label
|
||||
>{/if}
|
||||
{#if job?.status === 'pending' || job?.status === 'running'}<button
|
||||
class="canvas-btn"
|
||||
onclick={() => job && onCancel(job)}>取消生成</button
|
||||
>{/if}
|
||||
{#if st?.tone === 'fail'}<button
|
||||
class="canvas-btn"
|
||||
onclick={() => job && onRetry(job)}>重试</button
|
||||
>{/if}
|
||||
</footer>
|
||||
</div>
|
||||
<Modal bind:open={detailsOpen} size="lg" title="作品详情与操作">
|
||||
{#if job && st}
|
||||
<div class="detail-actions">
|
||||
<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => {
|
||||
onReuse(job);
|
||||
detailsOpen = false;
|
||||
}}>复用到输入区</button
|
||||
>
|
||||
{#if output && !video && canSendToSlot}<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => {
|
||||
onSendToSlot(output);
|
||||
detailsOpen = false;
|
||||
}}>送入当前图片槽</button
|
||||
>{/if}
|
||||
{#if video && output}<button
|
||||
class="a-btn secondary sm"
|
||||
disabled={frameBusy}
|
||||
onclick={exportTail}>{frameBusy ? '正在导出…' : '导出尾帧'}</button
|
||||
>{/if}
|
||||
{#if st.tone === 'fail' || fileGone}<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => onRetry(job)}>用相同参数重做</button
|
||||
>{/if}
|
||||
{#if job.status !== 'pending' && job.status !== 'running'}<button
|
||||
class="a-btn danger sm"
|
||||
onclick={() => {
|
||||
onDelete(job);
|
||||
detailsOpen = false;
|
||||
}}>移除记录</button
|
||||
>{/if}
|
||||
</div>
|
||||
{#if st.tone === 'fail' || fileGone}<p class="error-note" role="alert">
|
||||
{fileGone ? t('jobst.missingDetail') : st.detail}
|
||||
</p>{/if}
|
||||
<div class="detail-meta">
|
||||
{#each spec as s}<span>{s.label} · {s.value}</span
|
||||
>{/each}{#if endpoint}<span>{endpoint}</span>{/if}
|
||||
</div>
|
||||
{#if inputs.length || prompt}
|
||||
<div class="section-title">{t('desk.inputs')}</div>
|
||||
{#if inputs.length}
|
||||
<div class="inputs">
|
||||
{#each inputs as inp (inp.label + inp.path)}
|
||||
{#if inp.kind === 'image'}
|
||||
<img
|
||||
class="mini"
|
||||
src={assetUrl(inp.path)}
|
||||
alt={inp.label}
|
||||
title={inp.label}
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="mini glyph" title={inp.label}
|
||||
><Icon
|
||||
name={inp.kind === 'video' ? 'film' : 'music'}
|
||||
size={14}
|
||||
/></span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<div class="section-title">{t('desk.workflow')}</div>
|
||||
<dl class="kv">
|
||||
<dt>{t('desk.kvName')}</dt>
|
||||
<dd>
|
||||
{wf?.name ?? t('studio.jobName', { id: job.id })}
|
||||
{#if wfDeleted}<span class="a-tag wf-gone"
|
||||
>{t('studio.wfDeletedTag')}</span
|
||||
>{/if}
|
||||
</dd>
|
||||
{#if wf?.usage}
|
||||
<dt>{t('desk.kvUsage')}</dt>
|
||||
<dd>{usageLabel(wf.usage)}</dd>
|
||||
{/if}
|
||||
<dt>{t('desk.kvCreated')}</dt>
|
||||
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
|
||||
{#if job.comfy_prompt_id}
|
||||
<dt>{t('desk.kvPromptId')}</dt>
|
||||
<dd class="mono">{job.comfy_prompt_id}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
{/if}
|
||||
</Modal>
|
||||
{#if lightbox && output && !video}<ImageLightbox
|
||||
src={assetUrl(output) ?? ''}
|
||||
alt={prompt || '创作结果'}
|
||||
onClose={() => (lightbox = false)}
|
||||
/>{/if}
|
||||
|
||||
<style>
|
||||
.artboard {
|
||||
display: grid;
|
||||
grid-template-rows: 38px minmax(0, 1fr) 32px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid #ffffff0d;
|
||||
border-radius: 12px;
|
||||
background: radial-gradient(ellipse at 50% 20%, #202028, #131316 70%);
|
||||
overflow: hidden;
|
||||
}
|
||||
.artboard-toolbar,
|
||||
.artboard-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.result-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.result-title small {
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 220px;
|
||||
}
|
||||
.canvas-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.canvas-btn {
|
||||
background: #ffffff04;
|
||||
color: var(--text-2);
|
||||
border: 1px solid #ffffff0a;
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.canvas-btn:hover {
|
||||
color: var(--text);
|
||||
background: #ffffff0a;
|
||||
}
|
||||
.canvas-btn[aria-pressed='true'],
|
||||
.play-button {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.stage {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stage-media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 8px 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 0;
|
||||
}
|
||||
.print {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template: minmax(0, 1fr) / minmax(0, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.print img,
|
||||
.print video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.print :global(.a-fix) {
|
||||
display: none;
|
||||
}
|
||||
.stage-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.stage-empty .sub {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.stage-bar {
|
||||
width: 200px;
|
||||
}
|
||||
.comparison-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #17171d;
|
||||
pointer-events: none;
|
||||
}
|
||||
.comparison-divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.artboard-footer {
|
||||
justify-content: flex-start;
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.result-spec {
|
||||
margin-left: auto;
|
||||
}
|
||||
.compare-slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.compare-slider input {
|
||||
width: 120px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.output-picker {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
max-width: 90%;
|
||||
overflow: auto;
|
||||
background: #101015d9;
|
||||
border: 1px solid var(--line);
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.output-picker button {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.output-picker button[aria-pressed='true'] {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.detail-actions,
|
||||
.detail-meta,
|
||||
.inputs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.detail-meta {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
margin: 20px 0 10px;
|
||||
}
|
||||
.mini {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.mini.glyph {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.prompt {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-2);
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 90px minmax(0, 1fr);
|
||||
font-size: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.kv dt {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error-note {
|
||||
color: var(--warn);
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.result-title small {
|
||||
display: none;
|
||||
}
|
||||
.stage-media {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,440 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { workflows, apiErrorMessage } from '$lib/api';
|
||||
import type { WorkflowMeta } from '$lib/comfy/types';
|
||||
import { usageLabel } from '$lib/components/generate/schema';
|
||||
import { spotlight } from '$lib/actions/spotlight';
|
||||
import { toast } from '$lib/toast';
|
||||
let {
|
||||
kind,
|
||||
metas,
|
||||
selectedId,
|
||||
pendingCount,
|
||||
onPick,
|
||||
onKind,
|
||||
onDetails,
|
||||
disabled = false,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
kind: 'image' | 'video';
|
||||
metas: WorkflowMeta[];
|
||||
selectedId: number | null;
|
||||
pendingCount: number;
|
||||
onPick: (id: number) => void;
|
||||
onKind: (kind: 'image' | 'video') => void;
|
||||
onDetails: () => void;
|
||||
} = $props();
|
||||
const client = useQueryClient();
|
||||
let search = $state(''),
|
||||
usage = $state('all'),
|
||||
family = $state('all'),
|
||||
favOnly = $state(false),
|
||||
limit = $state(48),
|
||||
pins = $state<number[]>([]);
|
||||
onMount(() => {
|
||||
try {
|
||||
pins = JSON.parse(
|
||||
localStorage.getItem('calliope.reference.workflowPins') || '[]',
|
||||
);
|
||||
} catch {
|
||||
pins = [];
|
||||
}
|
||||
});
|
||||
const families = $derived([
|
||||
...new Set(metas.map((m) => m.model_family).filter(Boolean)),
|
||||
]);
|
||||
const usages = $derived([
|
||||
...new Set(metas.map((m) => m.usage).filter(Boolean)),
|
||||
]);
|
||||
const filtered = $derived(
|
||||
metas
|
||||
.filter(
|
||||
(m) =>
|
||||
(!favOnly || m.favorite) &&
|
||||
(usage === 'all' || m.usage === usage) &&
|
||||
(family === 'all' || m.model_family === family) &&
|
||||
[m.name, m.description, m.model_family, ...(m.tags ?? [])]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase()),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(pins.includes(b.id)) - Number(pins.includes(a.id)) ||
|
||||
Number(b.favorite) - Number(a.favorite) ||
|
||||
a.name.localeCompare(b.name, 'zh'),
|
||||
),
|
||||
);
|
||||
$effect(() => {
|
||||
void search;
|
||||
void usage;
|
||||
void family;
|
||||
void favOnly;
|
||||
limit = 48;
|
||||
});
|
||||
$effect(() => {
|
||||
const index = filtered.findIndex((m) => m.id === selectedId);
|
||||
if (index >= limit) limit = index + 24;
|
||||
});
|
||||
function pin(id: number) {
|
||||
pins = pins.includes(id) ? pins.filter((p) => p !== id) : [...pins, id];
|
||||
localStorage.setItem(
|
||||
'calliope.reference.workflowPins',
|
||||
JSON.stringify(pins),
|
||||
);
|
||||
}
|
||||
const favorite = createMutation({
|
||||
mutationFn: (m: WorkflowMeta) =>
|
||||
workflows.update(m.id, { favorite: !m.favorite }),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['workflows-meta'] }),
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside class="workflow-rail" aria-label="创作工作流列表">
|
||||
<header>
|
||||
<div><strong>创作台</strong><small>选择工作流,开始创作</small></div>
|
||||
</header>
|
||||
<div class="rail-tools">
|
||||
<div class="kind-tabs" role="tablist" aria-label="创作类型">
|
||||
<button
|
||||
role="tab"
|
||||
{disabled}
|
||||
aria-selected={kind === 'image'}
|
||||
onclick={() => onKind('image')}>图片</button
|
||||
><button
|
||||
role="tab"
|
||||
{disabled}
|
||||
aria-selected={kind === 'video'}
|
||||
onclick={() => onKind('video')}>视频</button
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
aria-label="搜索工作流"
|
||||
placeholder="搜索工作流或用途…"
|
||||
/>
|
||||
<div class="filters">
|
||||
<select aria-label="筛选工作流用途" bind:value={usage}
|
||||
><option value="all">所有用途</option>{#each usages as u}<option
|
||||
value={u!}>{usageLabel(u!)}</option
|
||||
>{/each}</select
|
||||
><select aria-label="筛选模型家族" bind:value={family}
|
||||
><option value="all">全部模型</option>{#each families as f}<option
|
||||
value={f!}>{f}</option
|
||||
>{/each}</select
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
<span>{filtered.length} 个可用工作流</span><button
|
||||
aria-pressed={favOnly}
|
||||
onclick={() => (favOnly = !favOnly)}>{favOnly ? '★' : '☆'} 收藏</button
|
||||
>
|
||||
</div>
|
||||
<div class="rail-list">
|
||||
{#each filtered.slice(0, limit) as m (m.id)}<div
|
||||
class="workflow-row reference-option"
|
||||
use:spotlight
|
||||
class:selected={selectedId === m.id}
|
||||
>
|
||||
<button
|
||||
class="workflow-pick"
|
||||
{disabled}
|
||||
onclick={() => onPick(m.id)}
|
||||
aria-pressed={selectedId === m.id}
|
||||
title={m.name}
|
||||
><i class:video={kind === 'video'}>{kind === 'image' ? '◈' : '▷'}</i
|
||||
><span
|
||||
><b>{m.name}</b><small
|
||||
>{m.description ||
|
||||
(m.usage ? usageLabel(m.usage) : '本地 ComfyUI')}</small
|
||||
></span
|
||||
></button
|
||||
>
|
||||
<div class="row-actions">
|
||||
<button
|
||||
class:pressed={pins.includes(m.id)}
|
||||
aria-pressed={pins.includes(m.id)}
|
||||
aria-label={(pins.includes(m.id) ? '取消置顶' : '置顶') + m.name}
|
||||
onclick={() => pin(m.id)}>⌖</button
|
||||
><button
|
||||
aria-pressed={!!m.favorite}
|
||||
aria-label={(m.favorite ? '取消收藏' : '收藏') + m.name}
|
||||
disabled={$favorite.isPending}
|
||||
onclick={() => $favorite.mutate(m)}>{m.favorite ? '★' : '☆'}</button
|
||||
>
|
||||
</div>
|
||||
</div>{/each}
|
||||
{#if !filtered.length}<p class="empty">
|
||||
没有符合条件的工作流。可以清空搜索或更改筛选。
|
||||
</p>{/if}
|
||||
{#if filtered.length > limit}<button
|
||||
class="more"
|
||||
onclick={() => (limit += 48)}
|
||||
>再显示 {Math.min(filtered.length - limit, 48)} 个</button
|
||||
>{/if}
|
||||
</div>
|
||||
<footer>
|
||||
{#if pendingCount}<a href="/workflows?manage=1"
|
||||
>{pendingCount} 个工作流待配置 →</a
|
||||
>{/if}<button class="manage" onclick={onDetails}
|
||||
><span>+</span>
|
||||
<div>工作流说明与管理<small>输入要求 / 导入 / 分类</small></div>
|
||||
<b>›</b></button
|
||||
><a href="/models">云端模型与接口 ↗</a>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.workflow-rail {
|
||||
width: 232px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: #121216;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.workflow-rail > header {
|
||||
padding: 21px 18px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
header strong {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
}
|
||||
header small {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.rail-tools {
|
||||
padding: 14px 12px 7px;
|
||||
}
|
||||
.kind-tabs {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
background: #09090c;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
padding: 3px;
|
||||
}
|
||||
.kind-tabs button {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
padding: 7px 3px;
|
||||
background: none;
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.kind-tabs [aria-selected='true'] {
|
||||
color: #062f35;
|
||||
background: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.rail-tools input {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
padding: 9px 10px;
|
||||
background: #1d1b16;
|
||||
border: 1px solid #f5b04228;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.filters select {
|
||||
width: 50%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-3);
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.list-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.list-meta button {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.list-meta button[aria-pressed='true'] {
|
||||
color: var(--warn);
|
||||
}
|
||||
.rail-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0 8px 8px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.workflow-row {
|
||||
border: 1px solid transparent;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.workflow-row.selected {
|
||||
border-color: #00cae039;
|
||||
background: linear-gradient(120deg, #00cae016, #00cae003);
|
||||
}
|
||||
.workflow-row.selected::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.workflow-pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 8px;
|
||||
min-height: 55px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.workflow-pick i {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
border: 1px solid #00cae01f;
|
||||
color: var(--accent);
|
||||
background: #00cae008;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
}
|
||||
.workflow-pick > span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.workflow-pick b {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.workflow-pick small {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row-actions button {
|
||||
opacity: 0;
|
||||
padding: 2px;
|
||||
height: 24px;
|
||||
width: 20px;
|
||||
border: 0;
|
||||
color: var(--text-3);
|
||||
background: none;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row-actions button[aria-pressed='true'] {
|
||||
color: var(--warn);
|
||||
opacity: 1;
|
||||
}
|
||||
.workflow-row:hover .row-actions button,
|
||||
.workflow-row:focus-within .row-actions button {
|
||||
opacity: 1;
|
||||
}
|
||||
.workflow-row:hover {
|
||||
background-color: #ffffff05;
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
padding: 12px;
|
||||
}
|
||||
.more {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text-3);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
footer {
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
footer a,
|
||||
.manage {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
.manage {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 7px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.manage small {
|
||||
display: none;
|
||||
}
|
||||
.manage b {
|
||||
margin-left: auto;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.workflow-rail {
|
||||
width: 210px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { WorkflowMediaIO } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 输入要求徽标 (HEL-683):一个工作流真实声明 的进出媒介,
|
||||
* 来自 S2 元数据——图标+文字双编码,只报真实接线,不虚构输入。
|
||||
*/
|
||||
interface Props {
|
||||
io: WorkflowMediaIO | null | undefined;
|
||||
/** Compact form for row summaries (no out side when empty-in). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
let { io, compact = false }: Props = $props();
|
||||
|
||||
type Medium = 'image' | 'video' | 'audio';
|
||||
|
||||
const LABELS: Record<Medium, string> = {
|
||||
image: 'wl.mediaImage',
|
||||
video: 'wl.mediaVideo',
|
||||
audio: 'wl.mediaAudio',
|
||||
};
|
||||
|
||||
const inList = $derived(((io?.in ?? []) as Medium[]).filter((m) => m in LABELS));
|
||||
const outList = $derived(((io?.out ?? []) as Medium[]).filter((m) => m in LABELS));
|
||||
</script>
|
||||
|
||||
{#if inList.length || outList.length}
|
||||
<span class="req" aria-label={t('desk.bayReqAria')}>
|
||||
{#each inList as m (m)}
|
||||
<span class="pill in" title="{t(LABELS[m])} · {t('wl.mediaIn')}">
|
||||
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
|
||||
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
|
||||
</span>
|
||||
{/each}
|
||||
{#if inList.length && outList.length}<span class="arrow">→</span>{/if}
|
||||
{#each outList as m (m)}
|
||||
<span class="pill out" title="{t(LABELS[m])} · {t('wl.mediaOut')}">
|
||||
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
|
||||
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</span>
|
||||
{:else if !compact}
|
||||
<span class="pill none" title={t('desk.promptOnly')}><span class="lbl">{t('desk.promptOnly')}</span></span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.req {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-4);
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-mini);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill.in {
|
||||
color: var(--text);
|
||||
}
|
||||
.pill.none .lbl {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.arrow {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 挑选台筛选行 (HEL-683):搜索框 + 本机/云端来源分段 + 仅收藏开关。
|
||||
* 云端分段是诚实的占位——云端生成能力尚未接入,切过去只看到说明。
|
||||
*/
|
||||
interface Props {
|
||||
search: string;
|
||||
source: 'local' | 'cloud';
|
||||
favOnly: boolean;
|
||||
resultHint: string;
|
||||
searchEl?: HTMLInputElement | null;
|
||||
onSetSearch: (v: string) => void;
|
||||
onSetSource: (s: 'local' | 'cloud') => void;
|
||||
onToggleFav: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
search,
|
||||
source,
|
||||
favOnly,
|
||||
resultHint,
|
||||
searchEl = $bindable(null),
|
||||
onSetSearch,
|
||||
onSetSource,
|
||||
onToggleFav,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="filters">
|
||||
<div class="search">
|
||||
<Icon name="search" size={14} />
|
||||
<input
|
||||
bind:this={searchEl}
|
||||
class="search-input"
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder={t('desk.baySearchPh')}
|
||||
aria-label={t('desk.baySearchPh')}
|
||||
aria-keyshortcuts="/"
|
||||
oninput={(e) => onSetSearch(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="a-seg source" role="group" aria-label={t('desk.baySourceLabel')}>
|
||||
<button type="button" aria-pressed={source === 'local'} onclick={() => onSetSource('local')}>
|
||||
{t('desk.baySourceLocal')}
|
||||
</button>
|
||||
<button type="button" aria-pressed={source === 'cloud'} onclick={() => onSetSource('cloud')}>
|
||||
{t('desk.baySourceCloud')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip fav"
|
||||
class:on={favOnly}
|
||||
aria-pressed={favOnly}
|
||||
title={t('desk.bayFavOnly')}
|
||||
onclick={onToggleFav}
|
||||
>
|
||||
<Icon name="star" size={13} />
|
||||
<span class="lbl">{t('desk.bayFavOnly')}</span>
|
||||
</button>
|
||||
<span class="hint">{resultHint}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-3);
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.search:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.source {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fav.on {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.hint {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { WorkflowMeta } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel } from '$lib/components/generate/schema';
|
||||
import InputRequirementBadge from './InputRequirementBadge.svelte';
|
||||
|
||||
/**
|
||||
* 挑选台一行 (HEL-683):显示名 + 输入要求徽标 + 用途标签 + 端点/收藏
|
||||
* 标注。当前工作流行带取景框角线;键盘/指针悬停行只是背景高亮。
|
||||
*/
|
||||
interface Props {
|
||||
meta: WorkflowMeta;
|
||||
current: boolean;
|
||||
active: boolean;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onPick: () => void;
|
||||
onActive: () => void;
|
||||
}
|
||||
|
||||
let { meta, current, active, endpoints, onPick, onActive }: Props = $props();
|
||||
|
||||
const endpointName = $derived(
|
||||
meta.endpoint_id
|
||||
? (endpoints.find((e) => e.id === meta.endpoint_id)?.name ?? t('desk.wfDefaultConn'))
|
||||
: t('desk.wfDefaultConn'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="row"
|
||||
class:active
|
||||
class:current
|
||||
role="option"
|
||||
aria-selected={active ? 'true' : undefined}
|
||||
aria-current={current ? 'true' : undefined}
|
||||
onclick={onPick}
|
||||
onpointermove={onActive}
|
||||
onfocus={onActive}
|
||||
>
|
||||
<span class="main">
|
||||
<span class="name">
|
||||
{#if meta.favorite}<span class="fav"><Icon name="star" size={11} /></span>{/if}
|
||||
<span class="name-txt">{meta.name}</span>
|
||||
{#if current}<span class="cur-mark">{t('desk.wfCurrent')}</span>{/if}
|
||||
</span>
|
||||
<span class="sub">
|
||||
<span class="a-tag">{usageLabel(meta.usage!)}</span>
|
||||
<InputRequirementBadge io={meta.media_io} compact />
|
||||
</span>
|
||||
</span>
|
||||
<span class="side">
|
||||
<span class="conn" title="{t('studio.connLabel')}:{endpointName}">{endpointName}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
/* 长列表性能:视口外的行不参与布局/绘制。 */
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 52px;
|
||||
transition: background-color var(--d-state) ease;
|
||||
}
|
||||
.row:hover,
|
||||
.row.active {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.row:focus-visible {
|
||||
outline: none;
|
||||
background: var(--bg-3);
|
||||
box-shadow: 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
.main {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.name-txt {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
.fav {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
.cur-mark {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.side {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.conn {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
/* 当前工作流:取景框角线(HEL-680 的内容级选中语言)。 */
|
||||
.row.current {
|
||||
position: relative;
|
||||
}
|
||||
.row.current::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0);
|
||||
background-position:
|
||||
left top,
|
||||
left top,
|
||||
right top,
|
||||
right top,
|
||||
left bottom,
|
||||
left bottom,
|
||||
right bottom,
|
||||
right bottom;
|
||||
background-size:
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job } from '$lib/comfy/types';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { missingOutputsOf, markOutputMissing } from '$lib/missingOutputs.svelte';
|
||||
let {
|
||||
jobs,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onHistory,
|
||||
nameOf,
|
||||
}: {
|
||||
jobs: Job[];
|
||||
selectedId: number | null;
|
||||
onSelect: (job: Job) => void;
|
||||
onHistory: () => void;
|
||||
nameOf: (job: Job) => string;
|
||||
} = $props();
|
||||
const sorted = $derived(
|
||||
[...jobs].sort((a, b) => parseDbTs(b.created_at) - parseDbTs(a.created_at)),
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="result-rail" aria-label="生成结果记录">
|
||||
<header>
|
||||
<strong>生成记录 <small>{jobs.length}</small></strong><button
|
||||
class="a-btn ghost sm"
|
||||
onclick={onHistory}>全部记录与任务 ↗</button
|
||||
>
|
||||
</header>
|
||||
<div class="rail-items">
|
||||
{#each sorted as job (job.id)}
|
||||
{@const path = job.output_paths.find((p) => !missingOutputsOf(job).includes(p))}
|
||||
{@const status = jobStatusInfo(job)}
|
||||
<button
|
||||
class="result-item"
|
||||
class:selected={job.id === selectedId}
|
||||
aria-pressed={job.id === selectedId}
|
||||
aria-label="查看第 {job.id} 次生成,{status.label}"
|
||||
title="{nameOf(job)} · {job.created_at} · {status.label}"
|
||||
onclick={() => onSelect(job)}
|
||||
>
|
||||
<span class="thumb"
|
||||
>{#if path && job.kind === 'image'}<img
|
||||
src={assetUrl(path)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror={() => markOutputMissing(path)}
|
||||
/>{:else}<span>{job.kind === 'video' ? '▶' : '✳'}</span>{/if}</span
|
||||
>
|
||||
<span class="record-label">{nameOf(job)}</span>
|
||||
<span class="record-label"
|
||||
>第 {job.id} 次 · {path
|
||||
? status.label
|
||||
: job.status === 'done'
|
||||
? '文件缺失'
|
||||
: status.label}</span
|
||||
>
|
||||
</button>
|
||||
{:else}<p>生成后,结果会保存在这里。</p>{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.result-rail {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 8px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
gap: 10px;
|
||||
}
|
||||
strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
small {
|
||||
color: var(--text-3);
|
||||
margin-left: 5px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.rail-items {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.result-item {
|
||||
width: 122px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding: 3px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.result-item.selected {
|
||||
border: 2px solid var(--accent);
|
||||
padding: 2px;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.thumb {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 48px;
|
||||
background: var(--bg);
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
font-size: 20px;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.record-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
padding: 5px 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
p {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
margin: 4px 0;
|
||||
}
|
||||
@media (max-height: 650px) {
|
||||
.rail-items {
|
||||
display: none;
|
||||
}
|
||||
.thumb,
|
||||
img {
|
||||
height: 32px;
|
||||
}
|
||||
.result-item {
|
||||
width: 110px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,310 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { USAGE_BY_KIND, type WorkflowMeta, type WorkflowUsage } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel } from '$lib/components/generate/schema';
|
||||
|
||||
/**
|
||||
* 左栏任务模式 (HEL-683):该入口可用的任务模式(按真实工作流统计),
|
||||
* 未覆盖的模式置灰;底部为收藏与最近使用。窄屏退化为横向模式条。
|
||||
*/
|
||||
interface Props {
|
||||
kind: 'image' | 'video';
|
||||
metas: WorkflowMeta[];
|
||||
active: WorkflowUsage | 'all';
|
||||
onSelect: (mode: WorkflowUsage | 'all') => void;
|
||||
onPickWorkflow: (id: number) => void;
|
||||
}
|
||||
|
||||
let { kind, metas, active, onSelect, onPickWorkflow }: Props = $props();
|
||||
|
||||
interface ModeEntry {
|
||||
usage: WorkflowUsage;
|
||||
count: number;
|
||||
}
|
||||
const modes = $derived.by(() => {
|
||||
const counts = new Map<WorkflowUsage, number>();
|
||||
for (const m of metas) {
|
||||
if (!m.usage) continue;
|
||||
counts.set(m.usage, (counts.get(m.usage) ?? 0) + 1);
|
||||
}
|
||||
return USAGE_BY_KIND[kind].map((u) => ({ usage: u, count: counts.get(u) ?? 0 }));
|
||||
});
|
||||
|
||||
const favorites = $derived(
|
||||
metas
|
||||
.filter((m) => m.favorite)
|
||||
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
|
||||
.slice(0, 5),
|
||||
);
|
||||
const recents = $derived(
|
||||
metas
|
||||
.filter((m) => m.last_used_at && !m.favorite)
|
||||
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
|
||||
.slice(0, 5),
|
||||
);
|
||||
</script>
|
||||
|
||||
<nav class="rail" aria-label={t('desk.modeRailAria')}>
|
||||
<p class="rail-label">{t('desk.modeRailTitle')}</p>
|
||||
<div class="modes" role="group" aria-label={t('desk.modeRailTitle')}>
|
||||
<button
|
||||
type="button"
|
||||
class="mode"
|
||||
class:on={active === 'all'}
|
||||
aria-pressed={active === 'all'}
|
||||
onclick={() => onSelect('all')}
|
||||
>
|
||||
<span class="mode-lbl">{t('desk.modeAll')}</span>
|
||||
<span class="mode-n">{metas.length}</span>
|
||||
</button>
|
||||
{#each modes as m (m.usage)}
|
||||
<button
|
||||
type="button"
|
||||
class="mode"
|
||||
class:on={active === m.usage}
|
||||
class:off={m.count === 0}
|
||||
disabled={m.count === 0}
|
||||
aria-pressed={active === m.usage}
|
||||
title={m.count === 0 ? t('desk.modeEmpty') : usageLabel(m.usage)}
|
||||
onclick={() => onSelect(m.usage)}
|
||||
>
|
||||
<span class="mode-lbl">{usageLabel(m.usage)}</span>
|
||||
<span class="mode-n">{m.count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="lists">
|
||||
{#if recents.length}
|
||||
<section class="rail-sec">
|
||||
<p class="rail-label">
|
||||
<Icon name="clock" size={12} />
|
||||
{t('desk.railRecent')}
|
||||
</p>
|
||||
<ul class="pick-list">
|
||||
{#each recents as m (m.id)}
|
||||
<li>
|
||||
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
|
||||
<span class="pick-name">{m.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{#if favorites.length}
|
||||
<section class="rail-sec">
|
||||
<p class="rail-label">
|
||||
<Icon name="star" size={12} />
|
||||
{t('desk.railFav')}
|
||||
</p>
|
||||
<ul class="pick-list">
|
||||
{#each favorites as m (m.id)}
|
||||
<li>
|
||||
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
|
||||
<span class="pick-name">{m.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-5) var(--sp-3);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.rail-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin: 0 0 var(--sp-2);
|
||||
padding: 0 var(--sp-3);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: var(--fs-body);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.mode:hover:not(:disabled) {
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
}
|
||||
.mode:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
.mode.on {
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
}
|
||||
/* 取景框角线只标当前模式(HEL-680 内容级选中语言)。 */
|
||||
.mode.on {
|
||||
position: relative;
|
||||
}
|
||||
.mode.on::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0);
|
||||
background-position:
|
||||
left top,
|
||||
left top,
|
||||
right top,
|
||||
right top,
|
||||
left bottom,
|
||||
left bottom,
|
||||
right bottom,
|
||||
right bottom;
|
||||
background-size:
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px),
|
||||
var(--frame-arm, 12px) var(--frame-w, 2px),
|
||||
var(--frame-w, 2px) var(--frame-arm, 12px);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.mode.off {
|
||||
color: var(--text-3);
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mode-n {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lists {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
margin-top: var(--sp-5);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.rail-sec {
|
||||
min-width: 0;
|
||||
}
|
||||
.pick-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.pick {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.pick:hover {
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
}
|
||||
.pick:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
.pick-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 窄屏:任务模式退化为横向条,收藏/最近隐藏(挑选台里有同样入口)。 */
|
||||
@media (max-width: 760px) {
|
||||
.rail {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
.rail-label {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.modes {
|
||||
flex-direction: row;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.mode {
|
||||
width: auto;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mode.on::after {
|
||||
inset: 1px;
|
||||
}
|
||||
.lists {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,549 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { workflows, settings, apiErrorMessage } from '$lib/api';
|
||||
import type { WorkflowMeta } from '$lib/comfy/types';
|
||||
import { usageLabel } from '$lib/components/generate/schema';
|
||||
import InputRequirementBadge from './InputRequirementBadge.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
interface Props {
|
||||
open: boolean;
|
||||
metas: WorkflowMeta[];
|
||||
pendingCount: number;
|
||||
selectedId: number | null;
|
||||
activeId: number | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onPick: (id: number) => void;
|
||||
onClose: () => void;
|
||||
catalogue?: boolean;
|
||||
localOnly?: boolean;
|
||||
onPreview?: (id: number) => void;
|
||||
}
|
||||
let {
|
||||
open,
|
||||
metas,
|
||||
pendingCount,
|
||||
selectedId,
|
||||
activeId = $bindable(null),
|
||||
endpoints,
|
||||
onPick,
|
||||
onClose,
|
||||
catalogue = false,
|
||||
localOnly = false,
|
||||
onPreview,
|
||||
}: Props = $props();
|
||||
const client = useQueryClient();
|
||||
const modelsQuery = createQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: settings.get,
|
||||
});
|
||||
let search = $state(''),
|
||||
category = $state('all'),
|
||||
family = $state('all'),
|
||||
source = $state('local'),
|
||||
usage = $state('all'),
|
||||
favOnly = $state(false),
|
||||
recentOnly = $state(false),
|
||||
visibleCount = $state(48);
|
||||
let searchEl: HTMLInputElement;
|
||||
let listEl: HTMLDivElement;
|
||||
const labels: Record<string, string> = {
|
||||
basic: '基础工具',
|
||||
audio_tts: '语音',
|
||||
music: '音乐',
|
||||
image: '图片',
|
||||
video: '视频',
|
||||
digital_human: '数字人',
|
||||
motion_transfer: '动作',
|
||||
};
|
||||
const categories = $derived([
|
||||
...new Set(metas.map((m) => m.category).filter((k): k is string => !!k)),
|
||||
]);
|
||||
const families = $derived(
|
||||
[...new Set(metas.map((m) => m.model_family).filter((k): k is string => !!k))].sort(),
|
||||
);
|
||||
const filtered = $derived(
|
||||
metas
|
||||
.filter(
|
||||
(m) =>
|
||||
(category === 'all' || m.category === category) &&
|
||||
(usage === 'all' || m.usage === usage) &&
|
||||
(family === 'all' || m.model_family === family) &&
|
||||
(!favOnly || m.favorite) &&
|
||||
(!recentOnly || m.last_used_at) &&
|
||||
(!search.trim() ||
|
||||
[
|
||||
m.name,
|
||||
m.description,
|
||||
...(m.tags ?? []),
|
||||
m.model_family,
|
||||
m.usage ? usageLabel(m.usage) : '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase().trim())),
|
||||
)
|
||||
.sort((a, b) =>
|
||||
recentOnly
|
||||
? (b.last_used_at ?? '').localeCompare(a.last_used_at ?? '')
|
||||
: Number(b.favorite) - Number(a.favorite) || a.name.localeCompare(b.name, 'zh'),
|
||||
),
|
||||
);
|
||||
const visible = $derived(filtered.slice(0, visibleCount));
|
||||
const cloudModels = $derived(
|
||||
($modelsQuery.data?.llm_profiles ?? []).filter(
|
||||
(p) => !search || [p.name, p.model].join(' ').toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
);
|
||||
$effect(() => {
|
||||
void search;
|
||||
void category;
|
||||
void family;
|
||||
void usage;
|
||||
void favOnly;
|
||||
void recentOnly;
|
||||
visibleCount = 48;
|
||||
listEl?.scrollTo({ top: 0 });
|
||||
});
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const idx = filtered.findIndex((m) => m.id === selectedId);
|
||||
if (idx >= visibleCount) visibleCount = idx + 48;
|
||||
void tick().then(() => searchEl?.focus());
|
||||
});
|
||||
$effect(() => {
|
||||
if (!filtered.some((m) => m.id === activeId)) activeId = filtered[0]?.id ?? null;
|
||||
});
|
||||
export function focusSearch() {
|
||||
searchEl?.focus();
|
||||
}
|
||||
async function favorite(m: WorkflowMeta) {
|
||||
try {
|
||||
await workflows.update(m.id, { favorite: !m.favorite });
|
||||
await client.invalidateQueries({ queryKey: ['workflows-meta'] });
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e));
|
||||
}
|
||||
}
|
||||
function pick(m: WorkflowMeta) {
|
||||
if (onPreview) onPreview(m.id);
|
||||
else if (m.is_enabled && !m.needs_mapping && m.usage) onPick(m.id);
|
||||
}
|
||||
function move(delta: number) {
|
||||
const i = Math.max(
|
||||
0,
|
||||
Math.min(filtered.length - 1, filtered.findIndex((m) => m.id === activeId) + delta),
|
||||
);
|
||||
activeId = filtered[i]?.id ?? null;
|
||||
if (i >= visibleCount) visibleCount = i + 24;
|
||||
void tick().then(() =>
|
||||
listEl?.querySelector(`[data-wf-row="${activeId}"]`)?.scrollIntoView({ block: 'nearest' }),
|
||||
);
|
||||
}
|
||||
function key(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.key === 'Escape' && !catalogue) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (source !== 'local') {
|
||||
return;
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
} else if (e.key === 'Enter' && e.target === searchEl) {
|
||||
e.preventDefault();
|
||||
const m = filtered.find((m) => m.id === activeId);
|
||||
if (m) pick(m);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="catalogue" inert={!open} onkeydown={key}>
|
||||
<aside class="categories">
|
||||
<p>按创作任务寻找</p>
|
||||
<button
|
||||
class:active={category === 'all' && !favOnly && !recentOnly}
|
||||
onclick={() => {
|
||||
category = 'all';
|
||||
favOnly = false;
|
||||
recentOnly = false;
|
||||
}}><span>全部工具</span><small>{metas.length}</small></button
|
||||
>
|
||||
{#each categories as cat}<button
|
||||
class:active={category === cat}
|
||||
onclick={() => {
|
||||
category = cat;
|
||||
favOnly = false;
|
||||
recentOnly = false;
|
||||
}}
|
||||
><span>{labels[cat] ?? cat}</span><small
|
||||
>{metas.filter((m) => m.category === cat).length}</small
|
||||
></button
|
||||
>{/each}
|
||||
<div class="divider"></div>
|
||||
<button
|
||||
class:active={favOnly}
|
||||
onclick={() => {
|
||||
favOnly = !favOnly;
|
||||
recentOnly = false;
|
||||
}}><span>☆ 我的收藏</span><small>{metas.filter((m) => m.favorite).length}</small></button
|
||||
><button
|
||||
class:active={recentOnly}
|
||||
onclick={() => {
|
||||
recentOnly = !recentOnly;
|
||||
favOnly = false;
|
||||
}}><span>◷ 最近使用</span></button
|
||||
>
|
||||
</aside>
|
||||
<section class="catalogue-main">
|
||||
<label class="search"
|
||||
><span>⌕</span><input
|
||||
type="search"
|
||||
bind:this={searchEl}
|
||||
bind:value={search}
|
||||
placeholder="搜索工作流、模型或用途…"
|
||||
aria-label="搜索工作流"
|
||||
/></label
|
||||
>
|
||||
<div class="filters">
|
||||
<button class:active={source === 'local'} onclick={() => (source = 'local')}
|
||||
>本地 ComfyUI</button
|
||||
>{#if !localOnly}<button class:active={source === 'cloud'} onclick={() => (source = 'cloud')}
|
||||
>云端与智能体</button
|
||||
>{/if}{#if source === 'local'}<select aria-label="筛选创作任务" bind:value={usage}
|
||||
><option value="all">所有创作任务</option>{#each [...new Set(metas
|
||||
.map((m) => m.usage)
|
||||
.filter(Boolean))] as u}<option value={u!}>{usageLabel(u!)}</option>{/each}</select
|
||||
><select aria-label="筛选模型家族" bind:value={family}
|
||||
><option value="all">全部模型</option>{#each families as f}<option value={f}>{f}</option
|
||||
>{/each}</select
|
||||
>{/if}<span>{source === 'local' ? filtered.length : cloudModels.length} 个结果</span>
|
||||
</div>
|
||||
<div class="results" bind:this={listEl}>
|
||||
{#if source === 'cloud'}
|
||||
<div class="cloud-context">
|
||||
<h3>让专业搭档加入创作。</h3>
|
||||
<p>使用已配置模型,在智能体或画布中创作。自定义多媒体接口与技能可在设置中管理。</p>
|
||||
<a href="/canvas" class="a-btn primary">打开创作画布 ↗</a>
|
||||
</div>
|
||||
{#each cloudModels as profile}<article class="workflow-card">
|
||||
<div class="family">{profile.model}<span>已配置模型</span></div>
|
||||
<h3>{profile.name}</h3>
|
||||
<a href="/agents" class="tool-link">交给智能体创作 ↗</a>
|
||||
</article>{:else}<p class="empty">
|
||||
还没有匹配的模型。<a href="/models">配置模型 ↗</a>
|
||||
</p>{/each}
|
||||
{:else}
|
||||
{#each visible as m (m.id)}<article
|
||||
class="workflow-card"
|
||||
class:selected={m.id === selectedId}
|
||||
class:roving={m.id === activeId}
|
||||
data-wf-row={m.id}
|
||||
>
|
||||
<button
|
||||
class="workflow-choice"
|
||||
onclick={() => pick(m)}
|
||||
onfocus={() => (activeId = m.id)}
|
||||
disabled={!onPreview && (!m.is_enabled || m.needs_mapping || !m.usage)}
|
||||
aria-label="{onPreview ? '查看' : '使用'} {m.name}"
|
||||
aria-current={m.id === selectedId ? 'true' : undefined}
|
||||
>
|
||||
<div class="family">
|
||||
{m.model_family ?? 'COMFYUI'}<span>本地</span>
|
||||
</div>
|
||||
<h3 title={m.name}>{m.name}</h3>
|
||||
{#if m.description}<p class="description">{m.description}</p>{/if}
|
||||
<div class="card-meta">
|
||||
<span>{m.usage ? usageLabel(m.usage) : '待映射'}</span><InputRequirementBadge
|
||||
io={m.media_io}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="favorite"
|
||||
class:saved={m.favorite}
|
||||
aria-label="{m.favorite ? '取消收藏' : '收藏'} {m.name}"
|
||||
aria-pressed={m.favorite}
|
||||
onclick={() => favorite(m)}>{m.favorite ? '★' : '☆'}</button
|
||||
>
|
||||
{#if m.needs_mapping || !m.is_enabled || !m.usage}<a
|
||||
href="/workflows?manage=1"
|
||||
class="tool-link">{m.needs_mapping ? '完成映射' : '管理工作流'} ↗</a
|
||||
>{/if}
|
||||
</article>{:else}<p class="empty">没有找到匹配的工作流,试试更短的关键词。</p>{/each}
|
||||
{#if visibleCount < filtered.length}<button
|
||||
class="more a-btn secondary"
|
||||
onclick={() => (visibleCount += 48)}
|
||||
>再看 {Math.min(48, filtered.length - visibleCount)} 个工具</button
|
||||
>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if pendingCount > 0}<p class="pending">
|
||||
另有 {pendingCount} 个工作流待映射。<a href="/workflows?manage=1">去整理 ↗</a>
|
||||
</p>{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.description {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 10px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.catalogue {
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
}
|
||||
.catalogue-main {
|
||||
padding-left: 12px;
|
||||
}
|
||||
.results {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.catalogue {
|
||||
display: grid;
|
||||
grid-template-columns: 145px minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.categories {
|
||||
padding: 16px 15px 20px 0;
|
||||
border-right: 1px solid var(--line);
|
||||
overflow: auto;
|
||||
}
|
||||
.categories p {
|
||||
font-size: 9px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-3);
|
||||
margin: 0 9px 15px;
|
||||
}
|
||||
.categories button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 7px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
padding: 11px 10px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.categories button.active {
|
||||
background: #e8eafe;
|
||||
color: var(--accent);
|
||||
}
|
||||
.categories small {
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.divider {
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 20px 8px 12px;
|
||||
}
|
||||
.catalogue-main {
|
||||
padding: 16px 0 0 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
padding: 9px 13px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.search input {
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.search:focus-within {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
margin: 16px 0 21px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters button,
|
||||
.filters select {
|
||||
font-size: 10px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: var(--surface);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.filters button.active {
|
||||
color: var(--surface);
|
||||
background: var(--text);
|
||||
border-color: var(--text);
|
||||
}
|
||||
.filters span {
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
margin-left: auto;
|
||||
}
|
||||
.filters select {
|
||||
max-width: 140px;
|
||||
}
|
||||
.results {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 11px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
padding: 2px 5px 15px 2px;
|
||||
}
|
||||
.workflow-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
padding: 14px;
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
min-width: 0;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 132px;
|
||||
}
|
||||
.workflow-card.selected {
|
||||
border-color: var(--accent);
|
||||
background: #f1f3ff;
|
||||
}
|
||||
.workflow-choice {
|
||||
display: block;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
padding: 0 0 9px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.workflow-choice:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: default;
|
||||
}
|
||||
.family {
|
||||
font: 9px var(--mono);
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.family span {
|
||||
font: 8px var(--font);
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
}
|
||||
h3 {
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
margin: 0 0 9px;
|
||||
font-weight: 550;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
padding-right: 14px;
|
||||
}
|
||||
.favorite {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
font-size: 18px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #838a78;
|
||||
cursor: pointer;
|
||||
}
|
||||
.favorite.saved {
|
||||
color: var(--accent);
|
||||
}
|
||||
.tool-link {
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.cloud-context {
|
||||
grid-column: 1/-1;
|
||||
background: #e9e7df;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.cloud-context h3 {
|
||||
font-size: 19px;
|
||||
}
|
||||
.cloud-context p {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.cloud-context .a-btn {
|
||||
font-size: 11px;
|
||||
}
|
||||
.pending {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 0;
|
||||
}
|
||||
.empty {
|
||||
grid-column: 1/-1;
|
||||
padding: 45px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.more {
|
||||
grid-column: 1/-1;
|
||||
justify-self: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.workflow-card:hover {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.categories button:hover {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { Workflow, WorkflowMeta } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel, slotSummary } from '$lib/components/generate/schema';
|
||||
import InputRequirementBadge from './InputRequirementBadge.svelte';
|
||||
|
||||
/**
|
||||
* 输入要求预览 (HEL-683):挑选台打开时右侧的详情面板——展示键盘/指针
|
||||
* 停留的工作流的名称、家族、说明与真实输入要求(S2 元数据 + 已加载的
|
||||
* 完整记录槽位摘要),确认后进入创作区。
|
||||
*/
|
||||
interface Props {
|
||||
meta: WorkflowMeta | null;
|
||||
full: Workflow | null;
|
||||
currentId: number | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onUse: () => void;
|
||||
onToggleFav: (fav: boolean) => void;
|
||||
}
|
||||
|
||||
let { meta, full, currentId, endpoints, onUse, onToggleFav }: Props = $props();
|
||||
|
||||
const isCurrent = $derived(meta != null && meta.id === currentId);
|
||||
const broken = $derived((full?.warnings ?? []).some((w) => w.level === 'error'));
|
||||
const endpointName = $derived.by(() => {
|
||||
const id = meta?.endpoint_id;
|
||||
if (!id) return t('desk.wfDefaultConn');
|
||||
return endpoints.find((e) => e.id === id)?.name ?? t('desk.wfDefaultConn');
|
||||
});
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
basic: 'wfCat.basic',
|
||||
audio_tts: 'wfCat.audioTts',
|
||||
music: 'wfCat.music',
|
||||
image: 'wfCat.image',
|
||||
video: 'wfCat.video',
|
||||
digital_human: 'wfCat.digitalHuman',
|
||||
motion_transfer: 'wfCat.motionTransfer',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="preview">
|
||||
{#if meta}
|
||||
<div class="head">
|
||||
<h3 class="name" title={meta.name}>{meta.name}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="fav"
|
||||
class:on={meta.favorite}
|
||||
aria-pressed={meta.favorite}
|
||||
aria-label={t('desk.bayFavOnly')}
|
||||
title={meta.favorite ? t('desk.pvUnfav') : t('desk.pvFav')}
|
||||
onclick={() => onToggleFav(!meta.favorite)}
|
||||
>
|
||||
<Icon name="star" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="tags">
|
||||
{#if meta.usage}<span class="a-tag accent">{usageLabel(meta.usage)}</span>{/if}
|
||||
{#if meta.model_family}<span class="a-tag">{meta.model_family}</span>{/if}
|
||||
{#if meta.category && CATEGORY_LABELS[meta.category]}
|
||||
<span class="a-tag">{t(CATEGORY_LABELS[meta.category])}</span>
|
||||
{/if}
|
||||
{#if isCurrent}<span class="a-tag ok">{t('desk.pvCurrent')}</span>{/if}
|
||||
{#if broken}<span class="a-tag err">{t('desk.wfBroken')}</span>{/if}
|
||||
</div>
|
||||
|
||||
{#if meta.description}
|
||||
<p class="desc">{meta.description}</p>
|
||||
{/if}
|
||||
|
||||
<section class="sec">
|
||||
<h4 class="sec-title">{t('desk.bayReqTitle')}</h4>
|
||||
<div class="req-line">
|
||||
<InputRequirementBadge io={meta.media_io} />
|
||||
</div>
|
||||
{#if full}
|
||||
<p class="slot-summary">{slotSummary(full)}</p>
|
||||
{:else}
|
||||
<p class="slot-summary dim">{t('desk.pvSlotsLoading')}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<dl class="kv">
|
||||
<div class="kv-row">
|
||||
<dt>{t('studio.connLabel')}</dt>
|
||||
<dd>{endpointName}</dd>
|
||||
</div>
|
||||
{#if meta.last_used_at}
|
||||
<div class="kv-row">
|
||||
<dt>{t('desk.pvLastUsed')}</dt>
|
||||
<dd>{new Date(meta.last_used_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if (full?.input_schema ?? []).length}
|
||||
<div class="kv-row">
|
||||
<dt>{t('desk.pvParamCount')}</dt>
|
||||
<dd>{(full?.input_schema ?? []).length}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
<div class="use">
|
||||
<button type="button" class="a-btn primary" disabled={broken} onclick={onUse}>
|
||||
<Icon name="check" size={13} />
|
||||
{isCurrent ? t('desk.pvBackCreate') : t('desk.pvUse')}
|
||||
</button>
|
||||
{#if broken}<p class="use-hint">{t('studio.errWfBroken')}</p>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="preview-empty">
|
||||
<Icon name="search" size={20} />
|
||||
<p>{t('desk.pvEmpty')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
padding: var(--sp-7);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fav {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: none;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.fav.on {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.fav:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.desc {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-aux);
|
||||
line-height: 1.65;
|
||||
white-space: pre-line;
|
||||
}
|
||||
.sec {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.sec-title {
|
||||
margin: 0;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.req-line {
|
||||
display: flex;
|
||||
}
|
||||
.slot-summary {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.slot-summary.dim {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
gap: var(--sp-1);
|
||||
margin: 0;
|
||||
}
|
||||
.kv-row {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.kv-row dt {
|
||||
flex-shrink: 0;
|
||||
width: 72px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.kv-row dd {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.use {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-4);
|
||||
}
|
||||
.use-hint {
|
||||
margin: 0;
|
||||
color: var(--err);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.preview-empty {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: var(--sp-3);
|
||||
height: 100%;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
</style>
|
||||
@@ -1,23 +1,27 @@
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import ParameterPopover from './ParameterPopover.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import ParamChip from './ParamChip.svelte';
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import SlotTray from './SlotTray.svelte';
|
||||
import WorkflowPicker from './WorkflowPicker.svelte';
|
||||
import { chipValue, type BlockReason, type DeskSchema, type FormValue, type FormValues } from './schema';
|
||||
import {
|
||||
type BlockReason,
|
||||
type DeskSchema,
|
||||
type FormValue,
|
||||
type FormValues,
|
||||
} from './schema';
|
||||
|
||||
/**
|
||||
* 输入台 (spec-v2 §3.3): slots from the workflow JSON, the prompt, and one
|
||||
* row of chips — workflow, connection, common params, 参数 — ending in
|
||||
* 生成 with its reason line. The whole composer takes dropped files.
|
||||
* ≤760px it collapses to one line and rises as a bottom sheet on focus.
|
||||
*/
|
||||
/** Scrollable creative inputs with a separate, always visible submission footer. */
|
||||
interface Props {
|
||||
header?: Snippet;
|
||||
dock?: boolean;
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
workflows: Workflow[];
|
||||
current: Workflow | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
endpointId: string;
|
||||
@@ -32,18 +36,20 @@
|
||||
slotBusy?: boolean;
|
||||
promptEl?: HTMLTextAreaElement | null;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onPickWorkflow: (wf: Workflow) => void;
|
||||
onOpenBay: () => void;
|
||||
onPickEndpoint: (id: string) => void;
|
||||
onSubmit: () => void;
|
||||
onReveal: () => void;
|
||||
onOpenParams: () => void;
|
||||
onRestoreAll: () => void;
|
||||
onFromResults: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
header,
|
||||
dock = false,
|
||||
ds,
|
||||
values,
|
||||
workflows,
|
||||
current,
|
||||
endpoints,
|
||||
endpointId,
|
||||
@@ -57,354 +63,660 @@
|
||||
slotBusy = $bindable(false),
|
||||
promptEl = $bindable(null),
|
||||
onSet,
|
||||
onPickWorkflow,
|
||||
onOpenBay,
|
||||
onPickEndpoint,
|
||||
onSubmit,
|
||||
onReveal,
|
||||
onOpenParams,
|
||||
onRestoreAll,
|
||||
onFromResults,
|
||||
}: Props = $props();
|
||||
|
||||
let mediaOpen = $state(false);
|
||||
let popoverInputs = $state<ComfyDynamicInput[]>([]);
|
||||
let popoverAnchor = $state<HTMLElement | null>(null);
|
||||
export function openMedia() {
|
||||
mediaOpen = true;
|
||||
}
|
||||
function openParameter(inp: ComfyDynamicInput, anchor: HTMLElement) {
|
||||
popoverInputs =
|
||||
inp === ds.width || inp === ds.height
|
||||
? [ds.width, ds.height].filter((i): i is ComfyDynamicInput => !!i)
|
||||
: inp === ds.ratio
|
||||
? [ds.ratio, ds.megapixels].filter((i): i is ComfyDynamicInput => !!i)
|
||||
: [inp];
|
||||
popoverAnchor = anchor;
|
||||
}
|
||||
let tray = $state<ReturnType<typeof SlotTray> | null>(null);
|
||||
let root = $state<HTMLElement | null>(null);
|
||||
let dragDepth = $state(0);
|
||||
let expanded = $state(false);
|
||||
let connOpen = $state(false);
|
||||
|
||||
const hasSlots = $derived(ds.slots.length + ds.lists.length > 0);
|
||||
const promptVal = $derived(ds.prompt ? String(values[ds.prompt.nodeId] ?? '') : '');
|
||||
const promptErr = $derived(showErrors && ds.prompt ? errors[ds.prompt.nodeId] : undefined);
|
||||
const endpointName = $derived(endpoints.find((e) => e.id === endpointId)?.name ?? t('desk.noConn'));
|
||||
const commonInputs = $derived(
|
||||
ds.megapixels && !ds.common.some((i) => i.nodeId === ds.megapixels?.nodeId)
|
||||
? [...ds.common, ds.megapixels]
|
||||
: ds.common,
|
||||
);
|
||||
const advancedInputs = $derived(
|
||||
ds.advanced.filter((i) => !commonInputs.some((c) => c.nodeId === i.nodeId)),
|
||||
);
|
||||
const promptVal = $derived(
|
||||
ds.prompt ? String(values[ds.prompt.nodeId] ?? '') : '',
|
||||
);
|
||||
const promptErr = $derived(
|
||||
showErrors && ds.prompt ? errors[ds.prompt.nodeId] : undefined,
|
||||
);
|
||||
const endpointName = $derived(
|
||||
endpoints.find((e) => e.id === endpointId)?.name ?? t('desk.noConn'),
|
||||
);
|
||||
|
||||
/** Composer chips: ratio, the width × height pair, resolution, duration, seed. */
|
||||
const chips = $derived.by(() => {
|
||||
const out: Array<{ key: string; inputs: ComfyDynamicInput[]; text: string }> = [];
|
||||
if (ds.ratio) out.push({ key: ds.ratio.nodeId, inputs: [ds.ratio], text: chipValue(ds.ratio, values, ds) });
|
||||
if (ds.width && ds.height) {
|
||||
const w = values[ds.width.nodeId] ?? ds.width.defaultValue ?? '?';
|
||||
const h = values[ds.height.nodeId] ?? ds.height.defaultValue ?? '?';
|
||||
out.push({ key: ds.width.nodeId, inputs: [ds.width, ds.height], text: `${w}×${h}` });
|
||||
} else {
|
||||
for (const inp of [ds.width, ds.height]) {
|
||||
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: `${inp.label} ${chipValue(inp, values, ds)}` });
|
||||
}
|
||||
function patch(
|
||||
inputs: ComfyDynamicInput[],
|
||||
next: Record<string, string | number>,
|
||||
) {
|
||||
for (const inp of inputs) {
|
||||
const v = next[inp.nodeId];
|
||||
if (v !== undefined && v !== values[inp.nodeId]) onSet(inp.nodeId, v);
|
||||
}
|
||||
for (const inp of [ds.resolution, ds.duration, ds.seed]) {
|
||||
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: chipValue(inp, values, ds) });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
async function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragDepth = 0;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) tray?.dropFiles(files);
|
||||
if (files.length) {
|
||||
if (dock) {
|
||||
mediaOpen = true;
|
||||
await tick();
|
||||
}
|
||||
tray?.dropFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
function hasFiles(e: DragEvent): boolean {
|
||||
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
||||
}
|
||||
|
||||
function onFocusOut(e: FocusEvent) {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!next || !root?.contains(next)) expanded = false;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && expanded && !e.defaultPrevented) {
|
||||
expanded = false;
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="composer-wrap">
|
||||
<div class="a-sprocket" aria-hidden="true"></div>
|
||||
<div class="composer-wrap" class:dock>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={root}
|
||||
class="composer"
|
||||
class:drag={dragDepth > 0}
|
||||
class:expanded
|
||||
onfocusin={() => (expanded = true)}
|
||||
onfocusout={onFocusOut}
|
||||
onkeydown={onKeydown}
|
||||
ondragenter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
if (hasFiles(e)) {
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
}
|
||||
}}
|
||||
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
|
||||
ondragover={(e) => hasFiles(e) && e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if hasSlots}
|
||||
<div class="slots">
|
||||
<SlotTray bind:this={tray} bind:busy={slotBusy} {ds} {values} {showErrors} {errors} {onSet} {onFromResults} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ds.prompt}
|
||||
<textarea
|
||||
bind:this={promptEl}
|
||||
id="desk-prompt"
|
||||
class="prompt"
|
||||
class:invalid={!!promptErr}
|
||||
rows="2"
|
||||
value={promptVal}
|
||||
placeholder={t('desk.promptPh')}
|
||||
aria-label={t('studio.promptLabel')}
|
||||
aria-invalid={!!promptErr || undefined}
|
||||
aria-keyshortcuts="Control+Enter Meta+Enter /"
|
||||
oninput={(e) => onSet(ds.prompt!.nodeId, e.currentTarget.value)}
|
||||
></textarea>
|
||||
{#if promptErr}<p class="a-slot-hint err prompt-err" role="alert">{promptErr}</p>{/if}
|
||||
{:else if current}
|
||||
<p class="no-prompt">{t('desk.noPromptInput')}</p>
|
||||
{/if}
|
||||
|
||||
<div class="foot">
|
||||
<WorkflowPicker {workflows} {current} {endpoints} onPick={onPickWorkflow} />
|
||||
<div class="conn opt">
|
||||
<button
|
||||
type="button"
|
||||
id="desk-conn"
|
||||
class="a-chip"
|
||||
class:err={conn.state === 'fail'}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={connOpen}
|
||||
title={t('studio.connLabel')}
|
||||
onclick={() => (connOpen = !connOpen)}
|
||||
>
|
||||
<span class="status-chip-dot {conn.state === 'ok' ? 'run' : conn.state === 'fail' ? 'fail' : 'idle'}"></span>
|
||||
<span class="lbl">{endpointName}</span>
|
||||
{#if conn.state === 'ok' && conn.ms != null}<span class="mono">· {Math.round(conn.ms)} ms</span>{/if}
|
||||
{#if conn.state === 'dry'}<span>· {t('desk.dryRun')}</span>{/if}
|
||||
</button>
|
||||
<ShellPop bind:open={connOpen} label={t('studio.connLabel')} placement="top" width="280px">
|
||||
<div class="a-menu-group">{t('studio.connLabel')}</div>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item"
|
||||
aria-current={ep.id === endpointId ? 'true' : undefined}
|
||||
onclick={() => {
|
||||
onPickEndpoint(ep.id);
|
||||
connOpen = false;
|
||||
}}
|
||||
>
|
||||
<span class="a-menu-label">{ep.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<a class="a-menu-item" href="/connections">
|
||||
<Icon name="link" size={15} />
|
||||
<span class="a-menu-label">{t('studio.connManage')}</span>
|
||||
</a>
|
||||
</ShellPop>
|
||||
</div>
|
||||
{#each chips as c (c.key)}
|
||||
<div class="opt">
|
||||
<ParamChip
|
||||
inputs={c.inputs}
|
||||
{values}
|
||||
text={c.text}
|
||||
invalid={showErrors && c.inputs.some((i) => !!errors[i.nodeId]?.trim())}
|
||||
{onSet}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if ds.common.length + ds.advanced.length > 0}
|
||||
<button type="button" class="a-chip opt" class:changed={changedCount > 0} onclick={onOpenParams}>
|
||||
<Icon name="settings" size={14} />
|
||||
<span class="lbl">
|
||||
{changedCount > 0 ? t('desk.paramsChanged', { n: changedCount }) : t('desk.paramsAll')}
|
||||
</span>
|
||||
</button>
|
||||
<div class="composer-scroll" aria-label="创作输入">
|
||||
{@render header?.()}
|
||||
{#if current}
|
||||
<section class="input-section prompt-section">
|
||||
<h3>2 · 准备这次创作</h3>
|
||||
{#if hasSlots && !dock}<div class="slots">
|
||||
<SlotTray
|
||||
bind:this={tray}
|
||||
bind:busy={slotBusy}
|
||||
{ds}
|
||||
{values}
|
||||
{showErrors}
|
||||
{errors}
|
||||
{onSet}
|
||||
{onFromResults}
|
||||
/>
|
||||
</div>{/if}
|
||||
{#if dock}<div class="media-summary">
|
||||
{#if hasSlots}
|
||||
{#each ds.slots.slice(0, 4) as slot}<button
|
||||
class="media-token"
|
||||
onclick={openMedia}
|
||||
aria-label={'设置' + slot.label}
|
||||
>
|
||||
{#if values[slot.inp.nodeId] && slot.mediaKind === 'image'}<img
|
||||
src={assetUrl(String(values[slot.inp.nodeId]))}
|
||||
alt=""
|
||||
/>{:else}<Icon name="plus" size={12} />{/if}
|
||||
{slot.label}{slot.inp.required ? ' *' : ''}
|
||||
</button>{/each}
|
||||
{#if ds.lists.length}<button
|
||||
class="media-token"
|
||||
onclick={openMedia}>+ 参考材料</button
|
||||
>{/if}
|
||||
<button class="media-token" onclick={openMedia}
|
||||
>{slotBusy ? '正在上传…' : '管理素材'}</button
|
||||
>
|
||||
{:else}<small>描述你的画面</small>{/if}
|
||||
<small class="input-shortcut">Ctrl + Enter 生成</small>
|
||||
</div>{/if}
|
||||
<div class="prompt-pair" class:dual={dock && !!ds.negative}>
|
||||
{#if ds.prompt}<div class="prompt-field">
|
||||
<label for="desk-prompt"
|
||||
>{ds.negative ? '正向提示词' : '提示词'}
|
||||
{#if ds.prompt.required}<span>必填</span>{/if}</label
|
||||
><textarea
|
||||
bind:this={promptEl}
|
||||
id="desk-prompt"
|
||||
class="prompt"
|
||||
class:invalid={!!promptErr}
|
||||
rows="4"
|
||||
value={promptVal}
|
||||
placeholder="描述你想要的画面、动作和镜头…"
|
||||
aria-label={t('studio.promptLabel')}
|
||||
aria-invalid={!!promptErr || undefined}
|
||||
oninput={(e) =>
|
||||
onSet(ds.prompt!.nodeId, e.currentTarget.value)}
|
||||
></textarea>
|
||||
<div class="prompt-counter">
|
||||
<span>Enter 换行 · Ctrl + Enter 生成</span><span
|
||||
>{promptVal.length} 字</span
|
||||
>
|
||||
</div>
|
||||
{#if promptErr}<p class="error" role="alert">
|
||||
{promptErr}
|
||||
</p>{/if}
|
||||
</div>{:else}<p class="note">
|
||||
这个工具不需要填写提示词,请准备它需要的素材。
|
||||
</p>{/if}
|
||||
{#if dock && ds.negative}<div class="prompt-field negative">
|
||||
<label for="desk-negative"
|
||||
>反向提示词 {#if ds.negative.required}<span>必填</span
|
||||
>{/if}</label
|
||||
><textarea
|
||||
id="desk-negative"
|
||||
class="prompt"
|
||||
rows="4"
|
||||
value={String(values[ds.negative.nodeId] ?? '')}
|
||||
placeholder="填写希望避免的内容…"
|
||||
aria-label="反向提示词"
|
||||
aria-invalid={(showErrors && !!errors[ds.negative.nodeId]) ||
|
||||
undefined}
|
||||
oninput={(e) =>
|
||||
onSet(ds.negative!.nodeId, e.currentTarget.value)}
|
||||
></textarea>
|
||||
<div class="prompt-counter">
|
||||
<span>避免的内容</span><span
|
||||
>{String(values[ds.negative.nodeId] ?? '').length} 字</span
|
||||
>
|
||||
</div>
|
||||
{#if showErrors && errors[ds.negative.nodeId]}<p
|
||||
class="error"
|
||||
role="alert"
|
||||
>
|
||||
{errors[ds.negative.nodeId]}
|
||||
</p>{/if}
|
||||
</div>{/if}
|
||||
</div>
|
||||
</section>
|
||||
{#if !dock && commonInputs.length}<section class="input-section">
|
||||
<h3>画面与生成设置</h3>
|
||||
<ComfyDynamicForm
|
||||
inputs={commonInputs}
|
||||
values={values as Record<string, string | number>}
|
||||
quiet
|
||||
{errors}
|
||||
{showErrors}
|
||||
onChange={(v) => patch(commonInputs, v)}
|
||||
/>
|
||||
</section>{/if}
|
||||
{#if !dock && changedCount > 0}<button
|
||||
class="a-btn ghost sm"
|
||||
onclick={onRestoreAll}>恢复工具默认参数</button
|
||||
>{/if}
|
||||
{#if !dock && advancedInputs.length}<details class="advanced">
|
||||
<summary>更多参数 · {advancedInputs.length} 项</summary>
|
||||
<ComfyDynamicForm
|
||||
inputs={advancedInputs}
|
||||
values={values as Record<string, string | number>}
|
||||
quiet
|
||||
{errors}
|
||||
{showErrors}
|
||||
onChange={(v) => patch(advancedInputs, v)}
|
||||
/>
|
||||
</details>{/if}
|
||||
{/if}
|
||||
|
||||
<div class="submit">
|
||||
{#if block}
|
||||
<button type="button" class="why" class:err={block.tone === 'err'} onclick={onReveal}>
|
||||
{block.text}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn primary gen"
|
||||
class:sent
|
||||
disabled={!!block || submitting || slotBusy}
|
||||
aria-keyshortcuts="Control+Enter Meta+Enter"
|
||||
title={t('desk.genHint')}
|
||||
onclick={onSubmit}
|
||||
{#if !dock}<details
|
||||
class="runtime"
|
||||
open={conn.state === 'fail' || !endpointId}
|
||||
>
|
||||
<span class="l-idle"><Icon name="sparkle" size={13} />{t('desk.generate')}</span>
|
||||
<span class="l-sent" aria-hidden={!sent}><Icon name="check" size={13} />{t('desk.sent')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<summary id="desk-conn">运行设置 · {endpointName}</summary>
|
||||
<label for="comfy-endpoint">使用哪台 ComfyUI</label>
|
||||
<select
|
||||
id="comfy-endpoint"
|
||||
class="a-select"
|
||||
value={endpointId}
|
||||
onchange={(e) => onPickEndpoint(e.currentTarget.value)}
|
||||
>
|
||||
{#if !endpoints.length}<option value="">尚未配置服务</option>{/if}
|
||||
{#each endpoints as ep}<option value={ep.id}>{ep.name}</option
|
||||
>{/each}
|
||||
</select><a href="/connections" target="_blank" rel="noreferrer"
|
||||
>配置或检查连接 ↗</a
|
||||
>
|
||||
</details>{/if}
|
||||
</div>
|
||||
<footer class="submit">
|
||||
{#if dock}<div class="dock-chips">
|
||||
<button id="desk-conn" class="parameter" onclick={onOpenParams}
|
||||
>✦ {endpointName}⌄</button
|
||||
>{#each commonInputs
|
||||
.filter((i) => i !== ds.height)
|
||||
.slice(0, 3) as inp}<button
|
||||
class="parameter"
|
||||
onclick={(e) => openParameter(inp, e.currentTarget)}
|
||||
>{inp.label}: {String(
|
||||
inp === ds.width && ds.height
|
||||
? String(values[inp.nodeId]) +
|
||||
' × ' +
|
||||
String(values[ds.height.nodeId])
|
||||
: (values[inp.nodeId] ?? inp.defaultValue ?? '—'),
|
||||
)}⌄</button
|
||||
>{/each}<button class="parameter" onclick={onOpenParams}
|
||||
>创作设置⌄</button
|
||||
>
|
||||
</div>{/if}
|
||||
{#if block}<button
|
||||
type="button"
|
||||
class="why"
|
||||
class:err={block.tone === 'err'}
|
||||
onclick={onReveal}>{block.text}</button
|
||||
>
|
||||
{:else}<p class="ready">素材已准备好,可以开始生成。</p>{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn primary gen"
|
||||
disabled={!!block || submitting || slotBusy}
|
||||
onclick={onSubmit}
|
||||
>
|
||||
<Icon name="sparkle" size={16} />{submitting
|
||||
? '正在提交…'
|
||||
: slotBusy
|
||||
? '正在上传素材…'
|
||||
: sent
|
||||
? '已加入生成队列'
|
||||
: '开始生成'}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if dock}
|
||||
<Modal
|
||||
bind:open={mediaOpen}
|
||||
size="lg"
|
||||
title="本次创作素材"
|
||||
dismissible={!slotBusy}
|
||||
>
|
||||
<div class="media-editor">
|
||||
<SlotTray
|
||||
bind:this={tray}
|
||||
bind:busy={slotBusy}
|
||||
{ds}
|
||||
{values}
|
||||
{showErrors}
|
||||
{errors}
|
||||
{onSet}
|
||||
{onFromResults}
|
||||
/>
|
||||
</div>
|
||||
{#snippet footer()}<button
|
||||
class="a-btn secondary"
|
||||
disabled={slotBusy}
|
||||
onclick={() => (mediaOpen = false)}
|
||||
>{slotBusy ? '正在上传…' : '完成'}</button
|
||||
>{/snippet}
|
||||
</Modal>
|
||||
{/if}
|
||||
<ParameterPopover
|
||||
inputs={popoverInputs}
|
||||
{values}
|
||||
anchor={popoverAnchor}
|
||||
onClose={() => {
|
||||
popoverInputs = [];
|
||||
popoverAnchor = null;
|
||||
}}
|
||||
onApply={(v) => {
|
||||
for (const [id, value] of Object.entries(v)) onSet(id, value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.composer-wrap {
|
||||
padding: var(--sp-2) var(--sp-7) var(--sp-6);
|
||||
}
|
||||
.a-sprocket {
|
||||
margin: 0 var(--sp-2) var(--sp-3);
|
||||
}
|
||||
.composer-wrap,
|
||||
.composer {
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--composer-bg);
|
||||
transition:
|
||||
border-color var(--d-state) ease,
|
||||
box-shadow var(--d-state) ease;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.composer:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: var(--composer-focus);
|
||||
.composer-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.composer.drag {
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
.slots {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
.input-section {
|
||||
padding: 18px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
h3 {
|
||||
font-size: 13px;
|
||||
margin: 0 0 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
label span {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
margin-left: 6px;
|
||||
}
|
||||
.prompt {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
max-height: 180px;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-2);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
min-height: 100px;
|
||||
max-height: 260px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
padding: 12px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
field-sizing: content;
|
||||
color: var(--text);
|
||||
}
|
||||
.prompt:focus-visible {
|
||||
outline: 0;
|
||||
.prompt:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.prompt::placeholder {
|
||||
color: var(--text-3);
|
||||
.slots {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.prompt-err,
|
||||
.no-prompt {
|
||||
margin: 0;
|
||||
padding: 0 var(--sp-6);
|
||||
.slots :global(.tray) {
|
||||
padding: 0;
|
||||
gap: 14px;
|
||||
}
|
||||
.no-prompt {
|
||||
padding-top: var(--sp-5);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
.slots :global(.note) {
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3) var(--sp-3);
|
||||
.advanced,
|
||||
.runtime {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 14px 0;
|
||||
}
|
||||
.conn {
|
||||
position: relative;
|
||||
summary {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.runtime .a-select {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.runtime a {
|
||||
font-size: 12px;
|
||||
}
|
||||
.submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
padding: 12px 18px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
box-shadow: 0 -8px 20px #292a2d05;
|
||||
}
|
||||
.gen {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.why {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--warn);
|
||||
white-space: nowrap;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.why.err {
|
||||
.why.err,
|
||||
.error {
|
||||
color: var(--err);
|
||||
}
|
||||
/* 生成:按下即缩,提交后文字换成「已提交」约 --d-flash,期间仍可再次提交 */
|
||||
.gen {
|
||||
display: inline-grid;
|
||||
min-width: 84px;
|
||||
border-radius: var(--r-pill);
|
||||
.ready,
|
||||
.note,
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.gen > span {
|
||||
grid-area: 1 / 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-1);
|
||||
transition:
|
||||
opacity var(--d-state) ease,
|
||||
transform var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.gen .l-sent {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.gen.sent .l-idle {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
.gen.sent .l-sent {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
:global(:root[data-reduce='1']) .gen > span {
|
||||
transform: none;
|
||||
.error {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
@media (max-width: 1179px) {
|
||||
.composer-wrap {
|
||||
padding: var(--sp-2) var(--sp-6) var(--sp-5);
|
||||
}
|
||||
.dock {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.composer-wrap {
|
||||
padding: var(--sp-1) var(--sp-4) var(--sp-4);
|
||||
}
|
||||
.composer:not(.expanded) .slots,
|
||||
.composer:not(.expanded) .opt {
|
||||
display: none;
|
||||
}
|
||||
.composer:not(.expanded) .prompt {
|
||||
min-height: 40px;
|
||||
max-height: 40px;
|
||||
padding: var(--sp-4) var(--sp-5) var(--sp-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.composer.expanded {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
|
||||
z-index: 45;
|
||||
border-bottom: 0;
|
||||
border-radius: var(--r-lg) var(--r-lg) 0 0;
|
||||
box-shadow: var(--shadow-float);
|
||||
max-height: calc(100dvh - var(--topbar-h) - var(--tabbar-h));
|
||||
overflow-y: auto;
|
||||
}
|
||||
.composer.expanded .slots {
|
||||
max-height: none;
|
||||
}
|
||||
.submit {
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.dock .composer {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid #f5b04246;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(125deg, #1b1a1d, #17161a);
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 0 0 1px #f5b04206;
|
||||
}
|
||||
.dock .composer:focus-within {
|
||||
border-color: #f5b04280;
|
||||
}
|
||||
.dock .composer-scroll {
|
||||
padding: 9px 14px 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dock .input-section {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dock .input-section > h3 {
|
||||
display: none;
|
||||
}
|
||||
.media-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
margin-bottom: 6px;
|
||||
flex-shrink: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.media-summary > small {
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.media-token {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: #ffffff04;
|
||||
border: 1px dashed #ffffff25;
|
||||
border-radius: 6px;
|
||||
padding: 3px 7px;
|
||||
font-size: 10px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
height: 26px;
|
||||
}
|
||||
.media-token img {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.input-shortcut {
|
||||
margin-left: auto;
|
||||
}
|
||||
.dock .prompt-pair {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
gap: 18px;
|
||||
}
|
||||
.dock .prompt-pair.dual {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 0.65fr);
|
||||
}
|
||||
.dock .prompt-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.dock .negative {
|
||||
border-left: 1px solid var(--line);
|
||||
padding-left: 18px;
|
||||
}
|
||||
.dock .prompt-field label {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
margin: 0 0 4px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.dock .prompt-field:first-child label {
|
||||
color: #d0b37d;
|
||||
}
|
||||
.dock .prompt {
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
.dock .prompt:focus {
|
||||
outline: 0;
|
||||
}
|
||||
.prompt-counter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 9px;
|
||||
color: var(--text-3);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.dock .prompt-counter {
|
||||
display: none;
|
||||
}
|
||||
.dock .submit {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 14px;
|
||||
height: 45px;
|
||||
box-sizing: border-box;
|
||||
background: none;
|
||||
border-top: 1px solid var(--line);
|
||||
position: relative;
|
||||
}
|
||||
.dock-chips {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.parameter {
|
||||
background: #25242a;
|
||||
border: 1px solid #ffffff07;
|
||||
border-radius: 7px;
|
||||
padding: 5px 8px;
|
||||
font-size: 10px;
|
||||
color: #c4c2cb;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.parameter:hover {
|
||||
border-color: #f5b04255;
|
||||
}
|
||||
.dock .gen {
|
||||
width: auto;
|
||||
min-height: 32px;
|
||||
font-size: 11px;
|
||||
padding: 7px 18px;
|
||||
border-radius: 20px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(140deg, #ffc65e, #f5b042);
|
||||
color: #30210c;
|
||||
border-color: #edb458;
|
||||
box-shadow: 0 3px 12px #f5b04217;
|
||||
}
|
||||
.dock .why {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 3px);
|
||||
right: 14px;
|
||||
max-width: 65%;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
background: #241e17;
|
||||
font-size: 10px;
|
||||
color: var(--warn);
|
||||
z-index: 1;
|
||||
}
|
||||
.dock .why.err {
|
||||
color: var(--err);
|
||||
background: #241717;
|
||||
}
|
||||
.dock .ready {
|
||||
display: none;
|
||||
}
|
||||
.dock .error {
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
/**
|
||||
* 上下文条 (spec-v2 §3.3): the desk was opened from a creation stage.
|
||||
* Says where from, and plainly that takes stay in the desk feed — the
|
||||
* desk's generate call has no shot/asset link to fill back.
|
||||
* Says where from. With a full source identity (HEL-684) results can
|
||||
* be flowed back per take from the result card; otherwise the old
|
||||
* note stands — results stay in the desk feed, nothing is written back.
|
||||
*/
|
||||
interface Props {
|
||||
ctx: DeskContext;
|
||||
@@ -17,6 +18,7 @@
|
||||
let { ctx, projectTitle, onDismiss }: Props = $props();
|
||||
|
||||
const stageName = $derived(t(`workbench.stage.${ctx.stage}`));
|
||||
const canFlowBack = $derived(!!ctx.sourceKind && !!ctx.sourceId);
|
||||
</script>
|
||||
|
||||
<div class="context-bar" role="status">
|
||||
@@ -24,7 +26,9 @@
|
||||
<span class="from">
|
||||
<b>{t('desk.ctxFrom', { project: projectTitle })}</b>{#if ctx.label} · {ctx.label}{/if}
|
||||
</span>
|
||||
<span class="note">{t('desk.ctxNoBackfill', { stage: stageName })}</span>
|
||||
<span class="note">
|
||||
{canFlowBack ? t('desk.ctxBackfill', { stage: stageName }) : t('desk.ctxNoBackfill', { stage: stageName })}
|
||||
</span>
|
||||
<a class="a-btn ghost xs back" href={backHref(ctx)}>{t('desk.ctxBack', { stage: stageName })}</a>
|
||||
<button type="button" class="a-btn ghost xs" aria-label={t('desk.ctxDismiss')} onclick={onDismiss}>
|
||||
<Icon name="close" size={12} />
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { untrack, type Snippet } from 'svelte';
|
||||
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
|
||||
import {
|
||||
clearOutputMissing,
|
||||
markOutputMissing,
|
||||
missingOutputsOf,
|
||||
} from '$lib/missingOutputs.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
import { usageLabel } from './schema';
|
||||
@@ -18,11 +23,13 @@
|
||||
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
|
||||
* fullscreen — `overlay` / `open` drive the last two.
|
||||
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
|
||||
* ↑↓/J/K 即时无动画), a finished/just-picked video auto-plays muted,
|
||||
* ↑↓/J/K 即时无动画), video playback is always explicit,
|
||||
* failed takes get 重试, and a done take whose files no longer load
|
||||
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
|
||||
*/
|
||||
interface Props {
|
||||
main?: boolean;
|
||||
comparePath?: string | null;
|
||||
tab: 'result' | 'params';
|
||||
job: Job | null;
|
||||
wf: Workflow | undefined;
|
||||
@@ -34,19 +41,20 @@
|
||||
canSendToSlot: boolean;
|
||||
/** Animate this result change (pointer pick) — 150ms cross-fade. */
|
||||
crossfade: boolean;
|
||||
/** Bump to auto-play the stage video (pointer pick / completion). */
|
||||
playSignal: number;
|
||||
/** The stage <video> element, for the desk's space-bar toggle. */
|
||||
/** The stage <video> element, for explicit playback controls. */
|
||||
videoEl?: HTMLVideoElement | null;
|
||||
params: Snippet;
|
||||
onClose: () => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onCancel: (job: Job) => void;
|
||||
onSendToSlot: (path: string) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
main = false,
|
||||
comparePath = null,
|
||||
tab = $bindable(),
|
||||
job,
|
||||
wf,
|
||||
@@ -55,17 +63,25 @@
|
||||
inactive,
|
||||
canSendToSlot,
|
||||
crossfade,
|
||||
playSignal,
|
||||
videoEl = $bindable(null),
|
||||
params,
|
||||
onClose,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onSendToSlot,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
let outIndex = $state(0);
|
||||
let compare = $state(false),
|
||||
compareAt = $state(50),
|
||||
lightbox = $state(false);
|
||||
$effect(() => {
|
||||
void job?.id;
|
||||
compare = false;
|
||||
lightbox = false;
|
||||
});
|
||||
$effect.pre(() => {
|
||||
void job?.id;
|
||||
outIndex = 0;
|
||||
@@ -76,16 +92,20 @@
|
||||
// cannot be fetched — the failed case stays red.
|
||||
const gonePaths = $derived(missingOutputsOf(job));
|
||||
const fileGone = $derived(gonePaths.length > 0);
|
||||
const outputs = $derived(job && st?.tone === 'ok' ? job.output_paths.filter((p) => !gonePaths.includes(p)) : []);
|
||||
const outputs = $derived(
|
||||
job && st?.tone === 'ok' ? job.output_paths.filter((p) => !gonePaths.includes(p)) : [],
|
||||
);
|
||||
const output = $derived(outputs[Math.min(outIndex, outputs.length - 1)] ?? null);
|
||||
const allOutputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
|
||||
const video = $derived(!!job && !!output && isVideoOutput(job, output));
|
||||
const wfDeleted = $derived(!!job && job.workflow_id == null);
|
||||
const prompt = $derived(job ? promptOf(job, wf) : '');
|
||||
const inputs = $derived(job ? inputsOf(job, wf) : []);
|
||||
const compareSource = $derived(inputs.find((i) => i.kind === 'image')?.path ?? comparePath);
|
||||
const spec = $derived(job ? specOf(job, wf) : []);
|
||||
const endpoint = $derived(
|
||||
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ?? '',
|
||||
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ??
|
||||
'',
|
||||
);
|
||||
const progress = $derived.by(() => {
|
||||
if (!job || job.status !== 'running') return 0;
|
||||
@@ -108,25 +128,18 @@
|
||||
dims = `${v.videoWidth}×${v.videoHeight}`;
|
||||
}
|
||||
|
||||
// 完成或点选后自动播放(静音);键盘切换不触发。
|
||||
$effect(() => {
|
||||
void playSignal;
|
||||
if (inactive || tab !== 'result' || !videoEl) return;
|
||||
videoEl.muted = true;
|
||||
videoEl.play().catch(() => {});
|
||||
let paused = $state(true);
|
||||
$effect.pre(() => {
|
||||
void output;
|
||||
untrack(() => {
|
||||
videoEl?.pause();
|
||||
paused = true;
|
||||
});
|
||||
});
|
||||
|
||||
function reduced(): boolean {
|
||||
return (
|
||||
document.documentElement.dataset.reduce === '1' ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
}
|
||||
|
||||
/** 150ms 交叉淡变 on pointer picks; keyboard switches stay instant. */
|
||||
function xfade(_node: Element) {
|
||||
const ms = crossfade ? (reduced() ? 120 : 150) : 0;
|
||||
return { duration: ms, css: (t: number) => `opacity: ${t}` };
|
||||
function togglePlayback() {
|
||||
if (!videoEl) return;
|
||||
if (videoEl.paused) videoEl.play().catch(() => {});
|
||||
else videoEl.pause();
|
||||
}
|
||||
|
||||
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
|
||||
@@ -144,14 +157,23 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="inspector" class:open inert={inactive} aria-label={t('desk.inspector')}>
|
||||
<aside class="inspector" class:main class:open inert={inactive} aria-label={t('desk.inspector')}>
|
||||
<div class="tabs">
|
||||
<div class="a-seg" role="group" aria-label={t('desk.inspector')}>
|
||||
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}>{t('desk.tabResult')}</button>
|
||||
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}>{t('desk.tabParams')}</button>
|
||||
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}
|
||||
>{t('desk.tabResult')}</button
|
||||
>
|
||||
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}
|
||||
>{t('desk.tabParams')}</button
|
||||
>
|
||||
</div>
|
||||
<span class="keys"><span class="a-kbd">]</span>{t('desk.keyInspector')}</span>
|
||||
<button type="button" class="a-btn ghost sm close" aria-label={t('desk.closeInspector')} onclick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn ghost sm close"
|
||||
aria-label={t('desk.closeInspector')}
|
||||
onclick={onClose}
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -160,35 +182,58 @@
|
||||
{#if tab === 'params'}
|
||||
{@render params()}
|
||||
{:else if !job || !st}
|
||||
<div class="a-stage stage"><div class="stage-empty">{t('desk.pickTake')}</div></div>
|
||||
<div class="a-stage stage">
|
||||
<div class="stage-empty">
|
||||
<span class="empty-symbol">✳</span><strong>给想象留一个位置。</strong><span class="sub"
|
||||
>在右侧写下描述,或选择一次已有创作。</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="a-stage stage">
|
||||
{#key mediaKey}
|
||||
<div class="stage-media" in:xfade out:xfade>
|
||||
<div class="stage-media">
|
||||
{#if output && video}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
src={assetUrl(output)}
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
></video>
|
||||
<div class="print a-photo">
|
||||
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
src={assetUrl(output)}
|
||||
controls
|
||||
aria-label="生成的视频,使用播放按钮开始播放"
|
||||
onplay={() => (paused = false)}
|
||||
onpause={() => (paused = true)}
|
||||
onended={() => (paused = true)}
|
||||
playsinline
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
></video>
|
||||
</div>
|
||||
{:else if output}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt={prompt || t('desk.tabResult')}
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImg(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
<div class="print a-photo">
|
||||
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt={prompt || t('desk.tabResult')}
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImg(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
{#if main && compare && compareSource}<div
|
||||
class="comparison-layer"
|
||||
style:clip-path={`inset(0 ${100 - compareAt}% 0 0)`}
|
||||
>
|
||||
<img src={assetUrl(compareSource)} alt="对比参考画面" />
|
||||
</div>
|
||||
<div class="comparison-divider" style:left={`${compareAt}%`}></div>{/if}
|
||||
</div>
|
||||
{:else if fileGone}
|
||||
<div class="stage-empty gone">
|
||||
<span>{t('desk.missingTitle')}</span>
|
||||
@@ -199,7 +244,9 @@
|
||||
<span>{st.label}</span>
|
||||
<span class="sub">{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span>
|
||||
{#if progress > 0}
|
||||
<div class="a-progress stage-bar" style:--p={progress / 100}><i></i></div>
|
||||
<div class="a-progress stage-bar" style:--p={progress / 100}>
|
||||
<i></i>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -207,6 +254,19 @@
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if output && video}<div class="playback-bar">
|
||||
<button
|
||||
class="a-btn primary sm"
|
||||
onclick={togglePlayback}
|
||||
aria-label={paused ? '播放视频' : '暂停视频'}
|
||||
>{paused ? '▶ 播放视频' : 'Ⅱ 暂停视频'}</button
|
||||
>
|
||||
<span>视频默认暂停,由你控制播放。</span>
|
||||
<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => videoEl?.requestFullscreen().catch(() => {})}>全屏查看 ⤢</button
|
||||
>
|
||||
</div>{/if}
|
||||
{#if allOutputs.length > 1}
|
||||
<div class="strip" role="group" aria-label={t('desk.outputs')}>
|
||||
{#each allOutputs as p, i (p)}
|
||||
@@ -235,16 +295,42 @@
|
||||
<span>{s.label} <b class="mono">{s.value}</b></span>
|
||||
{/if}
|
||||
{/each}
|
||||
<span>{t('desk.elapsed')} <b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span>
|
||||
<span
|
||||
>{t('desk.elapsed')}
|
||||
<b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span
|
||||
>
|
||||
{#if endpoint}<span>{endpoint}</span>{/if}
|
||||
</div>
|
||||
|
||||
{#if main && compare && output && !video}<div class="compare-controls">
|
||||
<span>{inputs.some((i) => i.kind === 'image') ? '参考图片' : '上一版'}</span><input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
bind:value={compareAt}
|
||||
aria-label="拖动对比画面"
|
||||
/><span>当前结果</span>
|
||||
</div>{/if}
|
||||
<div class="actions">
|
||||
{#if main && output && !video}<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => (lightbox = true)}>放大查看 ⤢</button
|
||||
>{#if compareSource && compareSource !== output}<button
|
||||
class="a-btn secondary sm"
|
||||
aria-pressed={compare}
|
||||
onclick={() => (compare = !compare)}>{compare ? '结束对比' : '对比画面'} ◫</button
|
||||
>{/if}{/if}
|
||||
{#if fileGone && !outputs.length}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('desk.redo')}</button>
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('desk.removeRecord')}</button>
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}
|
||||
>{t('desk.redo')}</button
|
||||
>
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}
|
||||
>{t('desk.removeRecord')}</button
|
||||
>
|
||||
{:else}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}>{t('desk.reuseToComposer')}</button>
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}
|
||||
>{t('desk.reuseToComposer')}</button
|
||||
>
|
||||
{#if output && !video && canSendToSlot}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onSendToSlot(output)}>
|
||||
{t('desk.sendToSlot')}
|
||||
@@ -266,10 +352,18 @@
|
||||
</button>
|
||||
{/if}
|
||||
{#if st.tone === 'fail'}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button>
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}
|
||||
>{t('common.retry')}</button
|
||||
>
|
||||
{/if}
|
||||
{#if job.status === 'pending' || job.status === 'running'}<button
|
||||
class="a-btn secondary sm"
|
||||
onclick={() => onCancel(job)}>取消这次生成</button
|
||||
>{/if}
|
||||
{#if job.status !== 'pending' && job.status !== 'running'}
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}
|
||||
>{t('studio.deleteBtn')}</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -280,44 +374,61 @@
|
||||
<div class="note warn" role="alert">{t('jobst.missingDetail')}</div>
|
||||
{/if}
|
||||
|
||||
{#if inputs.length || prompt}
|
||||
<div class="section-title">{t('desk.inputs')}</div>
|
||||
{#if inputs.length}
|
||||
<div class="inputs">
|
||||
{#each inputs as inp (inp.label + inp.path)}
|
||||
{#if inp.kind === 'image'}
|
||||
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
|
||||
{:else}
|
||||
<span class="mini glyph" title={inp.label}><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<details class="result-details">
|
||||
<summary>创作详情与参数</summary>
|
||||
{#if inputs.length || prompt}
|
||||
<div class="section-title">{t('desk.inputs')}</div>
|
||||
{#if inputs.length}
|
||||
<div class="inputs">
|
||||
{#each inputs as inp (inp.label + inp.path)}
|
||||
{#if inp.kind === 'image'}
|
||||
<img
|
||||
class="mini"
|
||||
src={assetUrl(inp.path)}
|
||||
alt={inp.label}
|
||||
title={inp.label}
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="mini glyph" title={inp.label}
|
||||
><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
{/if}
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<div class="section-title">{t('desk.workflow')}</div>
|
||||
<dl class="kv">
|
||||
<dt>{t('desk.kvName')}</dt>
|
||||
<dd>
|
||||
{wf?.name ?? t('studio.jobName', { id: job.id })}
|
||||
{#if wfDeleted}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
|
||||
</dd>
|
||||
{#if wf?.usage}
|
||||
<dt>{t('desk.kvUsage')}</dt>
|
||||
<dd>{usageLabel(wf.usage)}</dd>
|
||||
{/if}
|
||||
<dt>{t('desk.kvCreated')}</dt>
|
||||
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
|
||||
{#if job.comfy_prompt_id}
|
||||
<dt>{t('desk.kvPromptId')}</dt>
|
||||
<dd class="mono">{job.comfy_prompt_id}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
<div class="section-title">{t('desk.workflow')}</div>
|
||||
<dl class="kv">
|
||||
<dt>{t('desk.kvName')}</dt>
|
||||
<dd>
|
||||
{wf?.name ?? t('studio.jobName', { id: job.id })}
|
||||
{#if wfDeleted}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
|
||||
</dd>
|
||||
{#if wf?.usage}
|
||||
<dt>{t('desk.kvUsage')}</dt>
|
||||
<dd>{usageLabel(wf.usage)}</dd>
|
||||
{/if}
|
||||
<dt>{t('desk.kvCreated')}</dt>
|
||||
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
|
||||
{#if job.comfy_prompt_id}
|
||||
<dt>{t('desk.kvPromptId')}</dt>
|
||||
<dd class="mono">{job.comfy_prompt_id}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{#if lightbox && output && !video}<ImageLightbox
|
||||
src={assetUrl(output) ?? ''}
|
||||
alt={prompt || '创作结果'}
|
||||
onClose={() => (lightbox = false)}
|
||||
/>{/if}
|
||||
|
||||
<style>
|
||||
.inspector {
|
||||
display: flex;
|
||||
@@ -362,14 +473,24 @@
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-5);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.stage-media > img,
|
||||
.stage-media > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
/* 相纸白边 (HEL-680):检视舞台上的结果按相纸呈现,媒体自适应内含 */
|
||||
.print {
|
||||
position: relative;
|
||||
display: grid;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
--paper-w: 5px;
|
||||
}
|
||||
.print > img,
|
||||
.print > video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
.stage-empty {
|
||||
display: grid;
|
||||
@@ -562,4 +683,206 @@
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
}
|
||||
|
||||
.inspector.main {
|
||||
position: static;
|
||||
inset: auto;
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-width: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.main .tabs {
|
||||
display: none;
|
||||
}
|
||||
.main .body {
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.main .stage {
|
||||
aspect-ratio: auto;
|
||||
width: 100%;
|
||||
min-height: 110px;
|
||||
flex: 1;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #eae9e3;
|
||||
}
|
||||
.main .stage-media {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.main .print {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.main .print img,
|
||||
.main .print video {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 0;
|
||||
}
|
||||
.main .meta,
|
||||
.main .actions,
|
||||
.main .playback-bar,
|
||||
.main .strip,
|
||||
.main .note,
|
||||
.main .result-details {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.main .meta {
|
||||
font-size: 10px;
|
||||
gap: 8px;
|
||||
line-height: 1.5;
|
||||
max-height: 30px;
|
||||
overflow: auto;
|
||||
}
|
||||
.main .actions {
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.main .result-details {
|
||||
font-size: 11px;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
}
|
||||
.playback-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.playback-bar span {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
@media (max-height: 650px) {
|
||||
.playback-bar span {
|
||||
display: none;
|
||||
}
|
||||
.main .meta {
|
||||
max-height: 16px;
|
||||
}
|
||||
.main .actions :global(.a-btn) {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
.main .a-fix {
|
||||
display: none;
|
||||
}
|
||||
.main .stage-empty {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty-symbol {
|
||||
color: var(--accent);
|
||||
font-size: 55px;
|
||||
font-weight: 300;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.stage-empty strong {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
.main .meta {
|
||||
font-size: 9px;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.main .actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.result-details {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.result-details summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.result-details .prompt {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.comparison-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.comparison-layer img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #eae9e3;
|
||||
}
|
||||
.comparison-divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 8px #292a2d40;
|
||||
pointer-events: none;
|
||||
}
|
||||
.compare-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.compare-controls input {
|
||||
flex: 1;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import type { ComfyDynamicInput } from '$lib/comfy/types';
|
||||
import { validateForm, classifySchema, type FormValues } from './schema';
|
||||
let {
|
||||
inputs,
|
||||
values,
|
||||
anchor,
|
||||
onClose,
|
||||
onApply,
|
||||
}: {
|
||||
inputs: ComfyDynamicInput[];
|
||||
values: FormValues;
|
||||
anchor: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
onApply: (values: FormValues) => void;
|
||||
} = $props();
|
||||
let draft = $state<FormValues>({});
|
||||
let showErrors = $state(false);
|
||||
let panel = $state<HTMLDivElement | null>(null);
|
||||
let x = $state(0),
|
||||
y = $state(0);
|
||||
const errors = $derived(
|
||||
validateForm(inputs, classifySchema(inputs, 'image'), draft),
|
||||
);
|
||||
$effect(() => {
|
||||
if (!anchor || !inputs.length) return;
|
||||
const source = anchor;
|
||||
draft = Object.fromEntries(
|
||||
inputs.map((i) => [i.nodeId, values[i.nodeId] ?? i.defaultValue ?? '']),
|
||||
);
|
||||
showErrors = false;
|
||||
const rect = source.getBoundingClientRect();
|
||||
x = Math.max(12, Math.min(rect.left, innerWidth - 312));
|
||||
y = Math.max(12, innerHeight - rect.top + 8);
|
||||
void tick().then(() =>
|
||||
panel
|
||||
?.querySelector<HTMLElement>('input,select,textarea,button')
|
||||
?.focus(),
|
||||
);
|
||||
const closeOutside = (e: PointerEvent) => {
|
||||
if (
|
||||
!panel?.contains(e.target as Node) &&
|
||||
!source.contains(e.target as Node)
|
||||
)
|
||||
onClose();
|
||||
};
|
||||
const keys = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
if (e.key === 'Tab' && panel) {
|
||||
const all = [
|
||||
...panel.querySelectorAll<HTMLElement>(
|
||||
'input,select,textarea,button',
|
||||
),
|
||||
].filter(
|
||||
(e) => !e.hasAttribute('disabled') && e.getClientRects().length,
|
||||
);
|
||||
if (e.shiftKey && document.activeElement === all[0]) {
|
||||
e.preventDefault();
|
||||
all.at(-1)?.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === all.at(-1)) {
|
||||
e.preventDefault();
|
||||
all[0]?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', closeOutside);
|
||||
document.addEventListener('keydown', keys, true);
|
||||
window.addEventListener('resize', onClose);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeOutside);
|
||||
document.removeEventListener('keydown', keys, true);
|
||||
window.removeEventListener('resize', onClose);
|
||||
if (source.isConnected) source.focus();
|
||||
};
|
||||
});
|
||||
function save() {
|
||||
if (Object.keys(errors).length) {
|
||||
showErrors = true;
|
||||
return;
|
||||
}
|
||||
onApply(draft);
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if inputs.length && anchor}
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="parameter-popover"
|
||||
role="dialog"
|
||||
aria-label={inputs.map((i) => i.label).join(' / ')}
|
||||
style:left={`${x}px`}
|
||||
style:bottom={`${y}px`}
|
||||
>
|
||||
<header>{inputs.map((i) => i.label).join(' / ')}</header>
|
||||
<ComfyDynamicForm
|
||||
{inputs}
|
||||
values={draft as Record<string, string | number>}
|
||||
quiet
|
||||
{errors}
|
||||
{showErrors}
|
||||
onChange={(v) => (draft = { ...draft, ...v })}
|
||||
/>
|
||||
<footer>
|
||||
<button class="a-btn ghost sm" onclick={onClose}>取消</button><button
|
||||
class="a-btn secondary sm"
|
||||
onclick={save}>应用参数</button
|
||||
>
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.parameter-popover {
|
||||
position: fixed;
|
||||
z-index: 800;
|
||||
width: 300px;
|
||||
box-sizing: border-box;
|
||||
max-height: calc(100dvh - 36px);
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
background: var(--float-bg);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-float);
|
||||
transform-origin: bottom left;
|
||||
animation: appear var(--d-pop) var(--ease-out);
|
||||
}
|
||||
header {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@keyframes appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -154,6 +154,7 @@
|
||||
{@const reject = rejects[s.inp.nodeId]?.[0]}
|
||||
<MediaSlot
|
||||
label={s.label}
|
||||
hint={s.lastFrame && !s.inp.required ? t('studio.slotOptional') : undefined}
|
||||
required={s.inp.required}
|
||||
value={String(values[s.inp.nodeId] ?? '')}
|
||||
kind={s.mediaKind}
|
||||
|
||||
@@ -8,7 +8,15 @@
|
||||
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel } from './schema';
|
||||
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf } from './takes';
|
||||
import {
|
||||
elapsedSec,
|
||||
fmtClock,
|
||||
fmtDur,
|
||||
inputsOf,
|
||||
isVideoOutput,
|
||||
promptOf,
|
||||
workbenchCtxOf,
|
||||
} from './takes';
|
||||
|
||||
/**
|
||||
* 一条记录 (spec-v2 §3.3 记录卡): workflow · usage · time, the prompt, the
|
||||
@@ -20,6 +28,9 @@
|
||||
* take whose files no longer load turns amber (文件缺失) with
|
||||
* 用相同参数重做 / 移除记录, and takes on a deleted workflow are
|
||||
* marked as such instead of pretending nothing changed.
|
||||
* HEL-684: a take generated with a real workbench source offers an
|
||||
* explicit 回流到源项目 action; once flowed back it shows 已回流 and
|
||||
* a direct 返回项目 link instead.
|
||||
*/
|
||||
interface Props {
|
||||
job: Job;
|
||||
@@ -33,11 +44,14 @@
|
||||
flash?: boolean;
|
||||
/** One-shot failure shake. */
|
||||
shake?: boolean;
|
||||
/** Flow-back request in flight (HEL-684). */
|
||||
attachBusy?: boolean;
|
||||
onSelect: () => void;
|
||||
onReuse: () => void;
|
||||
onRetry: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onAttach?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -49,11 +63,13 @@
|
||||
enter = false,
|
||||
flash = false,
|
||||
shake = false,
|
||||
attachBusy = false,
|
||||
onSelect,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onAttach,
|
||||
}: Props = $props();
|
||||
|
||||
const st = $derived(jobStatusInfo(job));
|
||||
@@ -80,6 +96,16 @@
|
||||
});
|
||||
const active = $derived(job.status === 'pending' || job.status === 'running');
|
||||
const name = $derived(wf?.name ?? t('studio.jobName', { id: job.id }));
|
||||
// HEL-684: flow-back only for finished takes with files and a real,
|
||||
// still-recorded workbench source; the attached marker comes from the
|
||||
// server so it survives reloads.
|
||||
const wbCtx = $derived(workbenchCtxOf(job));
|
||||
const attached = $derived(
|
||||
!!job.workbench_attach && !!wbCtx && job.workbench_attach.target_id === wbCtx.sourceId,
|
||||
);
|
||||
const canAttach = $derived(
|
||||
!!onAttach && !!wbCtx && !attached && st.tone === 'ok' && !fileGone && !!output,
|
||||
);
|
||||
|
||||
let portrait = $state(false);
|
||||
let clipSec = $state<number | null>(null);
|
||||
@@ -164,6 +190,25 @@
|
||||
</button>
|
||||
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('desk.removeRecord')}</button>
|
||||
{:else}
|
||||
{#if canAttach}
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn ghost xs attach"
|
||||
disabled={attachBusy}
|
||||
title={t('desk.attachHint', { label: wbCtx?.label ?? '' })}
|
||||
onclick={() => onAttach?.()}
|
||||
>
|
||||
{attachBusy ? t('desk.attaching') : t('desk.attachToProject')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if attached && wbCtx}
|
||||
<span class="a-tag ok" title={t('desk.attachHint', { label: wbCtx.label ?? '' })}>
|
||||
{t('desk.attachedTag')}
|
||||
</span>
|
||||
<a class="a-btn ghost xs" href={`/workbench/${wbCtx.projectId}?stage=${wbCtx.stage}`}>
|
||||
{t('desk.attachBack')}
|
||||
</a>
|
||||
{/if}
|
||||
<button type="button" class="a-btn ghost xs" onclick={onReuse}>{t('desk.reuse')}</button>
|
||||
{#if st.tone === 'fail' && job.error !== 'cancelled'}
|
||||
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>{t('common.retry')}</button>
|
||||
@@ -183,7 +228,8 @@
|
||||
|
||||
<div class="out">
|
||||
{#if output}
|
||||
<div class="thumb" class:portrait>
|
||||
<div class="thumb a-photo" class:portrait>
|
||||
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
|
||||
{#if isVideoOutput(job, output)}
|
||||
<video
|
||||
src={assetUrl(output)}
|
||||
@@ -238,15 +284,42 @@
|
||||
background-color var(--d-state) ease,
|
||||
box-shadow var(--d-state) ease;
|
||||
}
|
||||
/* 选中框:金色描边从上一条记录滑到新选中的记录(feed 驱动 WAAPI) */
|
||||
/* 选中框:取景框角线从上一条记录滑到新选中的记录(feed 驱动 WAAPI,
|
||||
HEL-680 四角短线取代整框描边) */
|
||||
.sel-mark {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
z-index: 3;
|
||||
display: none;
|
||||
border: 1px solid var(--accent-line);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0),
|
||||
linear-gradient(var(--accent) 0 0);
|
||||
background-position:
|
||||
left top,
|
||||
left top,
|
||||
right top,
|
||||
right top,
|
||||
left bottom,
|
||||
left bottom,
|
||||
right bottom,
|
||||
right bottom;
|
||||
background-size:
|
||||
14px 2px,
|
||||
2px 14px,
|
||||
14px 2px,
|
||||
2px 14px,
|
||||
14px 2px,
|
||||
2px 14px,
|
||||
14px 2px,
|
||||
2px 14px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.take.selected {
|
||||
border-color: transparent;
|
||||
@@ -458,6 +531,12 @@
|
||||
background: var(--bg-4);
|
||||
box-shadow: inset 0 0 0 1px var(--on-media-line);
|
||||
}
|
||||
/* 相纸白边 (HEL-680):有产出的记录缩略图按相纸呈现,压过占位态样式 */
|
||||
.thumb.a-photo {
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.thumb.portrait {
|
||||
width: 96px;
|
||||
aspect-ratio: 3 / 4;
|
||||
|
||||
@@ -38,11 +38,14 @@
|
||||
banner?: Snippet;
|
||||
/** Inspector toggle when it is hidden (desktop) or an overlay (mid). */
|
||||
inspectorToggle?: Snippet;
|
||||
/** Flow-back to the workbench source row in flight (HEL-684). */
|
||||
attachBusyId?: number | null;
|
||||
onSelect: (job: Job) => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onCancel: (job: Job) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
onAttach?: (job: Job) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -59,11 +62,13 @@
|
||||
orderedIds = $bindable(),
|
||||
banner,
|
||||
inspectorToggle,
|
||||
attachBusyId = null,
|
||||
onSelect,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onAttach,
|
||||
}: Props = $props();
|
||||
|
||||
const FILTERS: Array<{ id: FeedFilter; key: string }> = [
|
||||
@@ -263,6 +268,8 @@
|
||||
onRetry={() => onRetry(job)}
|
||||
onCancel={() => onCancel(job)}
|
||||
onDelete={() => onDelete(job)}
|
||||
attachBusy={attachBusyId === job.id}
|
||||
onAttach={onAttach ? () => onAttach(job) : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import { WORKFLOW_USAGES, type Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { slotSummary, usageLabel } from './schema';
|
||||
|
||||
/**
|
||||
* 工作流选择 (spec-v2 §3.3 工作流面板): the composer's first chip opens a
|
||||
* pop grouped by usage with search; each row shows what the workflow
|
||||
* really takes (slot summary from its JSON), its connection and whether
|
||||
* it last ran. Picking keeps the prompt and carried-over images.
|
||||
* D 批 (HEL-654): the footer's「↑↓ 选择」is now literally true — an
|
||||
* active row follows ↑↓ (and the pointer), Enter uses it, and the
|
||||
* search's Enter picks the active row instead of always the first.
|
||||
*/
|
||||
interface Props {
|
||||
workflows: Workflow[];
|
||||
current: Workflow | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onPick: (wf: Workflow) => void;
|
||||
}
|
||||
|
||||
let { workflows, current, endpoints, onPick }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let activeId = $state<number | null>(null);
|
||||
|
||||
type Status = { tone: '' | 'ok' | 'warn' | 'err'; label: string };
|
||||
|
||||
function statusOf(wf: Workflow): Status {
|
||||
if ((wf.warnings ?? []).some((w) => w.level === 'error')) return { tone: 'err', label: t('desk.wfBroken') };
|
||||
const lt = wf.last_test;
|
||||
if (!lt) return { tone: '', label: t('desk.wfUntested') };
|
||||
if (lt.status === 'done' && lt.output_count > 0) return { tone: 'ok', label: t('desk.wfOk') };
|
||||
if (lt.status === 'failed' || lt.status === 'done') return { tone: 'warn', label: t('desk.wfLastFailed') };
|
||||
return { tone: '', label: t('desk.wfTesting') };
|
||||
}
|
||||
|
||||
function endpointLabel(wf: Workflow): string {
|
||||
const ep = endpoints.find((e) => e.id === wf.endpoint_id);
|
||||
return ep ? ep.name : t('desk.wfDefaultConn');
|
||||
}
|
||||
|
||||
const rows = $derived(
|
||||
workflows.map((wf) => ({
|
||||
wf,
|
||||
summary: slotSummary(wf),
|
||||
conn: endpointLabel(wf),
|
||||
status: statusOf(wf),
|
||||
usage: wf.usage ? usageLabel(wf.usage) : '',
|
||||
})),
|
||||
);
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const hit = (r: (typeof rows)[number]) =>
|
||||
!q || `${r.wf.name} ${r.usage} ${r.summary}`.toLowerCase().includes(q);
|
||||
return WORKFLOW_USAGES.map((u) => ({
|
||||
usage: u,
|
||||
items: rows.filter((r) => r.wf.usage === u && hit(r)),
|
||||
})).filter((g) => g.items.length);
|
||||
});
|
||||
|
||||
/** Flattened rows in display order — the roving selection walks these. */
|
||||
const flat = $derived(groups.flatMap((g) => g.items));
|
||||
|
||||
// Keep the active row real: default to the current workflow, clamp on
|
||||
// filter changes, never point at a row that is no longer listed.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
if (activeId != null && flat.some((r) => r.wf.id === activeId)) return;
|
||||
activeId = flat.find((r) => r.wf.id === current?.id)?.wf.id ?? flat[0]?.wf.id ?? null;
|
||||
});
|
||||
|
||||
function moveActive(delta: number) {
|
||||
if (!flat.length) return;
|
||||
let i = flat.findIndex((r) => r.wf.id === activeId);
|
||||
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
|
||||
activeId = flat[Math.max(0, Math.min(flat.length - 1, i))].wf.id;
|
||||
}
|
||||
|
||||
function pickActive() {
|
||||
const row = flat.find((r) => r.wf.id === activeId) ?? flat[0];
|
||||
if (row) pick(row.wf);
|
||||
}
|
||||
|
||||
function pick(wf: Workflow) {
|
||||
open = false;
|
||||
query = '';
|
||||
if (wf.id !== current?.id) onPick(wf);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
pickActive();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="picker">
|
||||
<button
|
||||
type="button"
|
||||
id="desk-wf"
|
||||
class="a-chip wf"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={current?.name ?? t('desk.pickWorkflow')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<Icon name="playground" size={14} />
|
||||
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
|
||||
</button>
|
||||
<ShellPop
|
||||
bind:open
|
||||
label={t('desk.pickWorkflow')}
|
||||
placement="top"
|
||||
width="520px"
|
||||
arrowRoving={false}
|
||||
onclose={() => (query = '')}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="pop-body" onkeydown={onKeydown}>
|
||||
<div class="search">
|
||||
<Icon name="search" size={14} />
|
||||
<input
|
||||
class="search-input"
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder={t('desk.wfSearchPh')}
|
||||
aria-label={t('desk.wfSearchPh')}
|
||||
/>
|
||||
</div>
|
||||
<div class="list" role="listbox" aria-label={t('desk.pickWorkflow')}>
|
||||
{#each groups as g (g.usage)}
|
||||
<div class="a-menu-group">{usageLabel(g.usage)} · {g.items.length}</div>
|
||||
{#each g.items as r (r.wf.id)}
|
||||
{@const cur = r.wf.id === current?.id}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item row"
|
||||
class:off={r.status.tone === 'err'}
|
||||
class:active={r.wf.id === activeId}
|
||||
role="option"
|
||||
aria-current={cur ? 'true' : undefined}
|
||||
aria-selected={r.wf.id === activeId ? 'true' : undefined}
|
||||
onclick={() => pick(r.wf)}
|
||||
onpointermove={() => (activeId = r.wf.id)}
|
||||
>
|
||||
<span class="main">
|
||||
<span class="a-menu-label">{r.wf.name}{cur ? ` · ${t('desk.wfCurrent')}` : ''}</span>
|
||||
<span class="sub">
|
||||
<span class="a-tag">{r.summary}</span>
|
||||
<span class="a-tag">{r.conn}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="a-tag {r.status.tone}">{r.status.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="empty">{t('desk.wfNoMatch')}</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="foot">
|
||||
<a class="a-btn ghost sm" href="/connections">
|
||||
<Icon name="upload" size={13} />
|
||||
{t('desk.wfImport')}
|
||||
</a>
|
||||
<span class="keys">
|
||||
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
|
||||
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
|
||||
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.pop-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
/* Search and foot stay put; only the list scrolls inside the pop's max-height. */
|
||||
.list {
|
||||
max-height: min(380px, 50vh);
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
.row {
|
||||
align-items: center;
|
||||
}
|
||||
/* 键盘游走选中态(↑↓ 选择 · Enter 使用) */
|
||||
.row.active {
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.main {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.main .a-menu-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.row.off .a-menu-label {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3) var(--sp-1) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keys .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,8 +3,9 @@ import type { DeskKind } from './schema';
|
||||
|
||||
/**
|
||||
* 五段创作 → 生成台 handoff, carried in the URL so a reload keeps it.
|
||||
* The desk only prefills from it; the playground generate API has no
|
||||
* project/shot link, so results stay in the desk feed (no backfill).
|
||||
* The desk prefills from it and, when the full source identity is present
|
||||
* (sourceKind/sourceId), sends it along with the generate request so a
|
||||
* finished take can be flowed back to its source row (HEL-684).
|
||||
*/
|
||||
export interface DeskContext {
|
||||
kind: DeskKind;
|
||||
@@ -15,10 +16,30 @@ export interface DeskContext {
|
||||
prompt?: string;
|
||||
negative?: string;
|
||||
durationSec?: number;
|
||||
/** Real source row identity — only present on links the workbench
|
||||
* stamped with it; without it no flow-back is offered (nothing guessed). */
|
||||
sourceKind?: 'asset' | 'shot';
|
||||
sourceId?: string;
|
||||
}
|
||||
|
||||
/** The generate-request context payload shape (HEL-684). */
|
||||
export interface DeskWorkbenchContext {
|
||||
project_id: number;
|
||||
stage: 'assets' | 'shot';
|
||||
source_kind: 'asset' | 'shot';
|
||||
source_id: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const STAGES: WorkbenchStageKey[] = ['script', 'style', 'storyboard', 'assets', 'shot'];
|
||||
|
||||
/** Stage ↔ desk kind pairing the workbench rows render (assets → image,
|
||||
* shot → video). Anything else cannot display a flowed-back take. */
|
||||
const KIND_BY_STAGE: Partial<Record<WorkbenchStageKey, DeskKind>> = {
|
||||
assets: 'image',
|
||||
shot: 'video',
|
||||
};
|
||||
|
||||
export function deskHref(ctx: DeskContext): string {
|
||||
const q = new URLSearchParams({
|
||||
from: 'workbench',
|
||||
@@ -29,6 +50,10 @@ export function deskHref(ctx: DeskContext): string {
|
||||
if (ctx.prompt) q.set('prompt', ctx.prompt);
|
||||
if (ctx.negative) q.set('negative', ctx.negative);
|
||||
if (ctx.durationSec != null) q.set('duration', String(ctx.durationSec));
|
||||
if (ctx.sourceKind && ctx.sourceId) {
|
||||
q.set('source_kind', ctx.sourceKind);
|
||||
q.set('source_id', ctx.sourceId);
|
||||
}
|
||||
return `/${ctx.kind}?${q.toString()}`;
|
||||
}
|
||||
|
||||
@@ -39,6 +64,13 @@ export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
|
||||
const stage = q.get('stage') as WorkbenchStageKey | null;
|
||||
if (!Number.isInteger(projectId) || projectId <= 0 || !stage || !STAGES.includes(stage)) return null;
|
||||
const duration = Number(q.get('duration'));
|
||||
const rawKind = q.get('source_kind');
|
||||
const rawId = q.get('source_id') ?? '';
|
||||
// Source identity is only kept when it is complete and self-consistent;
|
||||
// old/hand-made links keep today's prefill-only behavior.
|
||||
const expectedKind = stage === 'assets' ? 'asset' : stage === 'shot' ? 'shot' : null;
|
||||
const sourceKind = rawKind === 'asset' || rawKind === 'shot' ? rawKind : null;
|
||||
const hasSource = sourceKind !== null && sourceKind === expectedKind && rawId.trim() !== '';
|
||||
return {
|
||||
kind,
|
||||
projectId,
|
||||
@@ -47,9 +79,29 @@ export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
|
||||
prompt: q.get('prompt') ?? undefined,
|
||||
negative: q.get('negative') ?? undefined,
|
||||
durationSec: Number.isFinite(duration) && duration > 0 ? duration : undefined,
|
||||
sourceKind: hasSource ? (sourceKind as 'asset' | 'shot') : undefined,
|
||||
sourceId: hasSource ? rawId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The context a generate request carries: only when the full, real source
|
||||
* identity is present AND the desk kind matches what that stage renders.
|
||||
*/
|
||||
export function workbenchContextOf(ctx: DeskContext | null): DeskWorkbenchContext | undefined {
|
||||
if (!ctx?.sourceId || !ctx.sourceKind) return undefined;
|
||||
if (ctx.stage !== 'assets' && ctx.stage !== 'shot') return undefined;
|
||||
if (KIND_BY_STAGE[ctx.stage] !== ctx.kind) return undefined;
|
||||
const out: DeskWorkbenchContext = {
|
||||
project_id: ctx.projectId,
|
||||
stage: ctx.stage,
|
||||
source_kind: ctx.sourceKind,
|
||||
source_id: ctx.sourceId,
|
||||
};
|
||||
if (ctx.label.trim()) out.label = ctx.label;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function backHref(ctx: DeskContext): string {
|
||||
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
|
||||
}
|
||||
|
||||
@@ -282,7 +282,9 @@ export function validateForm(
|
||||
if (first && last) {
|
||||
const a = String(values[first.inp.nodeId] ?? '');
|
||||
const b = String(values[last.inp.nodeId] ?? '');
|
||||
if ((!a || !b) && (a || b)) {
|
||||
// An optional last frame (H3 FL2VA) may stay empty; it never goes without a first frame.
|
||||
const unpaired = last.inp.required ? (!a || !b) && (a || b) : !a && !!b;
|
||||
if (unpaired) {
|
||||
errs.firstLast = t('studio.errFirstLast');
|
||||
if (!a) errs[first.inp.nodeId] = ' ';
|
||||
if (!b) errs[last.inp.nodeId] = ' ';
|
||||
|
||||
@@ -9,6 +9,34 @@ import { classifySchema, inputRole, isFirstFrame, isLastFrame, isMedia } from '.
|
||||
|
||||
export type FeedFilter = 'all' | 'image' | 'video' | 'failed';
|
||||
|
||||
/** The workbench source a take was generated with, if any (HEL-684). */
|
||||
export interface TakeWorkbenchCtx {
|
||||
projectId: number;
|
||||
stage: string;
|
||||
sourceKind: 'asset' | 'shot';
|
||||
sourceId: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function workbenchCtxOf(job: Job): TakeWorkbenchCtx | null {
|
||||
const stamp = job.payload?.workbench;
|
||||
if (!stamp || typeof stamp !== 'object') return null;
|
||||
const s = stamp as Record<string, unknown>;
|
||||
const projectId = s.project_id;
|
||||
const stage = typeof s.stage === 'string' ? s.stage : '';
|
||||
const sourceKind = s.source_kind === 'asset' || s.source_kind === 'shot' ? s.source_kind : null;
|
||||
const sourceId = typeof s.source_id === 'string' ? s.source_id : '';
|
||||
if (typeof projectId !== 'number' || !stage || !sourceKind || !sourceId) return null;
|
||||
const label = typeof s.label === 'string' && s.label.trim() ? s.label : undefined;
|
||||
return {
|
||||
projectId,
|
||||
stage,
|
||||
sourceKind,
|
||||
sourceId,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
export function jobValues(job: Job): Record<string, unknown> {
|
||||
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,13 @@
|
||||
);
|
||||
|
||||
// Five-stage creation progress (HEL-614) — real stage rows only.
|
||||
const STAGE_SEGMENTS = ['script', 'style', 'storyboard', 'assets', 'shot'] as const;
|
||||
const STAGE_SEGMENTS = [
|
||||
'script',
|
||||
'style',
|
||||
'storyboard',
|
||||
'assets',
|
||||
'shot',
|
||||
] as const;
|
||||
function segClass(key: string): string {
|
||||
const state = stats.stage_status?.[key];
|
||||
if (state === 'confirmed') return 'seg-confirmed';
|
||||
@@ -46,13 +52,16 @@
|
||||
|
||||
// Spec §2.1 allows the five avatar gradients as cover placeholders.
|
||||
const PLACEHOLDER_GRADIENTS = [
|
||||
'var(--avatar-1)',
|
||||
'var(--avatar-2)',
|
||||
'var(--avatar-3)',
|
||||
'var(--avatar-4)',
|
||||
'var(--avatar-5)',
|
||||
'#1f262c',
|
||||
'#253036',
|
||||
'#2d2333',
|
||||
'#26372c',
|
||||
'#302836',
|
||||
];
|
||||
const placeholder = $derived(PLACEHOLDER_GRADIENTS[project.id % PLACEHOLDER_GRADIENTS.length]);
|
||||
const placeholderIdx = $derived(project.id % PLACEHOLDER_GRADIENTS.length);
|
||||
const placeholder = $derived(PLACEHOLDER_GRADIENTS[placeholderIdx]);
|
||||
// HEL-680 灰阶封面占位:1–2 亮阶配深图标,3–5 暗阶配浅图标
|
||||
const placeholderIconColor = $derived('var(--text-2)');
|
||||
|
||||
const coverUrl = $derived(assetUrl(project.cover_path));
|
||||
|
||||
@@ -64,17 +73,28 @@
|
||||
if (stats.stage_confirmed >= STAGE_SEGMENTS.length) {
|
||||
return { cls: 'st-done', label: t('home.statusDone') };
|
||||
}
|
||||
const current = STAGE_SEGMENTS.find((k) => stats.stage_status?.[k] !== 'confirmed');
|
||||
const current = STAGE_SEGMENTS.find(
|
||||
(k) => stats.stage_status?.[k] !== 'confirmed',
|
||||
);
|
||||
const state = current ? stats.stage_status?.[current] : undefined;
|
||||
const stageName = current ? t(`workbench.stage.${current}`) : '';
|
||||
if (state === 'running') {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.running')}` };
|
||||
return {
|
||||
cls: 'st-progress',
|
||||
label: `${stageName} · ${t('workbench.state.running')}`,
|
||||
};
|
||||
}
|
||||
if (state === 'awaiting_confirm') {
|
||||
return { cls: 'st-pending', label: `${stageName} · ${t('workbench.state.draft')}` };
|
||||
return {
|
||||
cls: 'st-pending',
|
||||
label: `${stageName} · ${t('workbench.state.draft')}`,
|
||||
};
|
||||
}
|
||||
if (stats.stage_confirmed > 0) {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('home.statusProgress')}` };
|
||||
return {
|
||||
cls: 'st-progress',
|
||||
label: `${stageName} · ${t('home.statusProgress')}`,
|
||||
};
|
||||
}
|
||||
return { cls: 'st-draft', label: t('home.statusDraft') };
|
||||
});
|
||||
@@ -132,7 +152,9 @@
|
||||
toast.success(t('projectCard.renamedTo', { title: renameValue }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.renameFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('projectCard.renameFailed'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -152,7 +174,9 @@
|
||||
toast.success(t('projectCard.deleted', { title: project.title }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.deleteFailed'));
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('projectCard.deleteFailed'),
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -174,8 +198,15 @@
|
||||
onerror={() => (coverFailed = true)}
|
||||
/>
|
||||
{:else}
|
||||
<span class="cover-ph" style:background={placeholder} aria-hidden="true">
|
||||
<Icon name="film" size={22} />
|
||||
<span
|
||||
class="cover-ph"
|
||||
style:background={placeholder}
|
||||
style:color={placeholderIconColor}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="cover-type"
|
||||
>✳<small>STORY / {String(project.id).padStart(3, '0')}</small></span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="status-tag {cover.cls}">{cover.label}</span>
|
||||
@@ -188,11 +219,14 @@
|
||||
<span class="idea">{project.idea || t('home.noIdea')}</span>
|
||||
<span class="stage-bar" aria-label={t('home.stageProgress')}>
|
||||
{#each STAGE_SEGMENTS as key (key)}
|
||||
<span class="seg {segClass(key)}" title={t(`workbench.stage.${key}`)}></span>
|
||||
<span class="seg {segClass(key)}" title={t(`workbench.stage.${key}`)}
|
||||
></span>
|
||||
{/each}
|
||||
<span class="seg-count mono">{stats.stage_confirmed}/5</span>
|
||||
{#if stats.active_jobs > 0}
|
||||
<span class="jobs-pill">{t('home.activeJobs', { n: stats.active_jobs })}</span>
|
||||
<span class="jobs-pill"
|
||||
>{t('home.activeJobs', { n: stats.active_jobs })}</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="meta">
|
||||
@@ -222,7 +256,12 @@
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<button type="button" role="menuitem" class="menu-item" onclick={openRename}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="menu-item"
|
||||
onclick={openRename}
|
||||
>
|
||||
<Icon name="edit" size={14} />
|
||||
{t('common.rename')}
|
||||
</button>
|
||||
@@ -256,7 +295,9 @@
|
||||
</label>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (renameOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="ghost" onclick={() => (renameOpen = false)}
|
||||
>{t('common.cancel')}</Button
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!renameValue.trim() || $renameMutation.isPending}
|
||||
@@ -524,9 +565,81 @@
|
||||
}
|
||||
.menu-item.danger:hover {
|
||||
background: var(--error-dim);
|
||||
} .a-card :global(.field-input) {
|
||||
}
|
||||
.a-card :global(.field-input) {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.a-card {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.cover {
|
||||
height: 180px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.body {
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
.status-tag {
|
||||
top: auto;
|
||||
left: 12px;
|
||||
right: auto;
|
||||
bottom: 12px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 4px 7px;
|
||||
background: #121218ed;
|
||||
color: var(--text);
|
||||
font-size: 9px;
|
||||
}
|
||||
.cover-type {
|
||||
font-size: 56px;
|
||||
font-weight: 300;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.cover-type small {
|
||||
display: block;
|
||||
font: 9px var(--mono);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.cover-img {
|
||||
transition: transform 250ms var(--ease-out);
|
||||
}
|
||||
.stage-bar {
|
||||
width: 100%;
|
||||
}
|
||||
.seg {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
}
|
||||
.meta-stats,
|
||||
.meta-time {
|
||||
font-size: 10px;
|
||||
}
|
||||
.idea {
|
||||
font-size: 11px;
|
||||
min-height: 0;
|
||||
}
|
||||
.menu-wrap {
|
||||
left: auto;
|
||||
right: 10px;
|
||||
}
|
||||
.menu-btn {
|
||||
background: var(--surface);
|
||||
}
|
||||
.menu {
|
||||
left: auto;
|
||||
right: 0;
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.a-card:hover .cover-img {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
.tabs button.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.tabs button:focus-visible {
|
||||
@@ -341,7 +341,7 @@
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -271,9 +271,9 @@
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
background: var(--media-scrim);
|
||||
border: none;
|
||||
color: white;
|
||||
color: var(--on-media);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
padding: 0 4px;
|
||||
border-radius: 9999px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
color: var(--accent-ink);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
|
||||
.step-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
|
||||
.step-btn:focus-visible {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let { onStart, onImport }: { onStart: () => void; onImport: () => void } =
|
||||
$props();
|
||||
let card: HTMLDivElement;
|
||||
onMount(() => {
|
||||
let raf = 0,
|
||||
x = 0,
|
||||
v = 0,
|
||||
target = 0,
|
||||
time = 0;
|
||||
const reduced = () =>
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches ||
|
||||
document.documentElement.dataset.reduce === '1';
|
||||
function animate(now: number) {
|
||||
const dt = Math.min((now - time) / 1000 || 0.016, 0.032);
|
||||
time = now;
|
||||
v += ((target - x) * 100 - v * 10) * dt;
|
||||
x += v * dt;
|
||||
card.style.transform = `rotate(${10 + x}deg) translateY(${-Math.abs(x)}px)`;
|
||||
if (Math.abs(v) + Math.abs(target - x) > 0.01)
|
||||
raf = requestAnimationFrame(animate);
|
||||
else raf = 0;
|
||||
}
|
||||
function go(value: number) {
|
||||
if (reduced()) return;
|
||||
target = value;
|
||||
if (!raf) {
|
||||
time = performance.now();
|
||||
raf = requestAnimationFrame(animate);
|
||||
}
|
||||
}
|
||||
const enter = () => go(-5),
|
||||
leave = () => go(0);
|
||||
card.addEventListener('pointerenter', enter);
|
||||
card.addEventListener('pointerleave', leave);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
card.removeEventListener('pointerenter', enter);
|
||||
card.removeEventListener('pointerleave', leave);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="refract-hero">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow">● A SPACE FOR THE UNEXPECTED</div>
|
||||
<h1>让想象,<br /><span>在这里成形。</span></h1>
|
||||
<p>
|
||||
从一个念头,到一组画面,再到一段故事。<br
|
||||
/>你的模型、素材与灵感,都在同一个工作室。
|
||||
</p>
|
||||
<div class="hero-buttons">
|
||||
<button class="a-btn primary" onclick={onStart}>开始创作 →</button><button
|
||||
class="text-button"
|
||||
onclick={onImport}>导入已有剧本 ↗</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-art">
|
||||
<img
|
||||
class="hero-photo"
|
||||
src="/refract/coast.jpg"
|
||||
alt="海岸与薄雾构成的灵感画面"
|
||||
/>
|
||||
<div class="art-circle"></div>
|
||||
<span class="hero-star">✳</span>
|
||||
<div class="art-label">
|
||||
VISUAL STUDY / 001<br />LIGHT, SPACE & POSSIBILITY
|
||||
</div>
|
||||
<div class="hero-card" bind:this={card}>
|
||||
<img src="/refract/arch.jpg" alt="建筑几何参考" /><span
|
||||
>FOUND IN THE IN-BETWEEN</span
|
||||
>
|
||||
</div>
|
||||
<div class="art-word">Beyond<br />the ordinary.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.refract-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.12fr;
|
||||
min-height: 322px;
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(ellipse at 0 0, #7850ff1a, transparent 70%), #141419;
|
||||
border: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.hero-copy {
|
||||
padding: 35px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1.3px;
|
||||
margin-bottom: 19px;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(33px, 3vw, 47px);
|
||||
line-height: 1.3;
|
||||
margin: 0 0 15px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -1.6px;
|
||||
}
|
||||
h1 span {
|
||||
color: var(--accent);
|
||||
}
|
||||
p {
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
color: var(--text-3);
|
||||
margin: 0 0 23px;
|
||||
}
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
.hero-buttons .a-btn {
|
||||
height: 41px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.text-button {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hero-art {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #d2dbe4;
|
||||
min-height: 322px;
|
||||
isolation: isolate;
|
||||
}
|
||||
.hero-photo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center 70%;
|
||||
filter: contrast(0.92) saturate(0.7);
|
||||
}
|
||||
.art-circle {
|
||||
position: absolute;
|
||||
width: 270px;
|
||||
height: 270px;
|
||||
border: 1px solid #ffffff88;
|
||||
border-radius: 50%;
|
||||
top: 28px;
|
||||
left: 75px;
|
||||
z-index: 1;
|
||||
}
|
||||
.art-circle:before,
|
||||
.art-circle:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border: 1px solid #ffffff66;
|
||||
border-radius: 50%;
|
||||
inset: 26px;
|
||||
}
|
||||
.art-circle:after {
|
||||
inset: 57px;
|
||||
}
|
||||
.art-word {
|
||||
position: absolute;
|
||||
bottom: 22px;
|
||||
left: 23px;
|
||||
z-index: 2;
|
||||
font-family: Georgia, serif;
|
||||
font-size: 64px;
|
||||
color: white;
|
||||
line-height: 0.8;
|
||||
font-style: italic;
|
||||
letter-spacing: -4px;
|
||||
}
|
||||
.art-label {
|
||||
position: absolute;
|
||||
top: 21px;
|
||||
right: 22px;
|
||||
z-index: 2;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
text-align: right;
|
||||
letter-spacing: 1.4px;
|
||||
}
|
||||
.hero-card {
|
||||
position: absolute;
|
||||
right: 27px;
|
||||
top: 60px;
|
||||
width: 145px;
|
||||
height: 188px;
|
||||
border: 5px solid var(--surface);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 20px 30px #1b2a3530;
|
||||
transform: rotate(10deg);
|
||||
z-index: 3;
|
||||
}
|
||||
.hero-card img {
|
||||
width: 100%;
|
||||
height: 155px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.hero-card span {
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.4px;
|
||||
font-size: 8px;
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.hero-star {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 57px;
|
||||
color: var(--lime);
|
||||
font-size: 65px;
|
||||
line-height: 1;
|
||||
z-index: 2;
|
||||
}
|
||||
@media (max-width: 1250px) {
|
||||
.hero-copy {
|
||||
padding: 28px;
|
||||
}
|
||||
.hero-copy h1 {
|
||||
font-size: 35px;
|
||||
}
|
||||
.hero-buttons {
|
||||
gap: 10px;
|
||||
}
|
||||
.hero-card {
|
||||
width: 125px;
|
||||
height: 164px;
|
||||
right: 18px;
|
||||
}
|
||||
.hero-card img {
|
||||
height: 131px;
|
||||
}
|
||||
.art-circle {
|
||||
left: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,218 +1,232 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { projects } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
import { NAV } from './nav';
|
||||
import { goto } from "$app/navigation";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { toStore } from "svelte/store";
|
||||
import { projects } from "$lib/api";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Modal from "$lib/components/ui/Modal.svelte";
|
||||
import type { IconName } from "$lib/components/ui/icons";
|
||||
import { NAV } from "./nav";
|
||||
|
||||
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
interface Entry {
|
||||
key: string;
|
||||
group: 'pages' | 'projects';
|
||||
label: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
}
|
||||
interface Entry {
|
||||
key: string;
|
||||
group: "pages" | "projects";
|
||||
label: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
|
||||
let query = $state('');
|
||||
let activeIndex = $state(0);
|
||||
let list = $state<HTMLDivElement | null>(null);
|
||||
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
|
||||
let query = $state("");
|
||||
let activeIndex = $state(0);
|
||||
let list = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const projectsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['projects'],
|
||||
queryFn: projects.list,
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
);
|
||||
const projectsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ["projects"],
|
||||
queryFn: projects.list,
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
);
|
||||
|
||||
const entries = $derived.by<Entry[]>(() => {
|
||||
const pages: Entry[] = NAV.map((item) => ({
|
||||
key: `page-${item.id}`,
|
||||
group: 'pages',
|
||||
label: t(item.labelKey),
|
||||
icon: item.icon,
|
||||
href: item.href,
|
||||
}));
|
||||
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
|
||||
key: `project-${p.id}`,
|
||||
group: 'projects',
|
||||
label: p.title,
|
||||
icon: 'story',
|
||||
href: `/workbench/${p.id}`,
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = [...pages, ...projectEntries];
|
||||
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
|
||||
});
|
||||
const entries = $derived.by<Entry[]>(() => {
|
||||
const pages: Entry[] = NAV.map((item) => ({
|
||||
key: `page-${item.href}`,
|
||||
group: "pages",
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
href: item.href,
|
||||
}));
|
||||
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
|
||||
key: `project-${p.id}`,
|
||||
group: "projects",
|
||||
label: p.title,
|
||||
icon: "story",
|
||||
href: `/workbench/${p.id}`,
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = [...pages, ...projectEntries];
|
||||
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void query;
|
||||
activeIndex = 0;
|
||||
});
|
||||
$effect(() => {
|
||||
void query;
|
||||
activeIndex = 0;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) query = '';
|
||||
});
|
||||
$effect(() => {
|
||||
if (!open) query = "";
|
||||
});
|
||||
|
||||
function optionId(index: number) {
|
||||
return `${listId}-${index}`;
|
||||
}
|
||||
function optionId(index: number) {
|
||||
return `${listId}-${index}`;
|
||||
}
|
||||
|
||||
function go(entry: Entry) {
|
||||
open = false;
|
||||
void goto(entry.href);
|
||||
}
|
||||
function go(entry: Entry) {
|
||||
open = false;
|
||||
void goto(entry.href);
|
||||
}
|
||||
|
||||
function move(step: number) {
|
||||
if (entries.length === 0) return;
|
||||
activeIndex = (activeIndex + step + entries.length) % entries.length;
|
||||
list?.querySelector(`#${optionId(activeIndex)}`)?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
function move(step: number) {
|
||||
if (entries.length === 0) return;
|
||||
activeIndex = (activeIndex + step + entries.length) % entries.length;
|
||||
list
|
||||
?.querySelector(`#${optionId(activeIndex)}`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
} else if (event.key === 'Enter') {
|
||||
const entry = entries[activeIndex];
|
||||
if (entry) {
|
||||
event.preventDefault();
|
||||
go(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
} else if (event.key === "Enter") {
|
||||
const entry = entries[activeIndex];
|
||||
if (entry) {
|
||||
event.preventDefault();
|
||||
go(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function focusOnMount(node: HTMLInputElement) {
|
||||
requestAnimationFrame(() => node.focus());
|
||||
}
|
||||
function focusOnMount(node: HTMLInputElement) {
|
||||
requestAnimationFrame(() => node.focus());
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title={t('shell.searchTitle')}>
|
||||
<div class="palette">
|
||||
<div class="palette-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input
|
||||
use:focusOnMount
|
||||
class="palette-input"
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder={t('shell.searchPlaceholder')}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={entries.length ? optionId(activeIndex) : undefined}
|
||||
aria-label={t('shell.searchTitle')}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
<div class="palette-list" id={listId} role="listbox" bind:this={list} aria-label={t('shell.searchTitle')}>
|
||||
{#if entries.length === 0}
|
||||
<p class="palette-empty">{t('shell.searchEmpty', { q: query.trim() })}</p>
|
||||
{/if}
|
||||
{#each entries as entry, i (entry.key)}
|
||||
{#if i === 0 || entries[i - 1].group !== entry.group}
|
||||
<div class="a-menu-group" role="presentation">
|
||||
{entry.group === 'pages' ? t('shell.searchGroupPages') : t('shell.searchGroupProjects')}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
id={optionId(i)}
|
||||
class="a-menu-item"
|
||||
class:active={i === activeIndex}
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
aria-selected={i === activeIndex}
|
||||
onpointermove={() => (activeIndex = i)}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => go(entry)}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<Icon name={entry.icon} size={15} />
|
||||
<span class="a-menu-label">{entry.label}</span>
|
||||
<span class="a-menu-meta mono">{entry.href}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="palette-hint">
|
||||
<span class="a-kbd">↑↓</span>{t('shell.searchHintMove')}
|
||||
<span class="a-kbd">Enter</span>{t('shell.searchHintOpen')}
|
||||
<span class="a-kbd">Esc</span>{t('shell.searchHintClose')}
|
||||
</p>
|
||||
</div>
|
||||
<Modal bind:open title={t("shell.searchTitle")}>
|
||||
<div class="palette">
|
||||
<div class="palette-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input
|
||||
use:focusOnMount
|
||||
class="palette-input"
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder={t("shell.searchPlaceholder")}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={entries.length
|
||||
? optionId(activeIndex)
|
||||
: undefined}
|
||||
aria-label={t("shell.searchTitle")}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="palette-list"
|
||||
id={listId}
|
||||
role="listbox"
|
||||
bind:this={list}
|
||||
aria-label={t("shell.searchTitle")}
|
||||
>
|
||||
{#if entries.length === 0}
|
||||
<p class="palette-empty">
|
||||
{t("shell.searchEmpty", { q: query.trim() })}
|
||||
</p>
|
||||
{/if}
|
||||
{#each entries as entry, i (entry.key)}
|
||||
{#if i === 0 || entries[i - 1].group !== entry.group}
|
||||
<div class="a-menu-group" role="presentation">
|
||||
{entry.group === "pages"
|
||||
? t("shell.searchGroupPages")
|
||||
: t("shell.searchGroupProjects")}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
id={optionId(i)}
|
||||
class="a-menu-item"
|
||||
class:active={i === activeIndex}
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
aria-selected={i === activeIndex}
|
||||
onpointermove={() => (activeIndex = i)}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => go(entry)}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<Icon name={entry.icon} size={15} />
|
||||
<span class="a-menu-label">{entry.label}</span>
|
||||
<span class="a-menu-meta mono">{entry.href}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="palette-hint">
|
||||
<span class="a-kbd">↑↓</span>{t("shell.searchHintMove")}
|
||||
<span class="a-kbd">Enter</span>{t("shell.searchHintOpen")}
|
||||
<span class="a-kbd">Esc</span>{t("shell.searchHintClose")}
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.palette {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.palette-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-5);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.palette-search:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.palette-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.palette-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-list {
|
||||
max-height: min(360px, 50vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.palette-empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.palette-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-hint .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-3);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.palette-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.palette {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.palette-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-5);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.palette-search:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.palette-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.palette-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-list {
|
||||
max-height: min(360px, 50vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.palette-empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.palette-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-hint .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-3);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.palette-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { THEMES, theme, setTheme, themeStorageUnavailable } from "$lib/theme";
|
||||
let { compact = false }: { compact?: boolean } = $props();
|
||||
function move(event: KeyboardEvent) {
|
||||
const steps: Record<string, number> = {
|
||||
ArrowRight: 1,
|
||||
ArrowDown: 1,
|
||||
ArrowLeft: -1,
|
||||
ArrowUp: -1,
|
||||
};
|
||||
const step = steps[event.key];
|
||||
if (!step && event.key !== "Home" && event.key !== "End") return;
|
||||
event.preventDefault();
|
||||
const group = (event.currentTarget as HTMLElement).closest(
|
||||
".choices",
|
||||
) as HTMLElement;
|
||||
const buttons = [...group.querySelectorAll<HTMLButtonElement>("button")];
|
||||
const current = buttons.indexOf(
|
||||
document.activeElement as HTMLButtonElement,
|
||||
);
|
||||
const next =
|
||||
event.key === "Home"
|
||||
? 0
|
||||
: event.key === "End"
|
||||
? buttons.length - 1
|
||||
: (current + step + buttons.length) % buttons.length;
|
||||
buttons[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="choices" class:compact role="group" aria-label="外观主题">
|
||||
{#each THEMES as item}
|
||||
<button
|
||||
class="choice"
|
||||
class:selected={$theme === item.id}
|
||||
aria-pressed={$theme === item.id}
|
||||
onkeydown={move}
|
||||
onclick={() => setTheme(item.id)}
|
||||
>
|
||||
<span class="preview" data-theme={item.id} aria-hidden="true"
|
||||
><span class="rail"></span><span class="list"
|
||||
><i></i><i></i><i></i></span
|
||||
><span class="stage"><i></i><b></b></span></span
|
||||
>
|
||||
<span class="copy"
|
||||
><strong>{item.name}</strong><small>{item.english}</small><span
|
||||
>{item.description}</span
|
||||
></span
|
||||
>
|
||||
<span class="check" aria-hidden="true"
|
||||
>{$theme === item.id ? "✓" : ""}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if $themeStorageUnavailable}<p class="notice" role="status">
|
||||
浏览器禁止本地存储:本次有效,其他操作不受影响
|
||||
</p>{/if}
|
||||
|
||||
<style>
|
||||
.choices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.choices.compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.choice {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 18px;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-2);
|
||||
color: var(--tx-1);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.choice:hover {
|
||||
border-color: var(--line-3);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.choice.selected {
|
||||
border-color: var(--gold);
|
||||
}
|
||||
.choice:focus-visible {
|
||||
outline: 2px solid var(--info);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.compact .choice {
|
||||
grid-template-columns: 76px minmax(0, 1fr) 18px;
|
||||
align-items: center;
|
||||
}
|
||||
.preview {
|
||||
grid-column: 1 / -1;
|
||||
height: 64px;
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 12px 22px 1fr;
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--line-2);
|
||||
}
|
||||
.compact .preview {
|
||||
grid-column: auto;
|
||||
height: 48px;
|
||||
}
|
||||
.rail,
|
||||
.list {
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
.rail::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
margin: 6px auto;
|
||||
border-radius: 50%;
|
||||
background: var(--gold);
|
||||
}
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 6px 3px;
|
||||
}
|
||||
.list i {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--line-3);
|
||||
}
|
||||
.list i:first-child {
|
||||
background: var(--sc-image);
|
||||
}
|
||||
.stage {
|
||||
padding: 5px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.stage i {
|
||||
flex: 1;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
}
|
||||
.stage b {
|
||||
height: 9px;
|
||||
width: 18px;
|
||||
align-self: end;
|
||||
border-radius: 2px;
|
||||
background: var(--grad-gold);
|
||||
}
|
||||
.copy {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.copy strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
.copy small {
|
||||
font: 10px var(--mono);
|
||||
color: var(--tx-3);
|
||||
align-self: center;
|
||||
}
|
||||
.copy > span {
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.check {
|
||||
color: var(--gold);
|
||||
}
|
||||
.notice {
|
||||
font-size: 12px;
|
||||
color: var(--tx-2);
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
@media (max-width: 1366px) {
|
||||
.choices:not(.compact) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,72 +1,130 @@
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
import type { IconName } from "$lib/components/ui/icons";
|
||||
|
||||
// IDs are retained for the existing page contracts while B1–B4 replace layouts.
|
||||
export type NavId =
|
||||
| 'projects'
|
||||
| 'workbench'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'tasks'
|
||||
| 'agents'
|
||||
| 'connections'
|
||||
| 'models'
|
||||
| 'library'
|
||||
| 'settings';
|
||||
|
||||
| "projects"
|
||||
| "workbench"
|
||||
| "image"
|
||||
| "video"
|
||||
| "tasks"
|
||||
| "agents"
|
||||
| "connections"
|
||||
| "models"
|
||||
| "library"
|
||||
| "settings"
|
||||
| "canvas"
|
||||
| "workflows";
|
||||
export interface NavItem {
|
||||
id: NavId;
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
id: NavId;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
scene: "project" | "image" | "agent" | "manage" | "asset";
|
||||
matches: (path: string) => boolean;
|
||||
}
|
||||
|
||||
// Order is fixed by spec-v2 §3: 项目 / 创作工作台 / 生图 / 生视频 / 生成任务 /
|
||||
// 智能体 / ComfyUI 连接 / 模型设置 / 设置. HEL-649 §二 puts the connection
|
||||
// and settings entries at the foot of the 60px rail.
|
||||
export const NAV_MAIN: NavItem[] = [
|
||||
{ id: 'projects', labelKey: 'shell.navProjects', icon: 'home', href: '/' },
|
||||
{ id: 'workbench', labelKey: 'shell.navWorkbench', icon: 'story', href: '/workbench' },
|
||||
{ id: 'image', labelKey: 'shell.navImage', icon: 'image', href: '/image' },
|
||||
{ id: 'video', labelKey: 'shell.navVideo', icon: 'film', href: '/video' },
|
||||
{ id: 'tasks', labelKey: 'shell.navTasks', icon: 'queue', href: '/tasks' },
|
||||
{ id: 'agents', labelKey: 'shell.navAgents', icon: 'sparkle', href: '/agents' },
|
||||
{
|
||||
id: "projects",
|
||||
label: "工作室",
|
||||
icon: "home",
|
||||
href: "/",
|
||||
scene: "project",
|
||||
matches: (p) => p === "/",
|
||||
},
|
||||
{
|
||||
id: "workbench",
|
||||
label: "项目",
|
||||
icon: "folder",
|
||||
href: "/workbench",
|
||||
scene: "project",
|
||||
matches: (p) => /^\/(workbench|project)(\/|$)/.test(p),
|
||||
},
|
||||
{
|
||||
id: "image",
|
||||
label: "创作台",
|
||||
icon: "image",
|
||||
href: "/create",
|
||||
scene: "image",
|
||||
matches: (p) =>
|
||||
["/create", "/image", "/video", "/playground", "/build-scene"].includes(
|
||||
p,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
label: "智能体",
|
||||
icon: "sparkle",
|
||||
href: "/agents",
|
||||
scene: "agent",
|
||||
matches: (p) => p === "/agents",
|
||||
},
|
||||
{
|
||||
id: "canvas",
|
||||
label: "画布",
|
||||
icon: "story",
|
||||
href: "/canvas",
|
||||
scene: "manage",
|
||||
matches: (p) => p.startsWith("/canvas"),
|
||||
},
|
||||
{
|
||||
id: "library",
|
||||
label: "素材库",
|
||||
icon: "assets",
|
||||
href: "/library",
|
||||
scene: "asset",
|
||||
matches: (p) => p === "/library",
|
||||
},
|
||||
];
|
||||
export const NAV_MANAGE: NavItem[] = [
|
||||
{
|
||||
id: "tasks",
|
||||
label: "任务",
|
||||
icon: "clock",
|
||||
href: "/tasks",
|
||||
scene: "manage",
|
||||
matches: (p) => p === "/tasks",
|
||||
},
|
||||
{
|
||||
id: "workflows",
|
||||
label: "工作流",
|
||||
icon: "playground",
|
||||
href: "/workflows",
|
||||
scene: "manage",
|
||||
matches: (p) => p === "/workflows",
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
label: "模型连接",
|
||||
icon: "link",
|
||||
href: "/model-connections",
|
||||
scene: "manage",
|
||||
matches: (p) =>
|
||||
["/connections", "/models", "/model-connections"].includes(p),
|
||||
},
|
||||
];
|
||||
|
||||
export const NAV_FOOT: NavItem[] = [
|
||||
{ id: 'connections', labelKey: 'shell.navConnections', icon: 'link', href: '/connections' },
|
||||
{ id: 'models', labelKey: 'shell.navModels', icon: 'assets', href: '/models' },
|
||||
{ id: 'library', labelKey: 'shell.navLibrary', icon: 'folder', href: '/library' },
|
||||
{ id: 'settings', labelKey: 'shell.navSettings', icon: 'settings', href: '/settings' },
|
||||
{
|
||||
id: "settings",
|
||||
label: "设置",
|
||||
icon: "settings",
|
||||
href: "/settings",
|
||||
scene: "manage",
|
||||
matches: (p) => p === "/settings",
|
||||
},
|
||||
];
|
||||
|
||||
export const NAV: NavItem[] = [...NAV_MAIN, ...NAV_FOOT];
|
||||
|
||||
export interface TabItem {
|
||||
id: 'projects' | 'workbench' | 'generate';
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
/** Pages that light this tab up. */
|
||||
covers: NavId[];
|
||||
}
|
||||
|
||||
/** ≤760px bottom tab bar (§二): 项目 / 创作 / 生成 / 更多. */
|
||||
export const TABS: TabItem[] = [
|
||||
{ id: 'projects', labelKey: 'shell.tabProjects', icon: 'home', href: '/', covers: ['projects'] },
|
||||
{ id: 'workbench', labelKey: 'shell.tabCreate', icon: 'story', href: '/workbench', covers: ['workbench'] },
|
||||
{
|
||||
id: 'generate',
|
||||
labelKey: 'shell.tabGenerate',
|
||||
icon: 'image',
|
||||
href: '/image',
|
||||
covers: ['image', 'video'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Every page a tab does not link to directly lives in the 更多 sheet. 生视频
|
||||
* is still its own route, so it is listed there too until the two
|
||||
* generation pages merge.
|
||||
*/
|
||||
export const MORE_ITEMS: NavItem[] = NAV.filter(
|
||||
(item) => !TABS.some((tab) => tab.href === item.href),
|
||||
);
|
||||
// Tools awaiting their replacement keep reachable entries in global search.
|
||||
export const NAV = [
|
||||
...NAV_MAIN,
|
||||
...NAV_MANAGE,
|
||||
...NAV_FOOT,
|
||||
{ id: "video", label: "视频创作", icon: "film", href: "/video" },
|
||||
{ id: "models", label: "大语言模型", icon: "assets", href: "/models" },
|
||||
{
|
||||
id: "connections",
|
||||
label: "ComfyUI 连接",
|
||||
icon: "link",
|
||||
href: "/connections",
|
||||
},
|
||||
{ id: "image", label: "试验台", icon: "playground", href: "/playground" },
|
||||
{ id: "video", label: "三维搭景", icon: "story", href: "/build-scene" },
|
||||
] as const;
|
||||
|
||||
@@ -1,855 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { page } from '$app/state';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import Composer from '$lib/components/generate/Composer.svelte';
|
||||
import ContextBar from '$lib/components/generate/ContextBar.svelte';
|
||||
import Inspector from '$lib/components/generate/Inspector.svelte';
|
||||
import ParamsPanel from '$lib/components/generate/ParamsPanel.svelte';
|
||||
import TakeFeed from '$lib/components/generate/TakeFeed.svelte';
|
||||
import { createTakeDeleteQueue } from '$lib/components/generate/takeDelete.svelte';
|
||||
import { readDeskContext } from '$lib/components/generate/context';
|
||||
import {
|
||||
changedParams,
|
||||
classifySchema,
|
||||
listPaths,
|
||||
missingMedia,
|
||||
schemaDefaults,
|
||||
validateForm,
|
||||
type BlockReason,
|
||||
type DeskKind,
|
||||
type FormValue,
|
||||
type FormValues,
|
||||
} from '$lib/components/generate/schema';
|
||||
import { matchesFilter, type FeedFilter } from '$lib/components/generate/takes';
|
||||
import { apiErrorMessage, assetUrl, comfy, jobsApi, playgroundApi, projects, settings, workflows } from '$lib/api';
|
||||
import { USAGE_BY_KIND, type Job, type Workflow } from '$lib/comfy/types';
|
||||
import { compactInputValues } from '$lib/comfy/promptInput';
|
||||
import { connectEvents } from '$lib/events';
|
||||
import { handleJobEvent } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 生成台 (spec-v2 §3.3, HEL-650 C 批): one desk for images and video — the
|
||||
* take feed with the composer below and the inspector on the right.
|
||||
* `/image` and `/video` only preselect: the default workflow comes from
|
||||
* that kind's usages and the feed opens filtered to it. Slots and params
|
||||
* follow the selected workflow's JSON (see generate/schema.ts).
|
||||
*/
|
||||
interface Props {
|
||||
kind: DeskKind;
|
||||
}
|
||||
|
||||
let { kind }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Data
|
||||
// ------------------------------------------------------------------
|
||||
const settingsQuery = createQuery({ queryKey: ['settings'], queryFn: settings.get });
|
||||
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.list });
|
||||
const jobsQuery = createQuery({
|
||||
queryKey: ['playground-jobs'],
|
||||
queryFn: playgroundApi.jobs,
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
|
||||
let ctxDismissed = $state(false);
|
||||
const ctx = $derived(ctxDismissed ? null : readDeskContext(page.url, kind));
|
||||
const projectsQuery = createQuery(
|
||||
toStore(() => ({ queryKey: ['projects'], queryFn: projects.list, enabled: !!ctx })),
|
||||
);
|
||||
const ctxProjectTitle = $derived(
|
||||
($projectsQuery.data ?? []).find((p) => p.id === ctx?.projectId)?.title ??
|
||||
t('attach.projectFallback', { id: ctx?.projectId ?? '' }),
|
||||
);
|
||||
|
||||
const endpoints = $derived($settingsQuery.data?.comfyui_endpoints ?? []);
|
||||
const activeId = $derived($settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? '');
|
||||
const dryRun = $derived($settingsQuery.data?.dry_run === true);
|
||||
|
||||
// HEL-636/638: only confirmed usages with a settled output mapping.
|
||||
const deskWorkflows = $derived(
|
||||
($workflowsQuery.data ?? []).filter((w) => w.is_enabled && (w.usage ?? null) !== null && !w.needs_mapping),
|
||||
);
|
||||
const workflowById = $derived(new Map(($workflowsQuery.data ?? []).map((w) => [w.id, w])));
|
||||
const workflowOf = (job: Job): Workflow | undefined =>
|
||||
job.workflow_id != null ? workflowById.get(job.workflow_id) : undefined;
|
||||
|
||||
const allJobs = $derived($jobsQuery.data ?? []);
|
||||
|
||||
// Jobs enqueued from this desk — a fast failure can land before the
|
||||
// jobs list refetch knows about them.
|
||||
const submittedIds = new Set<number>();
|
||||
|
||||
// D 批: one-shot completion flash / failure shake (SSE-driven) and the
|
||||
// 5-second undo window for record deletes.
|
||||
const flashIds = new SvelteSet<number>();
|
||||
const shakeIds = new SvelteSet<number>();
|
||||
const deleteQueue = createTakeDeleteQueue(client);
|
||||
const pendingIds = deleteQueue.pending;
|
||||
$effect(() => deleteQueue.destroy);
|
||||
|
||||
function flashTake(id: number, ms: number, set: SvelteSet<number>) {
|
||||
set.add(id);
|
||||
setTimeout(() => set.delete(id), ms);
|
||||
}
|
||||
|
||||
let feed = $state<TakeFeed | null>(null);
|
||||
let orderedIds = $state<number[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
const off = connectEvents((ev) => {
|
||||
handleJobEvent(ev);
|
||||
if (ev.type === 'job.failed' && !ev.data.cancelled) {
|
||||
const id = Number(ev.data.job_id);
|
||||
// The SSE backlog replays old failures on every connect.
|
||||
const known = allJobs.find((j) => j.id === id);
|
||||
if (submittedIds.has(id) || (known && known.status !== 'failed')) {
|
||||
toast.error(t('studio.jobFailedToast', { detail: String(ev.data.error ?? '') }));
|
||||
}
|
||||
flashTake(id, 400, shakeIds);
|
||||
}
|
||||
if (ev.type === 'job.completed') {
|
||||
const id = Number(ev.data.job_id);
|
||||
flashTake(id, 950, flashIds);
|
||||
// 完成:检视区若正选中该条且为视频,自动播放。
|
||||
if (selectedJobId === id) playSignal += 1;
|
||||
}
|
||||
if (ev.type === 'job.completed' && submittedIds.has(Number(ev.data.job_id)) && ev.data.dry_run) {
|
||||
toast.info(t('studio.dryRunToast'));
|
||||
}
|
||||
if (ev.type.startsWith('job.') || ev.type === 'events.resync') {
|
||||
void client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
void client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
}
|
||||
});
|
||||
return off;
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Form state
|
||||
// ------------------------------------------------------------------
|
||||
let endpointTouched = $state(false);
|
||||
let endpointId = $state('');
|
||||
let workflowId = $state<number | null>(null);
|
||||
let values = $state<FormValues>({});
|
||||
let showErrors = $state(false);
|
||||
let submitting = $state(false);
|
||||
let sent = $state(false);
|
||||
let slotBusy = $state(false);
|
||||
let promptEl = $state<HTMLTextAreaElement | null>(null);
|
||||
let pickerFor = $state<string | null>(null);
|
||||
|
||||
const currentWorkflow = $derived(deskWorkflows.find((w) => w.id === workflowId) ?? null);
|
||||
const schema = $derived(currentWorkflow?.input_schema ?? []);
|
||||
const ds = $derived(classifySchema(schema, currentWorkflow?.kind ?? kind));
|
||||
|
||||
// Default endpoint follows the workflow binding, then the active one —
|
||||
// unless the user picked one explicitly.
|
||||
$effect(() => {
|
||||
if (endpointTouched) return;
|
||||
endpointId = currentWorkflow?.endpoint_id ?? activeId ?? '';
|
||||
});
|
||||
|
||||
// Default workflow: this entry's usages in priority order, runnable ones first.
|
||||
$effect(() => {
|
||||
if (workflowId != null && deskWorkflows.some((w) => w.id === workflowId)) return;
|
||||
const usages = USAGE_BY_KIND[kind];
|
||||
const runnable = deskWorkflows.filter((w) => !(w.warnings ?? []).some((x) => x.level === 'error'));
|
||||
const pick =
|
||||
usages.map((u) => runnable.find((w) => w.usage === u)).find(Boolean) ??
|
||||
deskWorkflows.find((w) => usages.includes(w.usage!)) ??
|
||||
runnable[0] ??
|
||||
deskWorkflows[0];
|
||||
workflowId = pick?.id ?? null;
|
||||
});
|
||||
|
||||
/**
|
||||
* New workflow → its own defaults, keeping what the user already gave:
|
||||
* the prompt / negative prompt (by role) and uploaded images, poured
|
||||
* into the new workflow's image slots in order.
|
||||
*/
|
||||
function carryOver(prev: Workflow | null, prevValues: FormValues, next: Workflow): FormValues {
|
||||
const out = schemaDefaults(next.input_schema ?? []);
|
||||
const nextDs = classifySchema(next.input_schema ?? [], next.kind);
|
||||
if (!prev) return out;
|
||||
const prevDs = classifySchema(prev.input_schema ?? [], prev.kind);
|
||||
for (const [a, b] of [
|
||||
[prevDs.prompt, nextDs.prompt],
|
||||
[prevDs.negative, nextDs.negative],
|
||||
]) {
|
||||
const v = a ? prevValues[a.nodeId] : undefined;
|
||||
if (a && b && typeof v === 'string' && v.trim()) out[b.nodeId] = v;
|
||||
}
|
||||
const images: string[] = [];
|
||||
for (const s of prevDs.slots) {
|
||||
const v = prevValues[s.inp.nodeId];
|
||||
if (s.mediaKind === 'image' && !s.mask && typeof v === 'string' && v) images.push(v);
|
||||
}
|
||||
for (const g of prevDs.lists) if (g.kind === 'image_list') images.push(...listPaths(prevValues, g));
|
||||
for (const s of nextDs.slots) {
|
||||
if (s.mediaKind === 'image' && !s.mask && images.length) out[s.inp.nodeId] = images.shift()!;
|
||||
}
|
||||
const list = nextDs.lists.find((g) => g.kind === 'image_list');
|
||||
if (list && images.length) out[list.nodeId] = images.slice(0, list.max ?? 9);
|
||||
return out;
|
||||
}
|
||||
|
||||
let lastWf = $state<Workflow | null>(null);
|
||||
let ctxApplied = false;
|
||||
$effect(() => {
|
||||
const wf = currentWorkflow;
|
||||
untrack(() => {
|
||||
if (!wf) {
|
||||
if (lastWf) values = {};
|
||||
lastWf = null;
|
||||
return;
|
||||
}
|
||||
if (wf.id === lastWf?.id) return;
|
||||
let next = carryOver(lastWf, values, wf);
|
||||
if (!ctxApplied && ctx) {
|
||||
ctxApplied = true;
|
||||
next = applyContext(wf, next);
|
||||
}
|
||||
lastWf = wf;
|
||||
values = next;
|
||||
showErrors = false;
|
||||
});
|
||||
});
|
||||
|
||||
function applyContext(wf: Workflow, base: FormValues): FormValues {
|
||||
if (!ctx) return base;
|
||||
const d = classifySchema(wf.input_schema ?? [], wf.kind);
|
||||
const out = { ...base };
|
||||
if (d.prompt && ctx.prompt) out[d.prompt.nodeId] = ctx.prompt;
|
||||
if (d.negative && ctx.negative) out[d.negative.nodeId] = ctx.negative;
|
||||
if (d.duration && ctx.durationSec != null) {
|
||||
const opts = d.duration.options;
|
||||
const v = String(ctx.durationSec);
|
||||
if (!opts?.length || opts.includes(v)) out[d.duration.nodeId] = d.duration.kind === 'number' ? ctx.durationSec : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function setVal(nodeId: string, v: FormValue) {
|
||||
values = { ...values, [nodeId]: v };
|
||||
}
|
||||
|
||||
function restoreAll() {
|
||||
const defaults = schemaDefaults(schema);
|
||||
const next = { ...values };
|
||||
for (const inp of [...ds.common, ...ds.advanced]) {
|
||||
if (defaults[inp.nodeId] !== undefined) next[inp.nodeId] = defaults[inp.nodeId];
|
||||
else delete next[inp.nodeId];
|
||||
}
|
||||
values = next;
|
||||
}
|
||||
|
||||
const changedCount = $derived(changedParams(ds, values).length);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Connection & validation
|
||||
// ------------------------------------------------------------------
|
||||
const probeQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['comfy-probe', endpointId || null],
|
||||
queryFn: () => comfy.testEndpoint({ endpoint_id: endpointId, timeout_sec: 5 }),
|
||||
enabled: !!endpointId,
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 60_000,
|
||||
retry: false,
|
||||
})),
|
||||
);
|
||||
const connRec = $derived.by(() => {
|
||||
const data = $probeQuery.data;
|
||||
if (!endpointId || !data) return null;
|
||||
return { ok: data.ok, ms: data.elapsed_ms, at: $probeQuery.dataUpdatedAt };
|
||||
});
|
||||
const connState = $derived.by(() => {
|
||||
if (dryRun) return { state: 'dry' as const };
|
||||
if (connRec?.ok) return { state: 'ok' as const, ms: connRec.ms };
|
||||
if (connRec?.ok === false) return { state: 'fail' as const };
|
||||
return { state: 'untested' as const };
|
||||
});
|
||||
|
||||
function fmtClock(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const errors = $derived(validateForm(schema, ds, values));
|
||||
const brokenReasons = $derived(
|
||||
(currentWorkflow?.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message),
|
||||
);
|
||||
|
||||
/** The single reason 生成 is off, in the order a user can fix things. */
|
||||
const block = $derived.by<BlockReason | null>(() => {
|
||||
if (!currentWorkflow) return { text: t('desk.needWorkflow'), tone: 'warn', target: 'desk-wf' };
|
||||
if (!endpoints.length || !endpointId) return { text: t('studio.errNoConn'), tone: 'err', target: 'desk-conn' };
|
||||
if (connRec?.ok === false && !dryRun) {
|
||||
return { text: t('studio.errConnDown', { time: fmtClock(connRec.at) }), tone: 'err', target: 'desk-conn' };
|
||||
}
|
||||
if (brokenReasons.length) return { text: t('studio.errWfBroken'), tone: 'err', params: true };
|
||||
const missing = missingMedia(ds, values);
|
||||
const promptMissing = !!ds.prompt && !!errors[ds.prompt.nodeId] && !String(values[ds.prompt.nodeId] ?? '').trim();
|
||||
if (missing.length || promptMissing) {
|
||||
const labels = [...missing.map((m) => m.label), ...(promptMissing ? [t('studio.promptLabel')] : [])];
|
||||
return {
|
||||
text: t('desk.stillNeed', { what: labels.join('、') }),
|
||||
tone: 'warn',
|
||||
target: missing.length ? `slot-${missing[0].nodeId}` : 'desk-prompt',
|
||||
};
|
||||
}
|
||||
if (errors.firstLast) return { text: errors.firstLast, tone: 'warn', target: `slot-${ds.slots.find((s) => s.firstFrame)?.inp.nodeId}` };
|
||||
const firstErr = Object.entries(errors).find(([, m]) => m.trim());
|
||||
if (firstErr) {
|
||||
const [id, msg] = firstErr;
|
||||
if (ds.prompt && id === ds.prompt.nodeId) return { text: msg, tone: 'err', target: 'desk-prompt' };
|
||||
return { text: t('desk.paramError', { msg }), tone: 'err', params: true };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
async function reveal() {
|
||||
showErrors = true;
|
||||
const b = block;
|
||||
if (!b) return;
|
||||
if (b.params) {
|
||||
openParams();
|
||||
return;
|
||||
}
|
||||
await tick();
|
||||
const el = b.target ? document.getElementById(b.target) : null;
|
||||
if (el && !el.getClientRects().length) {
|
||||
// Collapsed narrow composer: focusing inside expands it first.
|
||||
promptEl?.focus();
|
||||
await tick();
|
||||
}
|
||||
el?.focus();
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Submit
|
||||
// ------------------------------------------------------------------
|
||||
let sentTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function submit() {
|
||||
if (submitting || slotBusy) return;
|
||||
if (block) {
|
||||
void reveal();
|
||||
return;
|
||||
}
|
||||
const wf = currentWorkflow!;
|
||||
submitting = true;
|
||||
try {
|
||||
const res = await playgroundApi.generate({
|
||||
workflow_id: wf.id,
|
||||
input_values: compactInputValues(values) as Record<string, unknown>,
|
||||
endpoint_id: endpointId || null,
|
||||
source: wf.kind === 'image' ? 'studio-image' : 'studio-video',
|
||||
});
|
||||
submittedIds.add(res.job.id);
|
||||
selectedJobId = res.job.id;
|
||||
if (!matchesFilter(res.job, filter)) filter = 'all';
|
||||
showErrors = false;
|
||||
sent = true;
|
||||
if (sentTimer) clearTimeout(sentTimer);
|
||||
sentTimer = setTimeout(() => (sent = false), 900);
|
||||
// C 批审核:从「参数」页提交后自动切到「结果」页,进行中进度可见。
|
||||
tab = 'result';
|
||||
if (wide) inspOff = false;
|
||||
else if (!narrow) inspOpen = true;
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(t('studio.submitFailedToast', { detail: apiErrorMessage(err) }));
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Feed, selection & inspector
|
||||
// ------------------------------------------------------------------
|
||||
let filter = $state<FeedFilter>(untrack(() => kind));
|
||||
let selectedJobId = $state<number | null>(null);
|
||||
let busyId = $state<number | null>(null);
|
||||
let tab = $state<'result' | 'params'>('result');
|
||||
let inspOff = $state(false);
|
||||
let inspOpen = $state(false);
|
||||
let wide = $state(true);
|
||||
let narrow = $state(false);
|
||||
let crossfade = $state(false);
|
||||
let playSignal = $state(0);
|
||||
let videoEl = $state<HTMLVideoElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const mq = window.matchMedia('(min-width: 1180px)');
|
||||
const mq2 = window.matchMedia('(max-width: 760px)');
|
||||
const sync = () => {
|
||||
wide = mq.matches;
|
||||
narrow = mq2.matches;
|
||||
};
|
||||
sync();
|
||||
mq.addEventListener('change', sync);
|
||||
mq2.addEventListener('change', sync);
|
||||
return () => {
|
||||
mq.removeEventListener('change', sync);
|
||||
mq2.removeEventListener('change', sync);
|
||||
};
|
||||
});
|
||||
|
||||
let nowMs = $state(Date.now());
|
||||
const anyActive = $derived(allJobs.some((j) => j.status === 'pending' || j.status === 'running'));
|
||||
$effect(() => {
|
||||
if (!anyActive) return;
|
||||
const id = setInterval(() => (nowMs = Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
/** Picked take, else the newest finished one in view (undo-pending rows don't count). */
|
||||
const selectedJob = $derived(
|
||||
allJobs.find((j) => j.id === selectedJobId && !pendingIds.has(j.id)) ??
|
||||
allJobs
|
||||
.filter((j) => matchesFilter(j, filter) && !pendingIds.has(j.id) && jobStatusInfo(j).tone === 'ok')
|
||||
.sort((a, b) => parseDbTs(b.created_at) - parseDbTs(a.created_at))[0] ??
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* 结果切换 (HEL-648): pointer picks slide the gold frame and cross-fade
|
||||
* the inspector; keyboard ↑↓/J/K switch instantly with no animation.
|
||||
*/
|
||||
function selectTake(job: Job, via: 'pointer' | 'keyboard' = 'pointer') {
|
||||
const from = via === 'pointer' ? (feed?.markRect() ?? null) : null;
|
||||
selectedJobId = job.id;
|
||||
tab = 'result';
|
||||
crossfade = via === 'pointer';
|
||||
if (via === 'pointer') {
|
||||
playSignal += 1;
|
||||
if (!wide) inspOpen = true;
|
||||
void tick().then(() => feed?.slideFrom(from));
|
||||
} else {
|
||||
feed?.scrollToJob(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
function moveSelection(delta: number) {
|
||||
if (!orderedIds.length) return;
|
||||
let i = orderedIds.indexOf(selectedJob?.id ?? -1);
|
||||
if (i < 0) i = orderedIds.length - 1;
|
||||
i = Math.max(0, Math.min(orderedIds.length - 1, i + delta));
|
||||
const job = allJobs.find((j) => j.id === orderedIds[i]);
|
||||
if (job) selectTake(job, 'keyboard');
|
||||
}
|
||||
|
||||
function openParams() {
|
||||
tab = 'params';
|
||||
if (wide) inspOff = false;
|
||||
else inspOpen = true;
|
||||
}
|
||||
|
||||
function toggleInspector() {
|
||||
if (wide) inspOff = !inspOff;
|
||||
else inspOpen = !inspOpen;
|
||||
}
|
||||
|
||||
function closeInspector() {
|
||||
if (wide) inspOff = true;
|
||||
else inspOpen = false;
|
||||
}
|
||||
|
||||
const imageSlotTarget = $derived(
|
||||
ds.slots.find((s) => s.mediaKind === 'image' && !s.mask && s.fromResults) ?? null,
|
||||
);
|
||||
const imageList = $derived(ds.lists.find((g) => g.kind === 'image_list') ?? null);
|
||||
|
||||
function sendToSlot(path: string) {
|
||||
if (imageSlotTarget && !String(values[imageSlotTarget.inp.nodeId] ?? '')) {
|
||||
setVal(imageSlotTarget.inp.nodeId, path);
|
||||
} else if (imageList) {
|
||||
const cur = listPaths(values, imageList);
|
||||
if (cur.length >= (imageList.max ?? 9)) {
|
||||
toast.error(t('studio.refFull', { max: imageList.max ?? 9 }));
|
||||
return;
|
||||
}
|
||||
setVal(imageList.nodeId, [...cur, path]);
|
||||
} else if (imageSlotTarget) {
|
||||
setVal(imageSlotTarget.inp.nodeId, path);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
toast.success(t('desk.sentToSlotToast'));
|
||||
}
|
||||
|
||||
/** Fill the composer back with a past take's values (on its own workflow). */
|
||||
function reuse(job: Job) {
|
||||
const wf = deskWorkflows.find((w) => w.id === job.workflow_id);
|
||||
if (!wf) {
|
||||
toast.error(t('studio.reuseMissingWf'));
|
||||
return;
|
||||
}
|
||||
const next = schemaDefaults(wf.input_schema ?? []);
|
||||
for (const [k, v] of Object.entries((job.payload?.input_values ?? {}) as Record<string, unknown>)) {
|
||||
if (typeof v === 'string' || typeof v === 'number') next[k] = v;
|
||||
else if (Array.isArray(v) && v.every((p) => typeof p === 'string')) next[k] = v as string[];
|
||||
}
|
||||
workflowId = wf.id;
|
||||
lastWf = wf;
|
||||
values = next;
|
||||
showErrors = false;
|
||||
toast.success(t('studio.reusedToast'));
|
||||
void tick().then(() => promptEl?.focus());
|
||||
}
|
||||
|
||||
async function retry(job: Job) {
|
||||
if (busyId != null) return;
|
||||
busyId = job.id;
|
||||
try {
|
||||
await jobsApi.retry(job.id);
|
||||
toast.success(t('studio.retryQueuedToast'));
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(t('studio.retryFailedToast', { detail: apiErrorMessage(err) }));
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(job: Job) {
|
||||
if (busyId != null) return;
|
||||
busyId = job.id;
|
||||
try {
|
||||
await jobsApi.cancel(job.id);
|
||||
toast.info(t('studio.cancelToast'));
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除整条任务 (§四.4 删除两级制): no dialog — the row collapses out,
|
||||
* a 5-second Toast offers 撤销 (row returns with its entrance and is
|
||||
* reselected), and only then does the DELETE really hit the backend.
|
||||
*/
|
||||
function requestDelete(job: Job) {
|
||||
if (job.status === 'pending' || job.status === 'running') return;
|
||||
const name = workflowOf(job)?.name ?? t('studio.jobName', { id: job.id });
|
||||
deleteQueue.request(job, {
|
||||
message: () =>
|
||||
(job.output_paths?.length ?? 0) > 0
|
||||
? t('studio.deletedTakeToast', { name })
|
||||
: t('studio.deletedTakeNoFilesToast', { name }),
|
||||
onUndo: (j) => {
|
||||
feed?.markEnter(j.id);
|
||||
const from = feed?.markRect() ?? null;
|
||||
selectedJobId = j.id;
|
||||
void tick().then(() => {
|
||||
feed?.slideFrom(from);
|
||||
feed?.scrollToJob(j.id);
|
||||
});
|
||||
},
|
||||
onCommitted: (j) => {
|
||||
submittedIds.delete(j.id);
|
||||
if (selectedJobId === j.id) selectedJobId = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 从结果选 (finished image takes → an image slot)
|
||||
// ------------------------------------------------------------------
|
||||
const imageResults = $derived(
|
||||
allJobs
|
||||
.filter((j) => j.kind === 'image' && jobStatusInfo(j).tone === 'ok')
|
||||
.flatMap((j) => j.output_paths.map((p) => ({ job: j, path: p }))),
|
||||
);
|
||||
|
||||
function pickResult(path: string) {
|
||||
const nodeId = pickerFor;
|
||||
pickerFor = null;
|
||||
if (!nodeId) return;
|
||||
const list = ds.lists.find((g) => g.nodeId === nodeId);
|
||||
if (list) {
|
||||
const cur = listPaths(values, list);
|
||||
if (cur.length >= (list.max ?? 9)) toast.error(t('studio.refFull', { max: list.max ?? 9 }));
|
||||
else setVal(nodeId, [...cur, path]);
|
||||
} else {
|
||||
setVal(nodeId, path);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦提示词 · ↑↓/J/K 切换记录
|
||||
// (即时无动画)· 空格播放/暂停 · Delete 删选中 · ] 检视区 · Esc 关浮层
|
||||
// ------------------------------------------------------------------
|
||||
function typing(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
|
||||
}
|
||||
|
||||
/** Focus sits on something that owns the key (button, link, video…). */
|
||||
function onInteractive(target: EventTarget | null): boolean {
|
||||
const el = target instanceof Element ? target : null;
|
||||
return !!el?.closest('button, a, input, textarea, select, label, video, audio, [contenteditable]');
|
||||
}
|
||||
|
||||
function inLayer(target: EventTarget | null): boolean {
|
||||
const el = target instanceof Element ? target : null;
|
||||
return !!el?.closest('.shell-pop, .modal, [role="dialog"]');
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented || pickerFor || inLayer(e.target)) return;
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === '/' && !typing(e.target)) {
|
||||
e.preventDefault();
|
||||
promptEl?.focus();
|
||||
} else if ((e.key === 'ArrowDown' || key === 'j') && !typing(e.target)) {
|
||||
e.preventDefault();
|
||||
moveSelection(1);
|
||||
} else if ((e.key === 'ArrowUp' || key === 'k') && !typing(e.target)) {
|
||||
e.preventDefault();
|
||||
moveSelection(-1);
|
||||
} else if (e.key === ' ') {
|
||||
// 空格播放/暂停:检视区可见且有视频时接管;焦点在控件上时走原生行为。
|
||||
if (typing(e.target) || onInteractive(e.target)) return;
|
||||
if (videoEl && (wide ? !inspOff : inspOpen)) {
|
||||
e.preventDefault();
|
||||
if (videoEl.paused) videoEl.play().catch(() => {});
|
||||
else videoEl.pause();
|
||||
}
|
||||
} else if (e.key === ']' && !typing(e.target)) {
|
||||
e.preventDefault();
|
||||
toggleInspector();
|
||||
} else if ((e.key === 'Delete' || e.key === 'Backspace') && !typing(e.target)) {
|
||||
const job = selectedJob;
|
||||
if (job && job.status !== 'pending' && job.status !== 'running') {
|
||||
e.preventDefault();
|
||||
requestDelete(job);
|
||||
}
|
||||
} else if (e.key === 'Escape' && !wide && inspOpen) {
|
||||
inspOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#snippet ctxBanner()}
|
||||
{#if ctx}
|
||||
<ContextBar {ctx} projectTitle={ctxProjectTitle} onDismiss={() => (ctxDismissed = true)} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet inspectorToggle()}
|
||||
{#if !wide || inspOff}
|
||||
<button type="button" class="a-btn ghost sm" aria-keyshortcuts="]" onclick={toggleInspector}>
|
||||
<Icon name="info" size={14} />
|
||||
{t('desk.openInspector')}
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet paramsTab()}
|
||||
<ParamsPanel
|
||||
workflow={currentWorkflow}
|
||||
{ds}
|
||||
{values}
|
||||
{errors}
|
||||
{showErrors}
|
||||
{changedCount}
|
||||
onSet={setVal}
|
||||
onRestoreAll={restoreAll}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<AppShell active={kind} title={t('desk.title')}>
|
||||
<div class="desk" class:insp-off={inspOff}>
|
||||
<section class="center" aria-label={t('desk.title')}>
|
||||
<TakeFeed
|
||||
bind:this={feed}
|
||||
jobs={allJobs}
|
||||
{workflowOf}
|
||||
bind:filter
|
||||
selectedId={selectedJob?.id ?? null}
|
||||
{nowMs}
|
||||
{busyId}
|
||||
loading={$jobsQuery.isLoading}
|
||||
{pendingIds}
|
||||
{flashIds}
|
||||
{shakeIds}
|
||||
bind:orderedIds
|
||||
banner={ctxBanner}
|
||||
{inspectorToggle}
|
||||
onSelect={(job) => selectTake(job, 'pointer')}
|
||||
onReuse={reuse}
|
||||
onRetry={retry}
|
||||
onCancel={cancel}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
{#if !$workflowsQuery.isLoading && !deskWorkflows.length}
|
||||
<div class="desk-empty" role="note">
|
||||
<Icon name="playground" size={16} />
|
||||
<span>{t('desk.noWorkflows')}</span>
|
||||
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
|
||||
</div>
|
||||
{/if}
|
||||
<Composer
|
||||
{ds}
|
||||
{values}
|
||||
workflows={deskWorkflows}
|
||||
current={currentWorkflow}
|
||||
{endpoints}
|
||||
{endpointId}
|
||||
conn={connState}
|
||||
{errors}
|
||||
{showErrors}
|
||||
{block}
|
||||
{changedCount}
|
||||
{submitting}
|
||||
{sent}
|
||||
bind:slotBusy
|
||||
bind:promptEl
|
||||
onSet={setVal}
|
||||
onPickWorkflow={(wf) => (workflowId = wf.id)}
|
||||
onPickEndpoint={(id) => {
|
||||
endpointTouched = true;
|
||||
endpointId = id;
|
||||
}}
|
||||
onSubmit={submit}
|
||||
onReveal={reveal}
|
||||
onOpenParams={openParams}
|
||||
onFromResults={(nodeId) => (pickerFor = nodeId)}
|
||||
/>
|
||||
</section>
|
||||
<Inspector
|
||||
bind:tab
|
||||
job={selectedJob}
|
||||
wf={selectedJob ? workflowOf(selectedJob) : undefined}
|
||||
{nowMs}
|
||||
open={inspOpen}
|
||||
inactive={wide ? inspOff : !inspOpen}
|
||||
canSendToSlot={!!imageSlotTarget || !!imageList}
|
||||
{crossfade}
|
||||
{playSignal}
|
||||
bind:videoEl
|
||||
params={paramsTab}
|
||||
onClose={closeInspector}
|
||||
onReuse={reuse}
|
||||
onRetry={retry}
|
||||
onSendToSlot={sendToSlot}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
<Modal open={pickerFor != null} title={t('studio.pickerTitle')} onclose={() => (pickerFor = null)}>
|
||||
{#if imageResults.length}
|
||||
<div class="picker-grid">
|
||||
{#each imageResults as entry (entry.path)}
|
||||
<button type="button" class="picker-item" onclick={() => pickResult(entry.path)}>
|
||||
<img src={assetUrl(entry.path)} alt={t('studio.pickerPick')} loading="lazy" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="picker-empty">{t('studio.pickerEmpty')}</p>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.desk {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 420px;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
transition: grid-template-columns var(--d-drawer) var(--ease-drawer);
|
||||
}
|
||||
.desk.insp-off {
|
||||
grid-template-columns: minmax(0, 1fr) 0px;
|
||||
}
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.desk-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
margin: 0 var(--sp-7) var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.picker-item {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.picker-item img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.picker-item:hover {
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
}
|
||||
.picker-empty {
|
||||
margin: 0;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.desk,
|
||||
.desk.insp-off {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.desk-empty {
|
||||
margin: 0 var(--sp-6) var(--sp-3);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.desk-empty {
|
||||
margin: 0 var(--sp-5) var(--sp-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import { enterModal, leaveModal, topModal } from './modalLayers';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +30,7 @@
|
||||
const titleId = `modal-title-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
let panel = $state<HTMLDivElement | null>(null);
|
||||
const layer = Symbol('dialog');
|
||||
|
||||
function requestClose() {
|
||||
if (!dismissible) return;
|
||||
@@ -36,6 +38,7 @@
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!topModal(layer)) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
@@ -75,20 +78,21 @@
|
||||
if (!open) return;
|
||||
|
||||
const invoker = document.activeElement as HTMLElement | null;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
enterModal(layer);
|
||||
document.addEventListener('keydown', handleKeydown, true);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!panel || panel.contains(document.activeElement)) return;
|
||||
if (!topModal(layer) || !panel || panel.contains(document.activeElement))
|
||||
return;
|
||||
const first = panel.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(first ?? panel).focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
const wasTop = topModal(layer);
|
||||
leaveModal(layer);
|
||||
document.removeEventListener('keydown', handleKeydown, true);
|
||||
if (invoker?.isConnected) invoker.focus();
|
||||
if (wasTop && invoker?.isConnected) invoker.focus();
|
||||
onclose?.();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ export const icons = {
|
||||
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
|
||||
// Status
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
alert:
|
||||
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Only the uppermost dialog handles the keyboard. Nested dialogs keep the
|
||||
// original body overflow until the final layer closes.
|
||||
const layers: symbol[] = [];
|
||||
let bodyOverflow = '';
|
||||
export function enterModal(token: symbol) {
|
||||
if (!layers.length) bodyOverflow = document.body.style.overflow;
|
||||
layers.push(token);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
export function topModal(token: symbol) {
|
||||
return layers.at(-1) === token;
|
||||
}
|
||||
export function leaveModal(token: symbol) {
|
||||
const index = layers.indexOf(token);
|
||||
if (index >= 0) layers.splice(index, 1);
|
||||
document.body.style.overflow = layers.length ? 'hidden' : bodyOverflow;
|
||||
}
|
||||
@@ -132,9 +132,7 @@ let {
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(139, 92, 246, 0.08), transparent 55%),
|
||||
#050508;
|
||||
background: var(--stage-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -79,14 +79,14 @@
|
||||
let criticNotes = $state<string[]>([]);
|
||||
|
||||
const preview = createMutation({
|
||||
mutationFn: async (vars?: { force?: boolean }) => {
|
||||
mutationFn: async (vars: { force?: boolean } | void) => {
|
||||
if (!clip && !scene) throw new Error(t('promptPreview.noClipSelected'));
|
||||
return jobsApi.previewPrompt(projectId, {
|
||||
clip_id: clip?.id,
|
||||
scene_id: clip ? undefined : scene?.id,
|
||||
workflow_id: workflow?.id,
|
||||
input_values: compactInputValues(inputValues),
|
||||
force: Boolean(vars?.force),
|
||||
force: Boolean((vars as { force?: boolean } | undefined)?.force),
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
|
||||
@@ -203,7 +203,7 @@ title={t('filmstrip.clipTitle', {
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
z-index: 1;
|
||||
background: #3f3f46;
|
||||
background: var(--bg-4);
|
||||
}
|
||||
|
||||
.clip.status-done .bar {
|
||||
|
||||
@@ -182,9 +182,11 @@
|
||||
kind: t(KIND_LABEL[asset.kind] ?? 'workbench.asset.kindProp'),
|
||||
name: asset.name,
|
||||
}),
|
||||
prompt: asset.prompt,
|
||||
negative: asset.negative_prompt || undefined,
|
||||
})}
|
||||
prompt: asset.prompt,
|
||||
negative: asset.negative_prompt || undefined,
|
||||
sourceKind: 'asset',
|
||||
sourceId: asset.id,
|
||||
})}
|
||||
title={t('desk.openInDeskHint')}
|
||||
>
|
||||
{t('desk.openInDesk')}
|
||||
|
||||
@@ -165,14 +165,16 @@
|
||||
{#if projectId}
|
||||
<a
|
||||
class="a-btn ghost sm"
|
||||
href={deskHref({
|
||||
kind: 'video',
|
||||
projectId,
|
||||
stage: 'shot',
|
||||
label: t('desk.ctxShot', { no: shot.shot_no }),
|
||||
prompt: shot.prompt,
|
||||
durationSec: shot.duration_sec,
|
||||
})}
|
||||
href={deskHref({
|
||||
kind: 'video',
|
||||
projectId,
|
||||
stage: 'shot',
|
||||
label: t('desk.ctxShot', { no: shot.shot_no }),
|
||||
prompt: shot.prompt,
|
||||
durationSec: shot.duration_sec,
|
||||
sourceKind: 'shot',
|
||||
sourceId: shot.id,
|
||||
})}
|
||||
title={t('desk.openInDeskHint')}
|
||||
>
|
||||
{t('desk.openInDesk')}
|
||||
|
||||
@@ -23,27 +23,14 @@
|
||||
return t(`workbench.stage.${key}`);
|
||||
}
|
||||
|
||||
function charOf(key: string): string {
|
||||
const map: Record<string, string> = {
|
||||
script: '剧',
|
||||
style: '视',
|
||||
storyboard: '分',
|
||||
assets: '素',
|
||||
shot: '镜',
|
||||
};
|
||||
return map[key] ?? key;
|
||||
}
|
||||
|
||||
function avatarVar(key: string): string {
|
||||
const idx = ORDER.indexOf(key) + 1;
|
||||
return `var(--avatar-${idx})`;
|
||||
}
|
||||
|
||||
function stateOf(stage: WorkbenchStage): 'todo' | 'running' | 'draft' | 'edited' | 'confirmed' {
|
||||
function stateOf(
|
||||
stage: WorkbenchStage,
|
||||
): 'todo' | 'running' | 'draft' | 'edited' | 'confirmed' {
|
||||
if (stage.status === 'running') return 'running';
|
||||
if (stage.status === 'confirmed' && !stage.draft_edited) return 'confirmed';
|
||||
if (stage.status === 'confirmed' && stage.draft_edited) return 'edited';
|
||||
if (stage.status === 'awaiting_confirm') return stage.draft_edited ? 'edited' : 'draft';
|
||||
if (stage.status === 'awaiting_confirm')
|
||||
return stage.draft_edited ? 'edited' : 'draft';
|
||||
return 'todo';
|
||||
}
|
||||
|
||||
@@ -70,7 +57,8 @@
|
||||
{@const state = stage ? stateOf(stage) : 'todo'}
|
||||
{@const open = stage ? unlocked(stages, key) : i === 0}
|
||||
<li class="step">
|
||||
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}></span>{/if}
|
||||
{#if i > 0}<span class="bar" class:bar-done={unlocked(stages, key)}
|
||||
></span>{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="step-btn"
|
||||
@@ -80,14 +68,19 @@
|
||||
data-state={state}
|
||||
onclick={() => onSelect(key)}
|
||||
aria-current={activeKey === key ? 'step' : undefined}
|
||||
title={open ? label(key) : t('workbench.lockedStep', { stage: label(ORDER[i - 1]) })}
|
||||
title={open
|
||||
? label(key)
|
||||
: t('workbench.lockedStep', { stage: label(ORDER[i - 1]) })}
|
||||
>
|
||||
<span class="avatar" style:background={avatarVar(key)}>{charOf(key)}</span>
|
||||
<span class="avatar"
|
||||
>{state === 'confirmed' ? '✓' : String(i + 1).padStart(2, '0')}</span
|
||||
>
|
||||
<span class="step-copy">
|
||||
<span class="step-name">{label(key)}</span>
|
||||
<span class="step-state st-{state}">
|
||||
{#if state === 'running'}<span class="breath" aria-hidden="true"></span>{/if}
|
||||
{stateLabel(stage!)}
|
||||
{#if state === 'running'}<span class="breath" aria-hidden="true"
|
||||
></span>{/if}
|
||||
{stage ? stateLabel(stage) : t('workbench.state.todo')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -238,4 +231,113 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 7px;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.step {
|
||||
display: block;
|
||||
}
|
||||
.bar {
|
||||
display: none;
|
||||
}
|
||||
.step-btn {
|
||||
width: 100%;
|
||||
padding: 12px 10px;
|
||||
border: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.avatar {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
background: none !important;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 50%;
|
||||
font: 9px var(--mono);
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
.step-btn.active {
|
||||
background: #e8eafe;
|
||||
box-shadow: none;
|
||||
}
|
||||
.step-btn.active .avatar {
|
||||
background: var(--accent) !important;
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.step-name {
|
||||
font-size: 11px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.step-state {
|
||||
font-size: 9px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.step-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
.step-btn.locked {
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
.step-btn[data-state='confirmed'] .avatar {
|
||||
border-color: var(--ok);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.step {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 130px;
|
||||
}
|
||||
.bar {
|
||||
display: none;
|
||||
}
|
||||
.step-btn {
|
||||
width: 100%;
|
||||
border: 1px solid #ffffff10;
|
||||
border-radius: 10px;
|
||||
background: #12171c;
|
||||
padding: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
.step-btn.active {
|
||||
background: #00cae012;
|
||||
border-color: #00cae080;
|
||||
box-shadow: none;
|
||||
}
|
||||
.step-btn.active .avatar {
|
||||
background: #00cae012 !important;
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.step-btn.active .step-name {
|
||||
color: #86e8f1;
|
||||
}
|
||||
.step-state {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.step-btn[data-state='confirmed'] .avatar {
|
||||
color: var(--ok);
|
||||
}
|
||||
.step-btn.locked {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.step-btn:hover:not(.active) {
|
||||
background: #ffffff07;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,908 @@
|
||||
<script lang="ts">
|
||||
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import {
|
||||
apiErrorMessage,
|
||||
workflows,
|
||||
type ComfyEndpoint,
|
||||
type WorkflowConvertFileReport,
|
||||
type WorkflowConvertReport,
|
||||
type WorkflowImportResult,
|
||||
type WorkflowUIFileInput,
|
||||
type WorkflowUsage,
|
||||
} from '$lib/api';
|
||||
import { WORKFLOW_USAGES } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
|
||||
/**
|
||||
* 批量导入向导 (HEL-682 / S4):扫描服务器目录或选择多个本地文件 →
|
||||
* dry-run 逐文件预检报告(ok / warn / fail / 需人工确认,含建议元数据)→
|
||||
* 确认后提交导入。warn 项可补充用途后以启用状态入库;fail 与需人工确认项
|
||||
* 不会被导入,也不得被标作可运行。重复导入按内容去重,全部跳过。
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
endpoints: ComfyEndpoint[];
|
||||
onimported?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), endpoints, onimported }: Props = $props();
|
||||
|
||||
type Step = 'source' | 'report' | 'result';
|
||||
|
||||
let step = $state<Step>('source');
|
||||
let mode = $state<'scan' | 'files'>('scan');
|
||||
let scanRoot = $state('example_ComfyUI_workflows');
|
||||
let preflightId = $state('');
|
||||
let inputFiles = $state<WorkflowUIFileInput[]>([]);
|
||||
let nonUiSkipped = $state<string[]>([]);
|
||||
let reading = $state(false);
|
||||
let running = $state(false);
|
||||
let importing = $state(false);
|
||||
let error = $state('');
|
||||
let report = $state<WorkflowConvertReport | null>(null);
|
||||
let result = $state<WorkflowImportResult | null>(null);
|
||||
let usageApplied = $state(0);
|
||||
/** Per-file usage chosen in the report step, keyed by name\0source_path. */
|
||||
let usageBy = $state<Record<string, WorkflowUsage>>({});
|
||||
/** Files the user un-checked (file-picker mode only). */
|
||||
let excludedKeys = $state<Set<string>>(new Set());
|
||||
let rSearch = $state('');
|
||||
let rFilter = $state<'all' | 'warn' | 'blocked'>('all');
|
||||
let expandedIssues = $state<Set<string>>(new Set());
|
||||
let visible = $state(60);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
function rk(f: { name: string; source_path: string | null }): string {
|
||||
return `${f.name}\u0000${f.source_path ?? ''}`;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
step = 'source';
|
||||
inputFiles = [];
|
||||
nonUiSkipped = [];
|
||||
report = null;
|
||||
result = null;
|
||||
usageApplied = 0;
|
||||
usageBy = {};
|
||||
excludedKeys = new Set();
|
||||
rSearch = '';
|
||||
rFilter = 'all';
|
||||
expandedIssues = new Set();
|
||||
visible = 60;
|
||||
error = '';
|
||||
}
|
||||
|
||||
function usageLabel(u: WorkflowUsage | null | undefined): string {
|
||||
return u ? t(`usage.${u}`) : t('usage.pending');
|
||||
}
|
||||
|
||||
function mediaIcon(kind: string): 'image' | 'film' | 'music' {
|
||||
if (kind === 'video') return 'film';
|
||||
if (kind === 'audio') return 'music';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function mediaTitle(kind: string): string {
|
||||
if (kind === 'video') return t('wl.mediaVideo');
|
||||
if (kind === 'audio') return t('wl.mediaAudio');
|
||||
return t('wl.mediaImage');
|
||||
}
|
||||
|
||||
async function readPickedFiles(list: FileList | null) {
|
||||
if (!list?.length) return;
|
||||
reading = true;
|
||||
error = '';
|
||||
try {
|
||||
const items: WorkflowUIFileInput[] = [];
|
||||
const skipped: string[] = [];
|
||||
for (const file of Array.from(list)) {
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
if (!(data && typeof data === 'object' && Array.isArray(data.nodes))) {
|
||||
skipped.push(file.name);
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
name: file.name.replace(/\.json$/i, ''),
|
||||
source_path: file.name,
|
||||
workflow_json: data,
|
||||
});
|
||||
} catch {
|
||||
skipped.push(file.name);
|
||||
}
|
||||
}
|
||||
inputFiles = items;
|
||||
nonUiSkipped = skipped;
|
||||
} finally {
|
||||
reading = false;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function runDryRun() {
|
||||
running = true;
|
||||
error = '';
|
||||
try {
|
||||
let res: WorkflowConvertReport;
|
||||
if (mode === 'scan') {
|
||||
res = await workflows.importScanDry(scanRoot.trim() || 'example_ComfyUI_workflows');
|
||||
inputFiles = [];
|
||||
nonUiSkipped = [];
|
||||
} else {
|
||||
if (inputFiles.length === 0) {
|
||||
error = t('wiz.noFiles');
|
||||
return;
|
||||
}
|
||||
res = await workflows.convert(
|
||||
inputFiles,
|
||||
preflightId ? { endpoint_id: preflightId } : undefined,
|
||||
);
|
||||
}
|
||||
report = res;
|
||||
usageBy = {};
|
||||
excludedKeys = new Set(res.files.filter((f) => !f.importable).map((f) => rk(f)));
|
||||
rSearch = '';
|
||||
rFilter = 'all';
|
||||
expandedIssues = new Set();
|
||||
visible = 60;
|
||||
step = 'report';
|
||||
} catch (err) {
|
||||
error = apiErrorMessage(err);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCount = $derived(
|
||||
report
|
||||
? mode === 'files'
|
||||
? report.files.filter((f) => f.importable && !excludedKeys.has(rk(f))).length
|
||||
: report.importable
|
||||
: 0,
|
||||
);
|
||||
const blockedCount = $derived(
|
||||
report ? report.files.filter((f) => !f.importable).length : 0,
|
||||
);
|
||||
|
||||
const filteredFiles = $derived.by(() => {
|
||||
if (!report) return [];
|
||||
const q = rSearch.trim().toLowerCase();
|
||||
return report.files.filter((f) => {
|
||||
if (rFilter === 'warn' && f.status !== 'warn') return false;
|
||||
if (rFilter === 'blocked' && f.importable) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
f.name.toLowerCase().includes(q) ||
|
||||
(f.source_path ?? '').toLowerCase().includes(q) ||
|
||||
(f.suggested?.model_family ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function needsUsagePick(f: WorkflowConvertFileReport): boolean {
|
||||
return Boolean(f.importable && (!f.suggested?.usage || f.suggested.usage_ambiguous));
|
||||
}
|
||||
|
||||
function setUsage(f: WorkflowConvertFileReport, usage: string) {
|
||||
const key = rk(f);
|
||||
if (usage) usageBy = { ...usageBy, [key]: usage as WorkflowUsage };
|
||||
else {
|
||||
const next = { ...usageBy };
|
||||
delete next[key];
|
||||
usageBy = next;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleIssue(f: WorkflowConvertFileReport) {
|
||||
const key = rk(f);
|
||||
const next = new Set(expandedIssues);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
expandedIssues = next;
|
||||
}
|
||||
|
||||
function skipText(reason: string | null | undefined): string {
|
||||
if (reason === 'conflict') return t('wiz.conflict');
|
||||
return reason ?? '';
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
if (!report) return;
|
||||
importing = true;
|
||||
error = '';
|
||||
try {
|
||||
let res: WorkflowImportResult;
|
||||
if (mode === 'files') {
|
||||
const files = inputFiles
|
||||
.map((item, i) => ({ item, f: report!.files[i] }))
|
||||
.filter(({ f }) => f?.importable && !excludedKeys.has(rk(f)))
|
||||
.map(({ item, f }) => {
|
||||
const usage = usageBy[rk(f)];
|
||||
return usage ? { ...item, usage } : item;
|
||||
});
|
||||
res = await workflows.importBatch(files);
|
||||
} else {
|
||||
res = await workflows.importScanCommit(scanRoot.trim() || 'example_ComfyUI_workflows');
|
||||
}
|
||||
result = res;
|
||||
// Directory commits import everything found server-side; usage
|
||||
// choices from the report step are applied to the fresh rows here.
|
||||
if (mode === 'scan') {
|
||||
let applied = 0;
|
||||
for (const f of res.files) {
|
||||
const usage = usageBy[rk(f)];
|
||||
if (f.result === 'imported' && f.id != null && usage) {
|
||||
try {
|
||||
await workflows.update(f.id, {
|
||||
usage,
|
||||
usage_source: 'user',
|
||||
is_enabled: true,
|
||||
});
|
||||
applied++;
|
||||
} catch {
|
||||
try {
|
||||
await workflows.update(f.id, { usage, usage_source: 'user' });
|
||||
applied++;
|
||||
} catch {
|
||||
/* keep the imported row, skip the override */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
usageApplied = applied;
|
||||
}
|
||||
step = 'result';
|
||||
onimported?.();
|
||||
if (res.imported > 0) toast.success(t('wiz.importedToast', { n: res.imported }));
|
||||
} catch (err) {
|
||||
error = apiErrorMessage(err);
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer bind:open title={t('wiz.title')} width={780} onclose={reset}>
|
||||
<ol class="wz-steps" aria-label={t('wiz.title')}>
|
||||
<li class:active={step === 'source'} class:done={step !== 'source'}>
|
||||
<span class="wz-step-n">1</span>{t('wiz.stepSource')}
|
||||
</li>
|
||||
<li class:active={step === 'report'} class:done={step === 'result'}>
|
||||
<span class="wz-step-n">2</span>{t('wiz.stepReport')}
|
||||
</li>
|
||||
<li class:active={step === 'result'}>
|
||||
<span class="wz-step-n">3</span>{t('wiz.stepResult')}
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
{#if error}
|
||||
<div class="wz-error" role="alert">
|
||||
<Icon name="alert" size={14} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if step === 'source'}
|
||||
<div class="seg wz-mode" role="radiogroup" aria-label={t('wiz.sourceLabel')}>
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:selected={mode === 'scan'}
|
||||
aria-pressed={mode === 'scan'}
|
||||
onclick={() => (mode = 'scan')}
|
||||
>
|
||||
<Icon name="folder" size={14} />
|
||||
{t('wiz.modeScan')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:selected={mode === 'files'}
|
||||
aria-pressed={mode === 'files'}
|
||||
onclick={() => (mode = 'files')}
|
||||
>
|
||||
<Icon name="upload" size={14} />
|
||||
{t('wiz.modeFiles')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if mode === 'scan'}
|
||||
<label class="wz-field">
|
||||
<span class="wz-label">{t('wiz.rootLabel')}</span>
|
||||
<input
|
||||
class="a-input wz-root"
|
||||
value={scanRoot}
|
||||
spellcheck="false"
|
||||
oninput={(e) => (scanRoot = e.currentTarget.value)}
|
||||
placeholder="example_ComfyUI_workflows"
|
||||
/>
|
||||
<span class="wz-hint">{t('wiz.rootHint')}</span>
|
||||
</label>
|
||||
{:else}
|
||||
<div class="wz-field">
|
||||
<span class="wz-label">{t('wiz.pickFiles')}</span>
|
||||
<button type="button" class="a-btn secondary" onclick={() => fileInput?.click()}>
|
||||
<Icon name="upload" size={14} />
|
||||
{t('wiz.pickFilesBtn')}
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
multiple
|
||||
class="wz-file-hidden"
|
||||
onchange={(e) => void readPickedFiles(e.currentTarget.files)}
|
||||
/>
|
||||
<span class="wz-hint">{t('wiz.pickFilesHint')}</span>
|
||||
{#if reading}
|
||||
<p class="wz-read">{t('common.loading')}…</p>
|
||||
{/if}
|
||||
{#if inputFiles.length}
|
||||
<p class="wz-read ok">
|
||||
<Icon name="check" size={13} />
|
||||
{t('wiz.parsed', { n: inputFiles.length })}
|
||||
</p>
|
||||
{/if}
|
||||
{#if nonUiSkipped.length}
|
||||
<p class="wz-read bad">
|
||||
<Icon name="alert" size={13} />
|
||||
{t('wiz.notUi', { n: nonUiSkipped.length })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'files' && endpoints.length > 0}
|
||||
<label class="wz-field">
|
||||
<span class="wz-label">{t('wiz.preflight')}</span>
|
||||
<select class="a-select wz-preflight" value={preflightId} onchange={(e) => (preflightId = e.currentTarget.value)}>
|
||||
<option value="">{t('wiz.preflightNone')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="wz-hint">{t('wiz.preflightHint')}</span>
|
||||
</label>
|
||||
{/if}
|
||||
{:else if step === 'report' && report}
|
||||
<div class="wz-summary">
|
||||
<span class="wz-chip">{t('wiz.total', { n: report.total })}</span>
|
||||
<span class="wz-chip ok">{t('wiz.ok', { n: report.ok })}</span>
|
||||
<span class="wz-chip warn">{t('wiz.warn', { n: report.warn })}</span>
|
||||
<span class="wz-chip fail">{t('wiz.fail', { n: report.fail })}</span>
|
||||
<span class="wz-chip confirm">{t('wiz.confirmCount', { n: report.needs_confirmation })}</span>
|
||||
<span class="wz-chip importable">{t('wiz.importable', { n: report.importable })}</span>
|
||||
</div>
|
||||
<p class="wz-note">{t('wiz.reportHint')}</p>
|
||||
|
||||
<div class="wz-toolbar">
|
||||
<div class="filters" role="group" aria-label={t('wiz.filterLabel')}>
|
||||
<button type="button" class="a-chip" class:sel={rFilter === 'all'} aria-pressed={rFilter === 'all'} onclick={() => (rFilter = 'all')}>
|
||||
{t('wl.filterAll')}
|
||||
</button>
|
||||
<button type="button" class="a-chip" class:sel={rFilter === 'warn'} aria-pressed={rFilter === 'warn'} onclick={() => (rFilter = 'warn')}>
|
||||
{t('wiz.filterWarn')}
|
||||
</button>
|
||||
<button type="button" class="a-chip" class:sel={rFilter === 'blocked'} aria-pressed={rFilter === 'blocked'} onclick={() => (rFilter = 'blocked')}>
|
||||
{t('wiz.filterBlocked')}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
class="a-input wz-search"
|
||||
value={rSearch}
|
||||
oninput={(e) => (rSearch = e.currentTarget.value)}
|
||||
placeholder={t('wiz.searchPh')}
|
||||
aria-label={t('wiz.searchPh')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="wz-list">
|
||||
{#each filteredFiles.slice(0, visible) as f (rk(f))}
|
||||
<div class="wz-row" class:blocked={!f.importable}>
|
||||
{#if mode === 'files'}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="wz-check"
|
||||
aria-label={t('wiz.includeAria', { name: f.name })}
|
||||
disabled={!f.importable}
|
||||
checked={f.importable && !excludedKeys.has(rk(f))}
|
||||
onchange={(e) => {
|
||||
const next = new Set(excludedKeys);
|
||||
if (e.currentTarget.checked) next.delete(rk(f));
|
||||
else next.add(rk(f));
|
||||
excludedKeys = next;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<span class="wz-dot {f.status}" title={f.status}></span>
|
||||
<div class="wz-main">
|
||||
<div class="wz-line">
|
||||
<span class="wz-name" title={f.suggested?.description ?? f.name}>{f.name}</span>
|
||||
{#if f.suggested?.model_family}
|
||||
<span class="tag tag-usage">{f.suggested.model_family}</span>
|
||||
{/if}
|
||||
{#if f.suggested}
|
||||
<span class="tag tag-usage">{usageLabel(f.suggested.usage)}</span>
|
||||
<span class="wz-media" aria-hidden="true">
|
||||
{#each f.suggested.media_in as m (m)}
|
||||
<span class="wz-m" title={mediaTitle(m)}><Icon name={mediaIcon(m)} size={12} /></span>
|
||||
{/each}
|
||||
{#if f.suggested.media_out.length}
|
||||
<span class="wz-arrow">→</span>
|
||||
{#each f.suggested.media_out as m (m)}
|
||||
<span class="wz-m" title={mediaTitle(m)}><Icon name={mediaIcon(m)} size={12} /></span>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if f.status === 'warn'}
|
||||
<span class="tag tag-warn">{t('wiz.warnTag')}</span>
|
||||
{/if}
|
||||
{#if f.needs_confirmation}
|
||||
<span class="tag tag-error">{t('wiz.confirmTag')}</span>
|
||||
{/if}
|
||||
{#if f.preflight && !f.preflight.ok}
|
||||
<span class="tag tag-error" title={f.preflight.findings.map((x) => x.message).join('\n')}>
|
||||
{t('wiz.preflightTag')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if f.source_path}
|
||||
<div class="wz-path mono">{f.source_path}</div>
|
||||
{/if}
|
||||
{#if !f.importable && f.skip_reason}
|
||||
<div class="wz-skip">{t('wiz.skipReason', { reason: skipText(f.skip_reason) })}</div>
|
||||
{/if}
|
||||
{#if needsUsagePick(f)}
|
||||
<label class="wz-usage">
|
||||
<span>{t('wiz.usagePick')}</span>
|
||||
<select
|
||||
class="a-select wz-usage-select"
|
||||
value={usageBy[rk(f)] ?? ''}
|
||||
onchange={(e) => setUsage(f, e.currentTarget.value)}
|
||||
>
|
||||
<option value="">{t('wiz.usageNone')}</option>
|
||||
{#each WORKFLOW_USAGES as u (u)}
|
||||
<option value={u}>{usageLabel(u)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if f.issues.length}
|
||||
<button type="button" class="wz-issues-toggle" onclick={() => toggleIssue(f)} aria-expanded={expandedIssues.has(rk(f))}>
|
||||
<Icon name="alert" size={12} />
|
||||
{t('wiz.issues', { n: f.issues.length })}
|
||||
</button>
|
||||
{#if expandedIssues.has(rk(f))}
|
||||
<ul class="wz-issues">
|
||||
{#each f.issues as iss, i (`${rk(f)}#${i}`)}
|
||||
<li class="lvl-{iss.level}">
|
||||
<span class="mono">{iss.node_id}</span>
|
||||
{iss.message}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if filteredFiles.length > visible}
|
||||
<button type="button" class="a-btn ghost wz-more" onclick={() => (visible += 100)}>
|
||||
{t('wl.moreCount', { n: Math.min(100, filteredFiles.length - visible), m: filteredFiles.length })}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if step === 'result' && result}
|
||||
<div class="wz-summary">
|
||||
<span class="wz-chip ok">{t('wiz.imported', { n: result.imported })}</span>
|
||||
<span class="wz-chip">{t('wiz.skipped', { n: result.skipped })}</span>
|
||||
{#if result.files.some((f) => f.result === 'failed')}
|
||||
<span class="wz-chip fail">{t('wiz.fail', { n: result.files.filter((f) => f.result === 'failed').length })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="wz-note">{t('wiz.resultNote')}</p>
|
||||
{#if usageApplied > 0}
|
||||
<p class="wz-note ok">{t('wiz.usageApplied', { n: usageApplied })}</p>
|
||||
{/if}
|
||||
<div class="wz-list">
|
||||
{#each result.files as f (rk(f))}
|
||||
<div class="wz-row">
|
||||
<span class="wz-dot {f.result === 'imported' ? 'ok' : f.result === 'failed' ? 'fail' : 'warn'}"></span>
|
||||
<div class="wz-main">
|
||||
<div class="wz-line">
|
||||
<span class="wz-name">{f.name}</span>
|
||||
{#if f.result === 'imported'}
|
||||
<span class="tag tag-ok">
|
||||
<Icon name="check" size={12} />
|
||||
{t('wiz.importedTag', { id: f.id ?? '?' })}
|
||||
</span>
|
||||
{:else if f.skip_reason === 'conflict' || f.result === 'skipped'}
|
||||
<span class="tag tag-usage">{skipText(f.skip_reason)}</span>
|
||||
{:else}
|
||||
<span class="tag tag-error">{skipText(f.skip_reason)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if f.source_path}
|
||||
<div class="wz-path mono">{f.source_path}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet footer()}
|
||||
{#if step === 'source'}
|
||||
<Button variant="secondary" onclick={() => (open = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={running} disabled={reading} onclick={() => void runDryRun()}>
|
||||
{running ? t('wiz.running') : t('wiz.runDry')}
|
||||
</Button>
|
||||
{:else if step === 'report'}
|
||||
<span class="wz-footer-note">
|
||||
{t('wiz.selectedN', { n: selectedCount })}
|
||||
{#if blockedCount}· {t('wiz.excludedM', { m: blockedCount })}{/if}
|
||||
</span>
|
||||
<Button variant="secondary" disabled={importing} onclick={() => (step = 'source')}>{t('wiz.back')}</Button>
|
||||
<Button variant="primary" loading={importing} disabled={selectedCount === 0} onclick={() => void runImport()}>
|
||||
{importing ? t('wiz.importing') : t('wiz.import')}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="secondary" onclick={() => { reset(); step = 'source'; }}>{t('wiz.again')}</Button>
|
||||
<Button variant="primary" onclick={() => (open = false)}>{t('wiz.done')}</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Drawer>
|
||||
|
||||
<style>
|
||||
.wz-steps {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.wz-steps li {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wz-steps li.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wz-steps li.done {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wz-step-n {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid currentColor;
|
||||
font-size: 10.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wz-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 7px;
|
||||
background: var(--error-dim);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wz-error :global(.icon) {
|
||||
color: var(--error);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.wz-mode {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.seg-item {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
text-align: center;
|
||||
padding: 8px 6px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.seg-item.selected {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wz-field {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.wz-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.wz-root,
|
||||
.wz-preflight,
|
||||
.wz-search {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wz-hint {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wz-file-hidden {
|
||||
display: none;
|
||||
}
|
||||
.wz-read {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wz-read.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
.wz-read.bad {
|
||||
color: var(--warn);
|
||||
}
|
||||
.wz-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.wz-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.wz-chip.ok,
|
||||
.wz-chip.importable {
|
||||
color: var(--success);
|
||||
background: var(--success-dim);
|
||||
}
|
||||
.wz-chip.warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-dim, var(--bg-4));
|
||||
}
|
||||
.wz-chip.fail,
|
||||
.wz-chip.confirm {
|
||||
color: var(--error);
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.wz-note {
|
||||
margin: 0 0 12px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wz-note.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
.wz-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wz-toolbar .filters {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.wz-search {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
.wz-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wz-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.wz-row:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.wz-row.blocked .wz-name {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wz-check {
|
||||
margin-top: 3px;
|
||||
accent-color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wz-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-top: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wz-dot.ok {
|
||||
background: var(--success);
|
||||
}
|
||||
.wz-dot.warn {
|
||||
background: var(--warn);
|
||||
}
|
||||
.wz-dot.fail {
|
||||
background: var(--error);
|
||||
}
|
||||
.wz-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wz-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wz-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tag-usage {
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.tag-warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-dim, var(--bg-4));
|
||||
}
|
||||
.tag-error {
|
||||
color: var(--error);
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.tag-ok {
|
||||
color: var(--success);
|
||||
background: var(--success-dim);
|
||||
}
|
||||
.wz-media {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wz-m {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.wz-arrow {
|
||||
font-size: 10px;
|
||||
}
|
||||
.wz-path {
|
||||
margin-top: 2px;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
word-break: break-all;
|
||||
}
|
||||
.wz-skip {
|
||||
margin-top: 4px;
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wz-usage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wz-usage-select {
|
||||
width: 180px;
|
||||
}
|
||||
.wz-issues-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 5px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
color: var(--warn);
|
||||
cursor: pointer;
|
||||
}
|
||||
.wz-issues-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.wz-issues {
|
||||
margin: 6px 0 0;
|
||||
padding: 8px 10px;
|
||||
list-style: none;
|
||||
border-radius: 7px;
|
||||
background: var(--surface-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wz-issues li {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wz-issues li .mono {
|
||||
color: var(--text-3);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.wz-issues li.lvl-error {
|
||||
color: var(--error);
|
||||
}
|
||||
.wz-more {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.wz-footer-note {
|
||||
margin-right: auto;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { apiErrorMessage, workflows, type WorkflowMeta } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
|
||||
/**
|
||||
* 工作流标注 (HEL-682 / S4):单条编辑显示名/说明/标签/分类/模型家族;
|
||||
* 批量模式对选中项做查找替换重命名、统一说明、追加标签与分类标注。
|
||||
* 只有填写了的字段会被提交,空字段一律不动。
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
/** Rows to update — length 1 = single edit (prefilled), >1 = batch. */
|
||||
targets: WorkflowMeta[];
|
||||
/** Known category keys / family names for the datalists. */
|
||||
categories: string[];
|
||||
families: string[];
|
||||
onchanged?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), targets, categories, families, onchanged }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
let name = $state('');
|
||||
let nameFind = $state('');
|
||||
let nameReplace = $state('');
|
||||
let description = $state('');
|
||||
let tagsText = $state('');
|
||||
let category = $state('');
|
||||
let modelFamily = $state('');
|
||||
let applying = $state(false);
|
||||
|
||||
const isBatch = $derived(targets.length > 1);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
const first = targets[0];
|
||||
name = targets.length === 1 ? (first?.name ?? '') : '';
|
||||
nameFind = '';
|
||||
nameReplace = '';
|
||||
description = targets.length === 1 ? (first?.description ?? '') : '';
|
||||
tagsText = targets.length === 1 ? (first?.tags?.join(', ') ?? '') : '';
|
||||
category = targets.length === 1 ? (first?.category ?? '') : '';
|
||||
modelFamily = targets.length === 1 ? (first?.model_family ?? '') : '';
|
||||
}
|
||||
});
|
||||
|
||||
function parseTags(text: string): string[] {
|
||||
return text
|
||||
.split(/[,,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function payloadFor(row: WorkflowMeta): Record<string, unknown> | null {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (isBatch) {
|
||||
if (nameFind && row.name.includes(nameFind)) {
|
||||
payload.name = row.name.split(nameFind).join(nameReplace);
|
||||
}
|
||||
if (description.trim()) payload.description = description.trim();
|
||||
if (tagsText.trim()) {
|
||||
const add = parseTags(tagsText);
|
||||
const merged = [...row.tags, ...add.filter((x) => !row.tags.includes(x))];
|
||||
if (merged.length !== row.tags.length) payload.tags = merged;
|
||||
}
|
||||
if (category.trim()) payload.category = category.trim();
|
||||
if (modelFamily.trim()) payload.model_family = modelFamily.trim();
|
||||
} else {
|
||||
if (name.trim() && name.trim() !== row.name) payload.name = name.trim();
|
||||
if (description !== (row.description ?? '')) payload.description = description.trim();
|
||||
const nextTags = parseTags(tagsText);
|
||||
if (JSON.stringify(nextTags) !== JSON.stringify(row.tags ?? [])) payload.tags = nextTags;
|
||||
if (category.trim() !== (row.category ?? '')) payload.category = category.trim();
|
||||
if (modelFamily.trim() !== (row.model_family ?? '')) payload.model_family = modelFamily.trim();
|
||||
}
|
||||
return Object.keys(payload).length ? payload : null;
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
applying = true;
|
||||
try {
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
for (const row of targets) {
|
||||
const payload = payloadFor(row);
|
||||
if (!payload) {
|
||||
done++; // nothing to change counts as OK (single mode no-op)
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await workflows.update(row.id, payload);
|
||||
done++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
if (failed === 0) {
|
||||
toast.success(isBatch ? t('md.applied', { n: done }) : t('md.appliedOne'));
|
||||
} else {
|
||||
toast.error(t('md.partial', { n: done, m: failed }));
|
||||
}
|
||||
open = false;
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
onchanged?.();
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
applying = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title={isBatch ? t('md.batchTitle', { n: targets.length }) : t('md.title')} size="md">
|
||||
<div class="md-form">
|
||||
{#if isBatch}
|
||||
<p class="md-hint">
|
||||
<Icon name="info" size={14} />
|
||||
{t('md.batchHint')}
|
||||
</p>
|
||||
<div class="md-grid2">
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.nameFind')}</span>
|
||||
<input class="a-input" value={nameFind} oninput={(e) => (nameFind = e.currentTarget.value)} placeholder={t('md.nameFindPh')} />
|
||||
</label>
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.nameReplace')}</span>
|
||||
<input class="a-input" value={nameReplace} oninput={(e) => (nameReplace = e.currentTarget.value)} placeholder={t('md.nameReplacePh')} />
|
||||
</label>
|
||||
</div>
|
||||
<p class="md-sub">{t('md.nameHint')}</p>
|
||||
{:else}
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.name')}</span>
|
||||
<input class="a-input" value={name} oninput={(e) => (name = e.currentTarget.value)} />
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.description')}</span>
|
||||
<textarea
|
||||
class="a-textarea md-desc"
|
||||
rows="2"
|
||||
value={description}
|
||||
oninput={(e) => (description = e.currentTarget.value)}
|
||||
placeholder={t('md.descriptionPh')}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.tags')}</span>
|
||||
<input
|
||||
class="a-input"
|
||||
value={tagsText}
|
||||
oninput={(e) => (tagsText = e.currentTarget.value)}
|
||||
placeholder={t('md.tagsPh')}
|
||||
/>
|
||||
<span class="md-sub">{t('md.tagsHint')}</span>
|
||||
</label>
|
||||
|
||||
<div class="md-grid2">
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.category')}</span>
|
||||
<input
|
||||
class="a-input"
|
||||
value={category}
|
||||
list="md-cat-list"
|
||||
oninput={(e) => (category = e.currentTarget.value)}
|
||||
/>
|
||||
<datalist id="md-cat-list">
|
||||
{#each categories as c (c)}
|
||||
<option value={c}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</label>
|
||||
<label class="md-field">
|
||||
<span class="md-label">{t('md.modelFamily')}</span>
|
||||
<input
|
||||
class="a-input"
|
||||
value={modelFamily}
|
||||
list="md-family-list"
|
||||
oninput={(e) => (modelFamily = e.currentTarget.value)}
|
||||
/>
|
||||
<datalist id="md-family-list">
|
||||
{#each families as f (f)}
|
||||
<option value={f}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<Button variant="secondary" disabled={applying} onclick={() => (open = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={applying} onclick={() => void apply()}>{t('md.apply')}</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.md-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.md-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 9px 12px;
|
||||
border-radius: 7px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.md-hint :global(.icon) {
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.md-grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.md-field {
|
||||
display: block;
|
||||
}
|
||||
.md-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.md-desc {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
.md-sub {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.md-grid2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1558,6 +1558,7 @@ export const de: Dict = {
|
||||
'studio.slotMask': 'Maske',
|
||||
'studio.slotFirst': 'Erster Frame',
|
||||
'studio.slotLast': 'Letzter Frame',
|
||||
'studio.slotOptional': 'Optional',
|
||||
'studio.slotRefVideo': 'Referenzvideo',
|
||||
'studio.slotAudio': 'Referenzaudio',
|
||||
'studio.slotPick': 'Klicken oder Datei ziehen',
|
||||
@@ -1901,7 +1902,7 @@ export const de: Dict = {
|
||||
'home.stageProgress': 'Fünf-Phasen-Fortschritt',
|
||||
'tasks.sourceWorkbench': 'Kreativ-Fluss',
|
||||
'jobst.idSuffix': ' · ComfyUI-Prompt-ID {id}',
|
||||
'jobst.done': 'Fertig',
|
||||
'jobst.done': 'Fixiert',
|
||||
'jobst.doneDetail': 'ComfyUI ist fertig, Ergebnisse gespeichert',
|
||||
'jobst.dryRun': 'Platzhalter',
|
||||
'jobst.dryRunDetail': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
|
||||
@@ -2002,6 +2003,15 @@ export const de: Dict = {
|
||||
'desk.ctxDismiss': 'Herkunftshinweis schließen',
|
||||
'desk.ctxFrom': 'Aus „{project}“',
|
||||
'desk.ctxNoBackfill': 'Ergebnisse bleiben im Verlauf und werden nicht in {stage} übernommen',
|
||||
'desk.ctxBackfill': 'Nach Fertigstellung kann jedes Ergebnis über „Zurück ins Projekt“ an {stage} zurückgegeben werden',
|
||||
'desk.attachToProject': 'Zurück ins Projekt',
|
||||
'desk.attachHint': 'Dieses Ergebnis zurückgeben an: {label}',
|
||||
'desk.attaching': 'Wird zurückgegeben…',
|
||||
'desk.attachedTag': 'Zurückgegeben',
|
||||
'desk.attachBack': 'Zum Projekt',
|
||||
'desk.attachDoneToast': 'Zurück ins Quellprojekt übergeben — im Projekt sichtbar',
|
||||
'desk.attachAlreadyToast': 'Dieses Ergebnis wurde bereits zurückgegeben, nichts dupliziert',
|
||||
'desk.attachFailedToast': 'Rückgabe fehlgeschlagen: {detail}',
|
||||
'desk.dayDate': '{d}.{m}.',
|
||||
'desk.download': 'Herunterladen',
|
||||
'desk.dropNoSlot': '{n} Datei(en) ohne passenden Slot: dieser Workflow hat keinen solchen Eingang',
|
||||
@@ -2080,6 +2090,36 @@ export const de: Dict = {
|
||||
'desk.wfUntested': 'Nicht getestet',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Gestern',
|
||||
// --- Dreispaltige Desk (HEL-683) ---
|
||||
'desk.bandChange': "Workflow wechseln",
|
||||
'desk.bayCloudBody': "Cloud-Modelle für Bild / Video (gpt-image2, nano-banana, Jimeng, Kling, …) sind noch nicht angebunden. Nach der Anbindung erscheinen sie mit ihren echten Fähigkeiten — nie als unbenutzbare Einträge.",
|
||||
'desk.bayCloudTitle': "Cloud-Generierung nicht angebunden",
|
||||
'desk.bayCount': "{n} / {total} Workflows",
|
||||
'desk.bayFavOnly': "Favoriten",
|
||||
'desk.bayMore': "{n} weitere anzeigen",
|
||||
'desk.bayNoFamily': "Modell unbekannt",
|
||||
'desk.bayPending': "{n} offen (Zweck / Ausgabe ungeklärt)",
|
||||
'desk.bayReqAria': "Eingabeanforderungen",
|
||||
'desk.bayReqTitle': "Eingabeanforderungen",
|
||||
'desk.baySearchPh': "Name, Beschreibung, Modell, Zweck suchen…",
|
||||
'desk.baySourceCloud': "Cloud",
|
||||
'desk.baySourceLabel': "Generierungsquelle",
|
||||
'desk.baySourceLocal': "Lokal",
|
||||
'desk.modeAll': "Alle",
|
||||
'desk.modeEmpty': "Für diesen Modus gibt es noch keinen nutzbaren Workflow",
|
||||
'desk.modeRailAria': "Aufgabenmodi",
|
||||
'desk.modeRailTitle': "Aufgabenmodi",
|
||||
'desk.pvBackCreate': "Zurück zum Erstellen",
|
||||
'desk.pvCurrent': "Aktueller Workflow",
|
||||
'desk.pvEmpty': "Links einen Workflow wählen, um die Eingaben zu sehen",
|
||||
'desk.pvFav': "Zu Favoriten",
|
||||
'desk.pvLastUsed': "Zuletzt genutzt",
|
||||
'desk.pvParamCount': "Parameter",
|
||||
'desk.pvSlotsLoading': "Eingabeslots werden gelesen…",
|
||||
'desk.pvUnfav': "Aus Favoriten entfernen",
|
||||
'desk.pvUse': "Diesen Workflow verwenden",
|
||||
'desk.railFav': "Favoriten",
|
||||
'desk.railRecent': "Zuletzt",
|
||||
'wfd.stepParse': 'JSON parsen',
|
||||
'wfd.stepNodes': 'Knoten erkennen',
|
||||
'wfd.stepModels': 'Modelle prüfen',
|
||||
@@ -2096,4 +2136,136 @@ export const de: Dict = {
|
||||
'wfd.nodesPickSuggest': 'Wähle unten die Verwendung und schließe die Ausgangszuordnung ab, bevor du speicherst.',
|
||||
'wfd.showRaw': 'Rohes JSON anzeigen',
|
||||
'wfd.hideRaw': 'Rohes JSON ausblenden',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'UI-Format-Workflows stapelweise importieren; suchen, filtern, annotieren — volles JSON nur bei Bedarf',
|
||||
'wl.batchImport': 'Stapelimport',
|
||||
'wl.dedupe': 'Duplikate entfernen',
|
||||
'wl.dedupeTitle': 'Duplizierte Workflows entfernen',
|
||||
'wl.dedupeConfirm': 'Einträge mit identischem Namen UND Inhalt werden gelöscht (der früheste Import bleibt). Gleicher Name mit anderem Inhalt bleibt unangetastet. Fortfahren?',
|
||||
'wl.dedupeDone': '{n} doppelte Workflows entfernt',
|
||||
'wl.dedupeNone': 'Keine doppelten Workflows gefunden',
|
||||
'wl.favCount': '{n} Favoriten',
|
||||
'wl.searchPh': 'Name, Beschreibung, Tags, Modell suchen… (/ drücken)',
|
||||
'wl.filterLabel': 'Filter',
|
||||
'wl.filterAll': 'Alle',
|
||||
'wl.filterFav': 'Favoriten',
|
||||
'wl.filterPending': 'Offen',
|
||||
'wl.filterDisabled': 'Deaktiviert',
|
||||
'wl.favAria': 'Favorisieren',
|
||||
'wl.unfavAria': 'Favorit entfernen',
|
||||
'wl.categoryLabel': 'Kategorie',
|
||||
'wl.categoryAll': 'Alle Kategorien',
|
||||
'wl.familyLabel': 'Modellfamilie',
|
||||
'wl.familyAll': 'Alle Familien',
|
||||
'wl.mediaLabel': 'Medium',
|
||||
'wl.mediaAll': 'Alle Medien',
|
||||
'wl.mediaImage': 'Bild',
|
||||
'wl.mediaVideo': 'Video',
|
||||
'wl.mediaAudio': 'Audio',
|
||||
'wl.mediaIn': 'Eingabe',
|
||||
'wl.mediaOut': 'Ausgabe',
|
||||
'wl.sortLabel': 'Sortierung',
|
||||
'wl.sortRecent': 'Favoriten / zuletzt',
|
||||
'wl.sortName': 'Name',
|
||||
'wl.resetFilters': 'Filter zurücksetzen',
|
||||
'wl.noMatch': 'Keine Workflows entsprechen den Filtern',
|
||||
'wl.resultCount': '{n} von {m}',
|
||||
'wl.selected': '{n} ausgewählt',
|
||||
'wl.batchLabel': 'Stapelaktionen',
|
||||
'wl.batchAnnotate': 'Annotieren',
|
||||
'wl.batchEnable': 'Aktivieren',
|
||||
'wl.batchDisable': 'Deaktivieren',
|
||||
'wl.clearSel': 'Auswahl aufheben',
|
||||
'wl.batchDone': '{n} Workflows aktualisiert',
|
||||
'wl.batchPartial': '{n} erfolgreich, {m} fehlgeschlagen (zuerst Verwendung und Ausgabe klären)',
|
||||
'wl.selectAll': 'Alle aufgelisteten Workflows auswählen',
|
||||
'wl.rowCheckAria': '{name} auswählen',
|
||||
'wl.headMeta': 'Metadaten',
|
||||
'wl.headActions': 'Aktionen',
|
||||
'wl.enableAria': '{name} aktivieren oder deaktivieren',
|
||||
'wl.enableBlocked': 'Verwendung und Ausgabeknoten vor Aktivierung festlegen',
|
||||
'wl.editMeta': 'Metadaten bearbeiten',
|
||||
'wl.lastUsed': 'Zuletzt verwendet',
|
||||
'wl.moreCount': '{n} mehr anzeigen ({m} gesamt)',
|
||||
'wfCat.basic': 'Grundverarbeitung',
|
||||
'wfCat.audioTts': 'Sprachgenerierung',
|
||||
'wfCat.music': 'Musikgenerierung',
|
||||
'wfCat.image': 'Bildgenerierung',
|
||||
'wfCat.video': 'Videogenerierung',
|
||||
'wfCat.digitalHuman': 'Digitaler Mensch',
|
||||
'wfCat.motionTransfer': 'Bewegungsübertragung',
|
||||
'md.title': 'Workflow-Metadaten',
|
||||
'md.batchTitle': 'Stapel-Annotieren ({n})',
|
||||
'md.batchHint': 'Nur ausgefüllte Felder werden angewendet; Namen per Suchen→Ersetzen, Tags werden ergänzt.',
|
||||
'md.name': 'Anzeigename',
|
||||
'md.nameFind': 'Suchen',
|
||||
'md.nameFindPh': 'Zu ersetzender Text…',
|
||||
'md.nameReplace': 'Ersetzen mit',
|
||||
'md.nameReplacePh': 'Ersatztext…',
|
||||
'md.nameHint': 'Leeres „Suchen“ lässt Namen unverändert.',
|
||||
'md.description': 'Beschreibung',
|
||||
'md.descriptionPh': 'Eine Zeile, was dieser Workflow tut…',
|
||||
'md.tags': 'Tags',
|
||||
'md.tagsPh': 'z. B. schnell, hohe Qualität',
|
||||
'md.tagsHint': 'Kommagetrennt; im Stapelmodus werden sie ergänzt.',
|
||||
'md.category': 'Kategorie',
|
||||
'md.modelFamily': 'Modellfamilie',
|
||||
'md.apply': 'Anwenden',
|
||||
'md.applied': '{n} Workflows aktualisiert',
|
||||
'md.appliedOne': 'Aktualisiert',
|
||||
'md.partial': '{n} erfolgreich, {m} fehlgeschlagen',
|
||||
'wiz.title': 'Workflows stapelweise importieren',
|
||||
'wiz.stepSource': 'Quelle',
|
||||
'wiz.stepReport': 'Prüfbericht',
|
||||
'wiz.stepResult': 'Ergebnis',
|
||||
'wiz.sourceLabel': 'Importquelle',
|
||||
'wiz.modeScan': 'Serververzeichnis scannen',
|
||||
'wiz.modeFiles': 'Lokale Dateien wählen',
|
||||
'wiz.rootLabel': 'Serververzeichnis',
|
||||
'wiz.rootHint': 'Ein Verzeichnispfad auf dem Server (z. B. example_ComfyUI_workflows des Repos); UI-Format-JSONs werden rekursiv gescannt, API-Format und andere übersprungen.',
|
||||
'wiz.preflight': 'Preflight gegen echtes ComfyUI (optional)',
|
||||
'wiz.preflightNone': 'Überspringen (Offline-Konvertierung)',
|
||||
'wiz.preflightHint': 'Mit gewählter Verbindung werden konvertierte Graphen Knoten für Knoten geprüft.',
|
||||
'wiz.pickFiles': 'Lokale Dateien',
|
||||
'wiz.pickFilesBtn': 'JSON-Dateien wählen (mehrere)',
|
||||
'wiz.pickFilesHint': 'ComfyUI-Workflows im UI-Format wählen; Mehrfachauswahl möglich.',
|
||||
'wiz.parsed': '{n} Dateien gelesen',
|
||||
'wiz.notUi': '{n} Dateien sind kein UI-Format (kein nodes-Array) und wurden übersprungen',
|
||||
'wiz.noFiles': 'Zuerst Dateien zum Import auswählen',
|
||||
'wiz.runDry': 'Probelauf starten (nichts wird gespeichert)',
|
||||
'wiz.running': 'Prüfe…',
|
||||
'wiz.total': '{n} Dateien',
|
||||
'wiz.ok': 'OK {n}',
|
||||
'wiz.warn': 'Warnung {n}',
|
||||
'wiz.fail': 'Fehler {n}',
|
||||
'wiz.confirmCount': 'Prüfung nötig {n}',
|
||||
'wiz.importable': 'Importierbar {n}',
|
||||
'wiz.reportHint': 'Der Probelauf speichert nichts. Warnungen werden aktiviert importiert, sobald eine Verwendung angegeben ist; prüfungspflichtige und fehlgeschlagene Dateien werden nie importiert.',
|
||||
'wiz.filterLabel': 'Berichtsfilter',
|
||||
'wiz.filterWarn': 'Nur Warnungen',
|
||||
'wiz.filterBlocked': 'Nicht importierbar',
|
||||
'wiz.searchPh': 'Dateinamen suchen…',
|
||||
'wiz.includeAria': '{name} importieren',
|
||||
'wiz.skipReason': 'Nicht importierbar: {reason}',
|
||||
'wiz.issues': '{n} Konvertierungshinweise',
|
||||
'wiz.usagePick': 'Verwendung:',
|
||||
'wiz.usageNone': 'Vorerst überspringen (nach Import offen)',
|
||||
'wiz.selectedN': '{n} Dateien werden importiert',
|
||||
'wiz.excludedM': '{m} nicht importierbar',
|
||||
'wiz.import': 'Importieren',
|
||||
'wiz.importing': 'Importiere…',
|
||||
'wiz.back': 'Zurück',
|
||||
'wiz.imported': '{n} importiert',
|
||||
'wiz.skipped': '{n} übersprungen',
|
||||
'wiz.conflict': 'Existiert bereits (Duplikat übersprungen)',
|
||||
'wiz.usageApplied': 'Verwendung für {n} neue Importe gesetzt',
|
||||
'wiz.done': 'Fertig',
|
||||
'wiz.again': 'Weitere Charge importieren',
|
||||
'wiz.resultNote': 'Dateien mit identischem Inhalt werden automatisch übersprungen — Wiederholung erzeugt keine Duplikate.',
|
||||
'wiz.importedToast': '{n} Workflows importiert',
|
||||
'wiz.warnTag': 'Warnung',
|
||||
'wiz.confirmTag': 'Prüfung nötig',
|
||||
'wiz.preflightTag': 'Preflight fehlgeschlagen',
|
||||
'wiz.importedTag': 'Importiert #{id}',
|
||||
};
|
||||
|
||||
@@ -1557,6 +1557,7 @@ export const en = {
|
||||
'studio.slotMask': 'Mask',
|
||||
'studio.slotFirst': 'First frame',
|
||||
'studio.slotLast': 'Last frame',
|
||||
'studio.slotOptional': 'Optional',
|
||||
'studio.slotRefVideo': 'Reference video',
|
||||
'studio.slotAudio': 'Reference audio',
|
||||
'studio.slotPick': 'Click or drop a file',
|
||||
@@ -1900,7 +1901,7 @@ export const en = {
|
||||
'home.stageProgress': 'Five-stage progress',
|
||||
'tasks.sourceWorkbench': 'Creation flow',
|
||||
'jobst.idSuffix': ' · ComfyUI prompt ID {id}',
|
||||
'jobst.done': 'Done',
|
||||
'jobst.done': 'Fixed',
|
||||
'jobst.doneDetail': 'ComfyUI finished and the results are saved',
|
||||
'jobst.dryRun': 'Placeholder',
|
||||
'jobst.dryRunDetail': 'Placeholder only: Dry-run is on, ComfyUI was not called',
|
||||
@@ -2001,6 +2002,15 @@ export const en = {
|
||||
'desk.ctxDismiss': 'Dismiss source note',
|
||||
'desk.ctxFrom': 'From “{project}”',
|
||||
'desk.ctxNoBackfill': 'Results stay in the desk feed and are not filled back into {stage}',
|
||||
'desk.ctxBackfill': 'When a take is done, use “Send back to project” on its card to return it to {stage}',
|
||||
'desk.attachToProject': 'Send back to project',
|
||||
'desk.attachHint': 'Send this take back to its source: {label}',
|
||||
'desk.attaching': 'Sending back…',
|
||||
'desk.attachedTag': 'Sent back',
|
||||
'desk.attachBack': 'Back to project',
|
||||
'desk.attachDoneToast': 'Sent back to the source project — open the project to see it',
|
||||
'desk.attachAlreadyToast': 'This take was already sent back; nothing duplicated',
|
||||
'desk.attachFailedToast': 'Could not send back: {detail}',
|
||||
'desk.dayDate': '{m}/{d}',
|
||||
'desk.download': 'Download',
|
||||
'desk.dropNoSlot': '{n} file(s) have no slot: this workflow has no input of that kind',
|
||||
@@ -2079,6 +2089,36 @@ export const en = {
|
||||
'desk.wfUntested': 'Not tested',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Yesterday',
|
||||
// --- Three-section desk (HEL-683) ---
|
||||
'desk.bandChange': "Change workflow",
|
||||
'desk.bayCloudBody': "Cloud image / video models (gpt-image2, nano-banana, Jimeng, Kling, …) are not connected to this product yet. Once integrated they will be listed with their real capabilities — never as unusable entries.",
|
||||
'desk.bayCloudTitle': "Cloud generation not connected",
|
||||
'desk.bayCount': "{n} / {total} workflows",
|
||||
'desk.bayFavOnly': "Favorites",
|
||||
'desk.bayMore': "Show {n} more",
|
||||
'desk.bayNoFamily': "Unrecognized model",
|
||||
'desk.bayPending': "{n} pending confirmation (usage / output unsettled)",
|
||||
'desk.bayReqAria': "Input requirements",
|
||||
'desk.bayReqTitle': "Input requirements",
|
||||
'desk.baySearchPh': "Search name, description, model, usage…",
|
||||
'desk.baySourceCloud': "Cloud",
|
||||
'desk.baySourceLabel': "Generation source",
|
||||
'desk.baySourceLocal': "Local",
|
||||
'desk.modeAll': "All",
|
||||
'desk.modeEmpty': "No runnable workflow for this mode yet",
|
||||
'desk.modeRailAria': "Task modes",
|
||||
'desk.modeRailTitle': "Task modes",
|
||||
'desk.pvBackCreate': "Back to creating",
|
||||
'desk.pvCurrent': "Current workflow",
|
||||
'desk.pvEmpty': "Pick a workflow on the left to see its input requirements",
|
||||
'desk.pvFav': "Add to favorites",
|
||||
'desk.pvLastUsed': "Last used",
|
||||
'desk.pvParamCount': "Parameters",
|
||||
'desk.pvSlotsLoading': "Loading input slots…",
|
||||
'desk.pvUnfav': "Remove from favorites",
|
||||
'desk.pvUse': "Use this workflow",
|
||||
'desk.railFav': "Favorites",
|
||||
'desk.railRecent': "Recent",
|
||||
'wfd.stepParse': 'Parse JSON',
|
||||
'wfd.stepNodes': 'Identify nodes',
|
||||
'wfd.stepModels': 'Verify models',
|
||||
@@ -2095,6 +2135,138 @@ export const en = {
|
||||
'wfd.nodesPickSuggest': 'Pick the usage and finish the output mapping below before saving.',
|
||||
'wfd.showRaw': 'View raw JSON',
|
||||
'wfd.hideRaw': 'Hide raw JSON',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'Batch-import UI-format workflows; search, filter and annotate — full JSON loads only per action',
|
||||
'wl.batchImport': 'Batch import',
|
||||
'wl.dedupe': 'Dedupe',
|
||||
'wl.dedupeTitle': 'Remove duplicate workflows',
|
||||
'wl.dedupeConfirm': 'This deletes entries whose name AND content are identical (the earliest import is kept). Same name with different content is your variant and stays untouched. Continue?',
|
||||
'wl.dedupeDone': 'Removed {n} duplicate workflows',
|
||||
'wl.dedupeNone': 'No duplicate workflows found',
|
||||
'wl.favCount': '{n} favorites',
|
||||
'wl.searchPh': 'Search name, description, tags, model… (press /)',
|
||||
'wl.filterLabel': 'Filters',
|
||||
'wl.filterAll': 'All',
|
||||
'wl.filterFav': 'Favorites',
|
||||
'wl.filterPending': 'Pending',
|
||||
'wl.filterDisabled': 'Disabled',
|
||||
'wl.favAria': 'Favorite',
|
||||
'wl.unfavAria': 'Unfavorite',
|
||||
'wl.categoryLabel': 'Category',
|
||||
'wl.categoryAll': 'All categories',
|
||||
'wl.familyLabel': 'Model family',
|
||||
'wl.familyAll': 'All families',
|
||||
'wl.mediaLabel': 'Media',
|
||||
'wl.mediaAll': 'All media',
|
||||
'wl.mediaImage': 'Image',
|
||||
'wl.mediaVideo': 'Video',
|
||||
'wl.mediaAudio': 'Audio',
|
||||
'wl.mediaIn': 'Input',
|
||||
'wl.mediaOut': 'Output',
|
||||
'wl.sortLabel': 'Sort',
|
||||
'wl.sortRecent': 'Favorite / recent',
|
||||
'wl.sortName': 'Name',
|
||||
'wl.resetFilters': 'Clear filters',
|
||||
'wl.noMatch': 'No workflows match the current filters',
|
||||
'wl.resultCount': '{n} of {m} match',
|
||||
'wl.selected': '{n} selected',
|
||||
'wl.batchLabel': 'Batch actions',
|
||||
'wl.batchAnnotate': 'Annotate',
|
||||
'wl.batchEnable': 'Enable',
|
||||
'wl.batchDisable': 'Disable',
|
||||
'wl.clearSel': 'Clear selection',
|
||||
'wl.batchDone': 'Updated {n} workflows',
|
||||
'wl.batchPartial': '{n} succeeded, {m} failed (usage and output mapping must be settled first)',
|
||||
'wl.selectAll': 'Select all listed workflows',
|
||||
'wl.rowCheckAria': 'Select {name}',
|
||||
'wl.headMeta': 'Metadata',
|
||||
'wl.headActions': 'Actions',
|
||||
'wl.enableAria': 'Enable or disable {name}',
|
||||
'wl.enableBlocked': 'Settle usage and output nodes before enabling',
|
||||
'wl.editMeta': 'Edit metadata',
|
||||
'wl.lastUsed': 'Last used',
|
||||
'wl.moreCount': 'Show {n} more ({m} total)',
|
||||
'wfCat.basic': 'Basic processing',
|
||||
'wfCat.audioTts': 'Speech generation',
|
||||
'wfCat.music': 'Music generation',
|
||||
'wfCat.image': 'Image generation',
|
||||
'wfCat.video': 'Video generation',
|
||||
'wfCat.digitalHuman': 'Digital human',
|
||||
'wfCat.motionTransfer': 'Motion transfer',
|
||||
'md.title': 'Workflow metadata',
|
||||
'md.batchTitle': 'Batch annotate ({n})',
|
||||
'md.batchHint': 'Only filled fields are applied; names are renamed via find→replace and tags are appended.',
|
||||
'md.name': 'Display name',
|
||||
'md.nameFind': 'Find',
|
||||
'md.nameFindPh': 'Text to replace…',
|
||||
'md.nameReplace': 'Replace with',
|
||||
'md.nameReplacePh': 'Replacement text…',
|
||||
'md.nameHint': 'Leave “Find” empty to keep names unchanged.',
|
||||
'md.description': 'Description',
|
||||
'md.descriptionPh': 'One line about what this workflow does…',
|
||||
'md.tags': 'Tags',
|
||||
'md.tagsPh': 'e.g. fast, high quality',
|
||||
'md.tagsHint': 'Comma separated; appended in batch mode.',
|
||||
'md.category': 'Category',
|
||||
'md.modelFamily': 'Model family',
|
||||
'md.apply': 'Apply',
|
||||
'md.applied': 'Updated {n} workflows',
|
||||
'md.appliedOne': 'Updated',
|
||||
'md.partial': '{n} succeeded, {m} failed',
|
||||
'wiz.title': 'Batch import workflows',
|
||||
'wiz.stepSource': 'Source',
|
||||
'wiz.stepReport': 'Dry-run report',
|
||||
'wiz.stepResult': 'Result',
|
||||
'wiz.sourceLabel': 'Import source',
|
||||
'wiz.modeScan': 'Scan server directory',
|
||||
'wiz.modeFiles': 'Pick local files',
|
||||
'wiz.rootLabel': 'Server directory',
|
||||
'wiz.rootHint': 'A directory path on the server (e.g. the repo’s example_ComfyUI_workflows); UI-format ComfyUI JSONs inside are scanned recursively — API-format and other JSONs are skipped.',
|
||||
'wiz.preflight': 'Preflight against a live ComfyUI (optional)',
|
||||
'wiz.preflightNone': 'Skip (offline conversion)',
|
||||
'wiz.preflightHint': 'With a connection selected, converted graphs are checked node-by-node against that ComfyUI.',
|
||||
'wiz.pickFiles': 'Local files',
|
||||
'wiz.pickFilesBtn': 'Choose JSON files (multiple)',
|
||||
'wiz.pickFilesHint': 'Pick ComfyUI UI-format workflow JSONs; multiple selection supported.',
|
||||
'wiz.parsed': '{n} files read',
|
||||
'wiz.notUi': '{n} files are not UI-format (no nodes array) and were skipped',
|
||||
'wiz.noFiles': 'Choose files to import first',
|
||||
'wiz.runDry': 'Run dry-run (nothing stored)',
|
||||
'wiz.running': 'Checking…',
|
||||
'wiz.total': '{n} files',
|
||||
'wiz.ok': 'OK {n}',
|
||||
'wiz.warn': 'Warn {n}',
|
||||
'wiz.fail': 'Fail {n}',
|
||||
'wiz.confirmCount': 'Need review {n}',
|
||||
'wiz.importable': 'Importable {n}',
|
||||
'wiz.reportHint': 'The dry-run stores nothing. Warn items import enabled once a usage is supplied; needs-review and failed items are never imported nor marked runnable.',
|
||||
'wiz.filterLabel': 'Report filters',
|
||||
'wiz.filterWarn': 'Warnings only',
|
||||
'wiz.filterBlocked': 'Not importable',
|
||||
'wiz.searchPh': 'Search file name…',
|
||||
'wiz.includeAria': 'Import {name}',
|
||||
'wiz.skipReason': 'Not importable: {reason}',
|
||||
'wiz.issues': '{n} conversion notes',
|
||||
'wiz.usagePick': 'Usage:',
|
||||
'wiz.usageNone': 'Skip for now (pending after import)',
|
||||
'wiz.selectedN': '{n} files will be imported',
|
||||
'wiz.excludedM': '{m} not importable',
|
||||
'wiz.import': 'Import',
|
||||
'wiz.importing': 'Importing…',
|
||||
'wiz.back': 'Back',
|
||||
'wiz.imported': 'Imported {n}',
|
||||
'wiz.skipped': 'Skipped {n}',
|
||||
'wiz.conflict': 'Already exists (duplicate skipped)',
|
||||
'wiz.usageApplied': 'Usage supplied for {n} new imports',
|
||||
'wiz.done': 'Done',
|
||||
'wiz.again': 'Import another batch',
|
||||
'wiz.resultNote': 'Files with identical content are skipped automatically — re-running never creates duplicates.',
|
||||
'wiz.importedToast': 'Imported {n} workflows',
|
||||
'wiz.warnTag': 'Warn',
|
||||
'wiz.confirmTag': 'Needs review',
|
||||
'wiz.preflightTag': 'Preflight failed',
|
||||
'wiz.importedTag': 'Imported #{id}',
|
||||
};
|
||||
|
||||
export type Dict = typeof en;
|
||||
|
||||
@@ -1558,6 +1558,7 @@ export const es: Dict = {
|
||||
'studio.slotMask': 'Máscara',
|
||||
'studio.slotFirst': 'Primer fotograma',
|
||||
'studio.slotLast': 'Último fotograma',
|
||||
'studio.slotOptional': 'Opcional',
|
||||
'studio.slotRefVideo': 'Vídeo de referencia',
|
||||
'studio.slotAudio': 'Audio de referencia',
|
||||
'studio.slotPick': 'Haz clic o arrastra',
|
||||
@@ -1901,7 +1902,7 @@ export const es: Dict = {
|
||||
'home.stageProgress': 'Progreso de cinco etapas',
|
||||
'tasks.sourceWorkbench': 'Flujo de creación',
|
||||
'jobst.idSuffix': ' · ID de prompt de ComfyUI {id}',
|
||||
'jobst.done': 'Listo',
|
||||
'jobst.done': 'Fijado',
|
||||
'jobst.doneDetail': 'ComfyUI terminó y los resultados se guardaron',
|
||||
'jobst.dryRun': 'Marcador',
|
||||
'jobst.dryRunDetail': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
|
||||
@@ -2002,6 +2003,15 @@ export const es: Dict = {
|
||||
'desk.ctxDismiss': 'Cerrar aviso de origen',
|
||||
'desk.ctxFrom': 'De «{project}»',
|
||||
'desk.ctxNoBackfill': 'Los resultados quedan en el registro y no se devuelven a {stage}',
|
||||
'desk.ctxBackfill': 'Al terminar, usa «Enviar al proyecto» en la tarjeta para devolver el resultado a {stage}',
|
||||
'desk.attachToProject': 'Enviar al proyecto',
|
||||
'desk.attachHint': 'Enviar este resultado a su origen: {label}',
|
||||
'desk.attaching': 'Enviando…',
|
||||
'desk.attachedTag': 'Enviado',
|
||||
'desk.attachBack': 'Ir al proyecto',
|
||||
'desk.attachDoneToast': 'Enviado al proyecto de origen: ábrelo para verlo',
|
||||
'desk.attachAlreadyToast': 'Este resultado ya fue enviado; no se duplicó nada',
|
||||
'desk.attachFailedToast': 'No se pudo enviar: {detail}',
|
||||
'desk.dayDate': '{d}/{m}',
|
||||
'desk.download': 'Descargar',
|
||||
'desk.dropNoSlot': '{n} archivo(s) sin ranura: este flujo no tiene esa entrada',
|
||||
@@ -2080,6 +2090,36 @@ export const es: Dict = {
|
||||
'desk.wfUntested': 'Sin probar',
|
||||
'desk.workflow': 'Flujo',
|
||||
'desk.yesterday': 'Ayer',
|
||||
// --- Escritorio de tres secciones (HEL-683) ---
|
||||
'desk.bandChange': "Cambiar flujo",
|
||||
'desk.bayCloudBody': "Los modelos de nube para imagen / vídeo (gpt-image2, nano-banana, Jimeng, Kling, …) aún no están conectados a este producto. Al integrarse se listarán con sus capacidades reales, nunca como entradas inutilizables.",
|
||||
'desk.bayCloudTitle': "Generación en la nube no conectada",
|
||||
'desk.bayCount': "{n} / {total} flujos",
|
||||
'desk.bayFavOnly': "Favoritos",
|
||||
'desk.bayMore': "Mostrar {n} más",
|
||||
'desk.bayNoFamily': "Modelo no identificado",
|
||||
'desk.bayPending': "{n} pendientes de confirmar (uso / salida sin fijar)",
|
||||
'desk.bayReqAria': "Requisitos de entrada",
|
||||
'desk.bayReqTitle': "Requisitos de entrada",
|
||||
'desk.baySearchPh': "Buscar nombre, descripción, modelo, uso…",
|
||||
'desk.baySourceCloud': "Nube",
|
||||
'desk.baySourceLabel': "Origen de generación",
|
||||
'desk.baySourceLocal': "Local",
|
||||
'desk.modeAll': "Todos",
|
||||
'desk.modeEmpty': "Aún no hay flujo utilizable para este modo",
|
||||
'desk.modeRailAria': "Modos de tarea",
|
||||
'desk.modeRailTitle': "Modos de tarea",
|
||||
'desk.pvBackCreate': "Volver a crear",
|
||||
'desk.pvCurrent': "Flujo actual",
|
||||
'desk.pvEmpty': "Elige un flujo a la izquierda para ver sus requisitos",
|
||||
'desk.pvFav': "Añadir a favoritos",
|
||||
'desk.pvLastUsed': "Último uso",
|
||||
'desk.pvParamCount': "Parámetros",
|
||||
'desk.pvSlotsLoading': "Leyendo ranuras de entrada…",
|
||||
'desk.pvUnfav': "Quitar de favoritos",
|
||||
'desk.pvUse': "Usar este flujo",
|
||||
'desk.railFav': "Favoritos",
|
||||
'desk.railRecent': "Recientes",
|
||||
'wfd.stepParse': 'Analizar JSON',
|
||||
'wfd.stepNodes': 'Identificar nodos',
|
||||
'wfd.stepModels': 'Verificar modelos',
|
||||
@@ -2096,4 +2136,136 @@ export const es: Dict = {
|
||||
'wfd.nodesPickSuggest': 'Elige el uso y completa el mapeo de salida abajo antes de guardar.',
|
||||
'wfd.showRaw': 'Ver JSON original',
|
||||
'wfd.hideRaw': 'Ocultar JSON original',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'Importa por lotes flujos en formato UI; busca, filtra y anota — el JSON completo solo se carga al actuar',
|
||||
'wl.batchImport': 'Importar por lotes',
|
||||
'wl.dedupe': 'Deduplicar',
|
||||
'wl.dedupeTitle': 'Eliminar flujos duplicados',
|
||||
'wl.dedupeConfirm': 'Se eliminan las entradas con nombre Y contenido idénticos (se conserva la primera importada). Mismo nombre con contenido distinto no se toca. ¿Continuar?',
|
||||
'wl.dedupeDone': 'Se eliminaron {n} flujos duplicados',
|
||||
'wl.dedupeNone': 'No se encontraron flujos duplicados',
|
||||
'wl.favCount': '{n} favoritos',
|
||||
'wl.searchPh': 'Buscar nombre, descripción, etiquetas, modelo… (pulsa /)',
|
||||
'wl.filterLabel': 'Filtros',
|
||||
'wl.filterAll': 'Todos',
|
||||
'wl.filterFav': 'Favoritos',
|
||||
'wl.filterPending': 'Pendientes',
|
||||
'wl.filterDisabled': 'Desactivados',
|
||||
'wl.favAria': 'Marcar como favorito',
|
||||
'wl.unfavAria': 'Quitar de favoritos',
|
||||
'wl.categoryLabel': 'Categoría',
|
||||
'wl.categoryAll': 'Todas las categorías',
|
||||
'wl.familyLabel': 'Familia de modelo',
|
||||
'wl.familyAll': 'Todas las familias',
|
||||
'wl.mediaLabel': 'Medio',
|
||||
'wl.mediaAll': 'Todos los medios',
|
||||
'wl.mediaImage': 'Imagen',
|
||||
'wl.mediaVideo': 'Vídeo',
|
||||
'wl.mediaAudio': 'Audio',
|
||||
'wl.mediaIn': 'Entrada',
|
||||
'wl.mediaOut': 'Salida',
|
||||
'wl.sortLabel': 'Orden',
|
||||
'wl.sortRecent': 'Favoritos / recientes',
|
||||
'wl.sortName': 'Nombre',
|
||||
'wl.resetFilters': 'Limpiar filtros',
|
||||
'wl.noMatch': 'Ningún flujo coincide con los filtros',
|
||||
'wl.resultCount': '{n} de {m}',
|
||||
'wl.selected': '{n} seleccionados',
|
||||
'wl.batchLabel': 'Acciones por lotes',
|
||||
'wl.batchAnnotate': 'Anotar',
|
||||
'wl.batchEnable': 'Activar',
|
||||
'wl.batchDisable': 'Desactivar',
|
||||
'wl.clearSel': 'Limpiar selección',
|
||||
'wl.batchDone': '{n} flujos actualizados',
|
||||
'wl.batchPartial': '{n} con éxito, {m} fallidos (confirma antes uso y salida)',
|
||||
'wl.selectAll': 'Seleccionar todos los flujos listados',
|
||||
'wl.rowCheckAria': 'Seleccionar {name}',
|
||||
'wl.headMeta': 'Metadatos',
|
||||
'wl.headActions': 'Acciones',
|
||||
'wl.enableAria': 'Activar o desactivar {name}',
|
||||
'wl.enableBlocked': 'Confirma uso y nodos de salida antes de activar',
|
||||
'wl.editMeta': 'Editar metadatos',
|
||||
'wl.lastUsed': 'Último uso',
|
||||
'wl.moreCount': 'Mostrar {n} más ({m} en total)',
|
||||
'wfCat.basic': 'Procesamiento básico',
|
||||
'wfCat.audioTts': 'Generación de voz',
|
||||
'wfCat.music': 'Generación de música',
|
||||
'wfCat.image': 'Generación de imágenes',
|
||||
'wfCat.video': 'Generación de vídeo',
|
||||
'wfCat.digitalHuman': 'Humano digital',
|
||||
'wfCat.motionTransfer': 'Transferencia de movimiento',
|
||||
'md.title': 'Metadatos del flujo',
|
||||
'md.batchTitle': 'Anotar por lotes ({n})',
|
||||
'md.batchHint': 'Solo se aplican los campos rellenados; los nombres se renombran con buscar→reemplazar y las etiquetas se añaden.',
|
||||
'md.name': 'Nombre visible',
|
||||
'md.nameFind': 'Buscar',
|
||||
'md.nameFindPh': 'Texto a reemplazar…',
|
||||
'md.nameReplace': 'Reemplazar por',
|
||||
'md.nameReplacePh': 'Texto de reemplazo…',
|
||||
'md.nameHint': 'Deja «Buscar» vacío para no cambiar nombres.',
|
||||
'md.description': 'Descripción',
|
||||
'md.descriptionPh': 'Una línea sobre qué hace este flujo…',
|
||||
'md.tags': 'Etiquetas',
|
||||
'md.tagsPh': 'p. ej. rápido, alta calidad',
|
||||
'md.tagsHint': 'Separadas por comas; en lote se añaden.',
|
||||
'md.category': 'Categoría',
|
||||
'md.modelFamily': 'Familia de modelo',
|
||||
'md.apply': 'Aplicar',
|
||||
'md.applied': '{n} flujos actualizados',
|
||||
'md.appliedOne': 'Actualizado',
|
||||
'md.partial': '{n} con éxito, {m} fallidos',
|
||||
'wiz.title': 'Importar flujos por lotes',
|
||||
'wiz.stepSource': 'Origen',
|
||||
'wiz.stepReport': 'Informe de prueba',
|
||||
'wiz.stepResult': 'Resultado',
|
||||
'wiz.sourceLabel': 'Origen de importación',
|
||||
'wiz.modeScan': 'Escanear carpeta del servidor',
|
||||
'wiz.modeFiles': 'Elegir archivos locales',
|
||||
'wiz.rootLabel': 'Carpeta del servidor',
|
||||
'wiz.rootHint': 'Ruta de carpeta en el servidor (p. ej. example_ComfyUI_workflows del repo); se escanean recursivamente los JSON en formato UI, el formato API y otros JSON se omiten.',
|
||||
'wiz.preflight': 'Preflight contra un ComfyUI real (opcional)',
|
||||
'wiz.preflightNone': 'Omitir (conversión sin conexión)',
|
||||
'wiz.preflightHint': 'Con una conexión elegida, los grafos convertidos se verifican nodo a nodo.',
|
||||
'wiz.pickFiles': 'Archivos locales',
|
||||
'wiz.pickFilesBtn': 'Elegir archivos JSON (varios)',
|
||||
'wiz.pickFilesHint': 'Elige JSON de flujos ComfyUI en formato UI; se admite selección múltiple.',
|
||||
'wiz.parsed': '{n} archivos leídos',
|
||||
'wiz.notUi': '{n} archivos no son formato UI (sin array nodes) y se omitieron',
|
||||
'wiz.noFiles': 'Elige primero los archivos a importar',
|
||||
'wiz.runDry': 'Ejecutar prueba (no se guarda nada)',
|
||||
'wiz.running': 'Comprobando…',
|
||||
'wiz.total': '{n} archivos',
|
||||
'wiz.ok': 'OK {n}',
|
||||
'wiz.warn': 'Avisos {n}',
|
||||
'wiz.fail': 'Fallos {n}',
|
||||
'wiz.confirmCount': 'Requieren revisión {n}',
|
||||
'wiz.importable': 'Importables {n}',
|
||||
'wiz.reportHint': 'La prueba no guarda nada. Los avisos se importan activados al indicar un uso; los que requieren revisión o fallaron nunca se importan.',
|
||||
'wiz.filterLabel': 'Filtros del informe',
|
||||
'wiz.filterWarn': 'Solo avisos',
|
||||
'wiz.filterBlocked': 'No importables',
|
||||
'wiz.searchPh': 'Buscar nombre de archivo…',
|
||||
'wiz.includeAria': 'Importar {name}',
|
||||
'wiz.skipReason': 'No importable: {reason}',
|
||||
'wiz.issues': '{n} notas de conversión',
|
||||
'wiz.usagePick': 'Uso:',
|
||||
'wiz.usageNone': 'Omitir por ahora (pendiente tras importar)',
|
||||
'wiz.selectedN': 'Se importarán {n} archivos',
|
||||
'wiz.excludedM': '{m} no importables',
|
||||
'wiz.import': 'Importar',
|
||||
'wiz.importing': 'Importando…',
|
||||
'wiz.back': 'Atrás',
|
||||
'wiz.imported': '{n} importados',
|
||||
'wiz.skipped': '{n} omitidos',
|
||||
'wiz.conflict': 'Ya existe (duplicado omitido)',
|
||||
'wiz.usageApplied': 'Uso indicado para {n} importaciones nuevas',
|
||||
'wiz.done': 'Hecho',
|
||||
'wiz.again': 'Importar otro lote',
|
||||
'wiz.resultNote': 'Los archivos con contenido idéntico se omiten automáticamente; repetir no crea duplicados.',
|
||||
'wiz.importedToast': '{n} flujos importados',
|
||||
'wiz.warnTag': 'Aviso',
|
||||
'wiz.confirmTag': 'Requiere revisión',
|
||||
'wiz.preflightTag': 'Preflight fallido',
|
||||
'wiz.importedTag': 'Importado #{id}',
|
||||
};
|
||||
|
||||
@@ -1558,6 +1558,7 @@ export const fr: Dict = {
|
||||
'studio.slotMask': 'Masque',
|
||||
'studio.slotFirst': 'Premier cadre',
|
||||
'studio.slotLast': 'Dernier cadre',
|
||||
'studio.slotOptional': 'Facultatif',
|
||||
'studio.slotRefVideo': 'Vidéo de référence',
|
||||
'studio.slotAudio': 'Audio de référence',
|
||||
'studio.slotPick': 'Cliquez ou déposez',
|
||||
@@ -1901,7 +1902,7 @@ export const fr: Dict = {
|
||||
'home.stageProgress': 'Progression en cinq étapes',
|
||||
'tasks.sourceWorkbench': 'Flux de création',
|
||||
'jobst.idSuffix': ' · ID de prompt ComfyUI {id}',
|
||||
'jobst.done': 'Terminé',
|
||||
'jobst.done': 'Fixé',
|
||||
'jobst.doneDetail': 'ComfyUI a terminé, résultats enregistrés',
|
||||
'jobst.dryRun': 'Substitut',
|
||||
'jobst.dryRunDetail': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
|
||||
@@ -2002,6 +2003,15 @@ export const fr: Dict = {
|
||||
'desk.ctxDismiss': 'Fermer la note d’origine',
|
||||
'desk.ctxFrom': 'Depuis « {project} »',
|
||||
'desk.ctxNoBackfill': 'Les résultats restent dans le fil et ne sont pas renvoyés vers {stage}',
|
||||
'desk.ctxBackfill': 'Une fois terminé, utilisez « Renvoyer au projet » sur la carte pour renvoyer le résultat vers {stage}',
|
||||
'desk.attachToProject': 'Renvoyer au projet',
|
||||
'desk.attachHint': 'Renvoyer ce résultat à sa source : {label}',
|
||||
'desk.attaching': 'Envoi…',
|
||||
'desk.attachedTag': 'Renvoyé',
|
||||
'desk.attachBack': 'Voir le projet',
|
||||
'desk.attachDoneToast': 'Renvoyé au projet source — ouvrez le projet pour le voir',
|
||||
'desk.attachAlreadyToast': 'Ce résultat a déjà été renvoyé ; rien de dupliqué',
|
||||
'desk.attachFailedToast': 'Échec du renvoi : {detail}',
|
||||
'desk.dayDate': '{d}/{m}',
|
||||
'desk.download': 'Télécharger',
|
||||
'desk.dropNoSlot': '{n} fichier(s) sans emplacement : ce workflow n’a pas cette entrée',
|
||||
@@ -2080,6 +2090,36 @@ export const fr: Dict = {
|
||||
'desk.wfUntested': 'Non testé',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Hier',
|
||||
// --- Bureau en trois sections (HEL-683) ---
|
||||
'desk.bandChange': "Changer de flux",
|
||||
'desk.bayCloudBody': "Les modèles cloud d'image / vidéo (gpt-image2, nano-banana, Jimeng, Kling, …) ne sont pas encore connectés à ce produit. Une fois intégrés, ils seront listés avec leurs capacités réelles — jamais en entrées inutilisables.",
|
||||
'desk.bayCloudTitle': "Génération cloud non connectée",
|
||||
'desk.bayCount': "{n} / {total} flux",
|
||||
'desk.bayFavOnly': "Favoris",
|
||||
'desk.bayMore': "Afficher {n} de plus",
|
||||
'desk.bayNoFamily': "Modèle non identifié",
|
||||
'desk.bayPending': "{n} à confirmer (usage / sortie non fixés)",
|
||||
'desk.bayReqAria': "Exigences d'entrée",
|
||||
'desk.bayReqTitle': "Exigences d'entrée",
|
||||
'desk.baySearchPh': "Rechercher nom, description, modèle, usage…",
|
||||
'desk.baySourceCloud': "Cloud",
|
||||
'desk.baySourceLabel': "Source de génération",
|
||||
'desk.baySourceLocal': "Local",
|
||||
'desk.modeAll': "Tous",
|
||||
'desk.modeEmpty': "Aucun flux utilisable pour ce mode pour l'instant",
|
||||
'desk.modeRailAria': "Modes de tâche",
|
||||
'desk.modeRailTitle': "Modes de tâche",
|
||||
'desk.pvBackCreate': "Retour à la création",
|
||||
'desk.pvCurrent': "Flux actuel",
|
||||
'desk.pvEmpty': "Choisissez un flux à gauche pour voir ses exigences d'entrée",
|
||||
'desk.pvFav': "Ajouter aux favoris",
|
||||
'desk.pvLastUsed': "Dernière utilisation",
|
||||
'desk.pvParamCount': "Paramètres",
|
||||
'desk.pvSlotsLoading': "Lecture des emplacements d’entrée…",
|
||||
'desk.pvUnfav': "Retirer des favoris",
|
||||
'desk.pvUse': "Utiliser ce flux",
|
||||
'desk.railFav': "Favoris",
|
||||
'desk.railRecent': "Récents",
|
||||
'wfd.stepParse': 'Analyser le JSON',
|
||||
'wfd.stepNodes': 'Identifier les nœuds',
|
||||
'wfd.stepModels': 'Vérifier les modèles',
|
||||
@@ -2096,4 +2136,136 @@ export const fr: Dict = {
|
||||
'wfd.nodesPickSuggest': "Choisissez l'usage et terminez le mappage des sorties ci-dessous avant d'enregistrer.",
|
||||
'wfd.showRaw': 'Voir le JSON brut',
|
||||
'wfd.hideRaw': 'Masquer le JSON brut',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'Importez par lots des flux au format UI ; cherchez, filtrez, annotez — le JSON complet ne se charge qu’à l’action',
|
||||
'wl.batchImport': 'Import par lot',
|
||||
'wl.dedupe': 'Dédupliquer',
|
||||
'wl.dedupeTitle': 'Supprimer les flux dupliqués',
|
||||
'wl.dedupeConfirm': 'Les entrées au nom ET contenu identiques seront supprimées (le premier import est conservé). Même nom mais contenu différent = votre variante, intact. Continuer ?',
|
||||
'wl.dedupeDone': '{n} flux dupliqués supprimés',
|
||||
'wl.dedupeNone': 'Aucun flux dupliqué trouvé',
|
||||
'wl.favCount': '{n} favoris',
|
||||
'wl.searchPh': 'Rechercher nom, description, tags, modèle… (touche /)',
|
||||
'wl.filterLabel': 'Filtres',
|
||||
'wl.filterAll': 'Tous',
|
||||
'wl.filterFav': 'Favoris',
|
||||
'wl.filterPending': 'À confirmer',
|
||||
'wl.filterDisabled': 'Désactivés',
|
||||
'wl.favAria': 'Mettre en favori',
|
||||
'wl.unfavAria': 'Retirer des favoris',
|
||||
'wl.categoryLabel': 'Catégorie',
|
||||
'wl.categoryAll': 'Toutes les catégories',
|
||||
'wl.familyLabel': 'Famille de modèle',
|
||||
'wl.familyAll': 'Toutes les familles',
|
||||
'wl.mediaLabel': 'Média',
|
||||
'wl.mediaAll': 'Tous les médias',
|
||||
'wl.mediaImage': 'Image',
|
||||
'wl.mediaVideo': 'Vidéo',
|
||||
'wl.mediaAudio': 'Audio',
|
||||
'wl.mediaIn': 'Entrée',
|
||||
'wl.mediaOut': 'Sortie',
|
||||
'wl.sortLabel': 'Tri',
|
||||
'wl.sortRecent': 'Favoris / récents',
|
||||
'wl.sortName': 'Nom',
|
||||
'wl.resetFilters': 'Effacer les filtres',
|
||||
'wl.noMatch': 'Aucun flux ne correspond aux filtres',
|
||||
'wl.resultCount': '{n} sur {m}',
|
||||
'wl.selected': '{n} sélectionnés',
|
||||
'wl.batchLabel': 'Actions groupées',
|
||||
'wl.batchAnnotate': 'Annoter',
|
||||
'wl.batchEnable': 'Activer',
|
||||
'wl.batchDisable': 'Désactiver',
|
||||
'wl.clearSel': 'Effacer la sélection',
|
||||
'wl.batchDone': '{n} flux mis à jour',
|
||||
'wl.batchPartial': '{n} réussis, {m} échoués (usage et sortie à confirmer d’abord)',
|
||||
'wl.selectAll': 'Sélectionner tous les flux listés',
|
||||
'wl.rowCheckAria': 'Sélectionner {name}',
|
||||
'wl.headMeta': 'Métadonnées',
|
||||
'wl.headActions': 'Actions',
|
||||
'wl.enableAria': 'Activer ou désactiver {name}',
|
||||
'wl.enableBlocked': 'Confirmez l’usage et les nœuds de sortie avant activation',
|
||||
'wl.editMeta': 'Modifier les métadonnées',
|
||||
'wl.lastUsed': 'Dernière utilisation',
|
||||
'wl.moreCount': 'Afficher {n} de plus ({m} au total)',
|
||||
'wfCat.basic': 'Traitement de base',
|
||||
'wfCat.audioTts': 'Génération de voix',
|
||||
'wfCat.music': 'Génération de musique',
|
||||
'wfCat.image': 'Génération d’images',
|
||||
'wfCat.video': 'Génération de vidéo',
|
||||
'wfCat.digitalHuman': 'Humain numérique',
|
||||
'wfCat.motionTransfer': 'Transfert de mouvement',
|
||||
'md.title': 'Métadonnées du flux',
|
||||
'md.batchTitle': 'Annotation groupée ({n})',
|
||||
'md.batchHint': 'Seuls les champs remplis sont appliqués ; renommage par rechercher→remplacer, tags ajoutés.',
|
||||
'md.name': 'Nom affiché',
|
||||
'md.nameFind': 'Rechercher',
|
||||
'md.nameFindPh': 'Texte à remplacer…',
|
||||
'md.nameReplace': 'Remplacer par',
|
||||
'md.nameReplacePh': 'Texte de remplacement…',
|
||||
'md.nameHint': 'Laissez « Rechercher » vide pour ne pas renommer.',
|
||||
'md.description': 'Description',
|
||||
'md.descriptionPh': 'Une ligne sur ce que fait ce flux…',
|
||||
'md.tags': 'Tags',
|
||||
'md.tagsPh': 'ex. rapide, haute qualité',
|
||||
'md.tagsHint': 'Séparés par des virgules ; ajoutés en mode groupé.',
|
||||
'md.category': 'Catégorie',
|
||||
'md.modelFamily': 'Famille de modèle',
|
||||
'md.apply': 'Appliquer',
|
||||
'md.applied': '{n} flux mis à jour',
|
||||
'md.appliedOne': 'Mis à jour',
|
||||
'md.partial': '{n} réussis, {m} échoués',
|
||||
'wiz.title': 'Import de flux par lot',
|
||||
'wiz.stepSource': 'Source',
|
||||
'wiz.stepReport': 'Rapport de test',
|
||||
'wiz.stepResult': 'Résultat',
|
||||
'wiz.sourceLabel': 'Source d’import',
|
||||
'wiz.modeScan': 'Scanner un dossier serveur',
|
||||
'wiz.modeFiles': 'Choisir des fichiers locaux',
|
||||
'wiz.rootLabel': 'Dossier serveur',
|
||||
'wiz.rootHint': 'Chemin d’un dossier sur le serveur (ex. example_ComfyUI_workflows du dépôt) ; les JSON au format UI sont scannés récursivement, le format API et les autres sont ignorés.',
|
||||
'wiz.preflight': 'Pré-vérification sur un ComfyUI réel (facultatif)',
|
||||
'wiz.preflightNone': 'Passer (conversion hors ligne)',
|
||||
'wiz.preflightHint': 'Avec une connexion choisie, les graphes convertis sont vérifiés nœud par nœud.',
|
||||
'wiz.pickFiles': 'Fichiers locaux',
|
||||
'wiz.pickFilesBtn': 'Choisir des fichiers JSON (plusieurs)',
|
||||
'wiz.pickFilesHint': 'Choisissez des flux ComfyUI au format UI ; sélection multiple possible.',
|
||||
'wiz.parsed': '{n} fichiers lus',
|
||||
'wiz.notUi': '{n} fichiers ne sont pas au format UI (pas de tableau nodes), ignorés',
|
||||
'wiz.noFiles': 'Choisissez d’abord des fichiers à importer',
|
||||
'wiz.runDry': 'Lancer le test (rien n’est enregistré)',
|
||||
'wiz.running': 'Vérification…',
|
||||
'wiz.total': '{n} fichiers',
|
||||
'wiz.ok': 'OK {n}',
|
||||
'wiz.warn': 'Avertissements {n}',
|
||||
'wiz.fail': 'Échecs {n}',
|
||||
'wiz.confirmCount': 'À vérifier {n}',
|
||||
'wiz.importable': 'Importables {n}',
|
||||
'wiz.reportHint': 'Le test n’écrit rien. Les avertissements s’importent activés dès qu’un usage est fourni ; les fichiers à vérifier ou en échec ne sont jamais importés.',
|
||||
'wiz.filterLabel': 'Filtres du rapport',
|
||||
'wiz.filterWarn': 'Avertissements seuls',
|
||||
'wiz.filterBlocked': 'Non importables',
|
||||
'wiz.searchPh': 'Rechercher un nom de fichier…',
|
||||
'wiz.includeAria': 'Importer {name}',
|
||||
'wiz.skipReason': 'Non importable : {reason}',
|
||||
'wiz.issues': '{n} notes de conversion',
|
||||
'wiz.usagePick': 'Usage :',
|
||||
'wiz.usageNone': 'Passer pour l’instant (à confirmer après import)',
|
||||
'wiz.selectedN': '{n} fichiers seront importés',
|
||||
'wiz.excludedM': '{m} non importables',
|
||||
'wiz.import': 'Importer',
|
||||
'wiz.importing': 'Import…',
|
||||
'wiz.back': 'Retour',
|
||||
'wiz.imported': '{n} importés',
|
||||
'wiz.skipped': '{n} ignorés',
|
||||
'wiz.conflict': 'Existe déjà (doublon ignoré)',
|
||||
'wiz.usageApplied': 'Usage fourni pour {n} nouveaux imports',
|
||||
'wiz.done': 'Terminé',
|
||||
'wiz.again': 'Importer un autre lot',
|
||||
'wiz.resultNote': 'Les fichiers au contenu identique sont ignorés automatiquement — relancer ne crée jamais de doublons.',
|
||||
'wiz.importedToast': '{n} flux importés',
|
||||
'wiz.warnTag': 'Avertissement',
|
||||
'wiz.confirmTag': 'À vérifier',
|
||||
'wiz.preflightTag': 'Pré-vérification échouée',
|
||||
'wiz.importedTag': 'Importé #{id}',
|
||||
};
|
||||
|
||||
@@ -1556,6 +1556,7 @@ export const ja: Dict = {
|
||||
'studio.slotMask': 'マスク',
|
||||
'studio.slotFirst': '始端フレーム',
|
||||
'studio.slotLast': '終端フレーム',
|
||||
'studio.slotOptional': '任意',
|
||||
'studio.slotRefVideo': '参照動画',
|
||||
'studio.slotAudio': '参照音声',
|
||||
'studio.slotPick': 'クリックまたはドロップ',
|
||||
@@ -1899,7 +1900,7 @@ export const ja: Dict = {
|
||||
'home.stageProgress': '五段階の進捗',
|
||||
'tasks.sourceWorkbench': '制作フロー',
|
||||
'jobst.idSuffix': ' · ComfyUI プロンプト ID {id}',
|
||||
'jobst.done': '完了',
|
||||
'jobst.done': '定着',
|
||||
'jobst.doneDetail': 'ComfyUI の実行が完了し、結果を保存しました',
|
||||
'jobst.dryRun': 'プレースホルダー',
|
||||
'jobst.dryRunDetail': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
|
||||
@@ -2000,6 +2001,15 @@ export const ja: Dict = {
|
||||
'desk.ctxDismiss': '出典の表示を閉じる',
|
||||
'desk.ctxFrom': '『{project}』から',
|
||||
'desk.ctxNoBackfill': '結果は生成台の記録にのみ残り、{stage}へ自動で戻りません',
|
||||
'desk.ctxBackfill': '生成が完了したら、結果カードの「プロジェクトへ戻す」で{stage}に戻せます',
|
||||
'desk.attachToProject': 'プロジェクトへ戻す',
|
||||
'desk.attachHint': 'この結果を戻し先に送る:{label}',
|
||||
'desk.attaching': '戻しています…',
|
||||
'desk.attachedTag': '戻しました',
|
||||
'desk.attachBack': 'プロジェクトへ',
|
||||
'desk.attachDoneToast': '送り元プロジェクトに戻しました。プロジェクトを開くと確認できます',
|
||||
'desk.attachAlreadyToast': 'この結果はすでに戻されています。重複は作られていません',
|
||||
'desk.attachFailedToast': '戻せませんでした:{detail}',
|
||||
'desk.dayDate': '{m}月{d}日',
|
||||
'desk.download': 'ダウンロード',
|
||||
'desk.dropNoSlot': '{n} 件のファイルを入れる枠がありません:このワークフローにはその入力がありません',
|
||||
@@ -2078,6 +2088,36 @@ export const ja: Dict = {
|
||||
'desk.wfUntested': '未テスト',
|
||||
'desk.workflow': 'ワークフロー',
|
||||
'desk.yesterday': '昨日',
|
||||
// --- 三段構成デスク (HEL-683) ---
|
||||
'desk.bandChange': "ワークフローを変更",
|
||||
'desk.bayCloudBody': "クラウドの画像/動画モデル(gpt-image2、nano-banana、即夢、Kling など)はまだ本製品に接続されていません。接続後は実際の機能とともに一覧表示され、使えない項目が並ぶことはありません。",
|
||||
'desk.bayCloudTitle': "クラウド生成は未接続",
|
||||
'desk.bayCount': "{n} / {total} 件のワークフロー",
|
||||
'desk.bayFavOnly': "お気に入り",
|
||||
'desk.bayMore': "さらに {n} 件表示",
|
||||
'desk.bayNoFamily': "モデル未識別",
|
||||
'desk.bayPending': "確認待ち {n} 件(用途/出力未確定)",
|
||||
'desk.bayReqAria': "入力要件",
|
||||
'desk.bayReqTitle': "入力要件",
|
||||
'desk.baySearchPh': "名前・説明・モデル・用途を検索…",
|
||||
'desk.baySourceCloud': "クラウド",
|
||||
'desk.baySourceLabel': "生成ソース",
|
||||
'desk.baySourceLocal': "ローカル",
|
||||
'desk.modeAll': "すべて",
|
||||
'desk.modeEmpty': "このモードで利用できるワークフローはまだありません",
|
||||
'desk.modeRailAria': "タスクモード",
|
||||
'desk.modeRailTitle': "タスクモード",
|
||||
'desk.pvBackCreate': "作成に戻る",
|
||||
'desk.pvCurrent': "現在のワークフロー",
|
||||
'desk.pvEmpty': "左でワークフローを選ぶと入力要件が表示されます",
|
||||
'desk.pvFav': "お気に入りに追加",
|
||||
'desk.pvLastUsed': "最終使用",
|
||||
'desk.pvParamCount': "パラメータ数",
|
||||
'desk.pvSlotsLoading': "入力スロットを読み込み中…",
|
||||
'desk.pvUnfav': "お気に入りから外す",
|
||||
'desk.pvUse': "このワークフローを使う",
|
||||
'desk.railFav': "お気に入り",
|
||||
'desk.railRecent': "最近の使用",
|
||||
'wfd.stepParse': 'JSON を解析',
|
||||
'wfd.stepNodes': 'ノードを識別',
|
||||
'wfd.stepModels': 'モデルを確認',
|
||||
@@ -2094,4 +2134,136 @@ export const ja: Dict = {
|
||||
'wfd.nodesPickSuggest': '保存前に下で用途を選び、出力マッピングを完了してください。',
|
||||
'wfd.showRaw': '元の JSON を見る',
|
||||
'wfd.hideRaw': '元の JSON を閉じる',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'UI 形式ワークフローを一括取り込み、検索・絞り込み・注釈管理。完全な JSON は操作時のみ読み込み',
|
||||
'wl.batchImport': '一括取り込み',
|
||||
'wl.dedupe': '重複を削除',
|
||||
'wl.dedupeTitle': '重複ワークフローの削除',
|
||||
'wl.dedupeConfirm': '名前と内容が完全に同一の項目を削除します(最初に取り込んだものを残す)。名前が同じでも内容が異なるものは触れません。実行しますか?',
|
||||
'wl.dedupeDone': '重複 {n} 件を削除しました',
|
||||
'wl.dedupeNone': '重複するワークフローはありません',
|
||||
'wl.favCount': 'お気に入り {n} 件',
|
||||
'wl.searchPh': '名前・説明・タグ・モデルを検索…(/ で移動)',
|
||||
'wl.filterLabel': '絞り込み',
|
||||
'wl.filterAll': 'すべて',
|
||||
'wl.filterFav': 'お気に入り',
|
||||
'wl.filterPending': '要確認',
|
||||
'wl.filterDisabled': '無効',
|
||||
'wl.favAria': 'お気に入りに追加',
|
||||
'wl.unfavAria': 'お気に入りから外す',
|
||||
'wl.categoryLabel': 'カテゴリ',
|
||||
'wl.categoryAll': 'すべてのカテゴリ',
|
||||
'wl.familyLabel': 'モデルファミリー',
|
||||
'wl.familyAll': 'すべてのファミリー',
|
||||
'wl.mediaLabel': 'メディア',
|
||||
'wl.mediaAll': 'すべてのメディア',
|
||||
'wl.mediaImage': '画像',
|
||||
'wl.mediaVideo': '動画',
|
||||
'wl.mediaAudio': '音声',
|
||||
'wl.mediaIn': '入力',
|
||||
'wl.mediaOut': '出力',
|
||||
'wl.sortLabel': '並び替え',
|
||||
'wl.sortRecent': 'お気に入り / 最近',
|
||||
'wl.sortName': '名前',
|
||||
'wl.resetFilters': '絞り込みを解除',
|
||||
'wl.noMatch': '条件に合うワークフローがありません',
|
||||
'wl.resultCount': '{n} / {m} 件',
|
||||
'wl.selected': '{n} 件選択中',
|
||||
'wl.batchLabel': '一括操作',
|
||||
'wl.batchAnnotate': '一括注釈',
|
||||
'wl.batchEnable': '有効化',
|
||||
'wl.batchDisable': '無効化',
|
||||
'wl.clearSel': '選択を解除',
|
||||
'wl.batchDone': '{n} 件を更新しました',
|
||||
'wl.batchPartial': '{n} 件成功、{m} 件失敗(用途と出力の確認が必要)',
|
||||
'wl.selectAll': '表示中のワークフローをすべて選択',
|
||||
'wl.rowCheckAria': '{name} を選択',
|
||||
'wl.headMeta': 'メタデータ',
|
||||
'wl.headActions': '操作',
|
||||
'wl.enableAria': '{name} を有効化・無効化',
|
||||
'wl.enableBlocked': '用途と出力ノードを確定してから有効化してください',
|
||||
'wl.editMeta': 'メタデータを編集',
|
||||
'wl.lastUsed': '最終使用',
|
||||
'wl.moreCount': 'さらに {n} 件表示(全 {m} 件)',
|
||||
'wfCat.basic': '基本処理',
|
||||
'wfCat.audioTts': '音声生成',
|
||||
'wfCat.music': '音楽生成',
|
||||
'wfCat.image': '画像生成',
|
||||
'wfCat.video': '動画生成',
|
||||
'wfCat.digitalHuman': 'デジタルヒューマン',
|
||||
'wfCat.motionTransfer': 'モーション転送',
|
||||
'md.title': 'ワークフローの注釈',
|
||||
'md.batchTitle': '一括注釈({n} 件)',
|
||||
'md.batchHint': '入力したフィールドのみ適用。名前は検索→置換で変更、タグは追加されます。',
|
||||
'md.name': '表示名',
|
||||
'md.nameFind': '検索',
|
||||
'md.nameFindPh': '置換する文字…',
|
||||
'md.nameReplace': '置換後',
|
||||
'md.nameReplacePh': '置換する文字…',
|
||||
'md.nameHint': '「検索」が空なら名前は変更されません。',
|
||||
'md.description': '説明',
|
||||
'md.descriptionPh': 'このワークフローの用途を一行で…',
|
||||
'md.tags': 'タグ',
|
||||
'md.tagsPh': '例: 高速, 高品質',
|
||||
'md.tagsHint': 'カンマ区切り。一括モードでは追加されます。',
|
||||
'md.category': 'カテゴリ',
|
||||
'md.modelFamily': 'モデルファミリー',
|
||||
'md.apply': '適用',
|
||||
'md.applied': '{n} 件を更新しました',
|
||||
'md.appliedOne': '更新しました',
|
||||
'md.partial': '{n} 件成功、{m} 件失敗',
|
||||
'wiz.title': 'ワークフローの一括取り込み',
|
||||
'wiz.stepSource': '取り込み元',
|
||||
'wiz.stepReport': '事前チェック',
|
||||
'wiz.stepResult': '結果',
|
||||
'wiz.sourceLabel': '取り込み元',
|
||||
'wiz.modeScan': 'サーバーディレクトリをスキャン',
|
||||
'wiz.modeFiles': 'ローカルファイルを選択',
|
||||
'wiz.rootLabel': 'サーバーディレクトリ',
|
||||
'wiz.rootHint': 'サーバー上のディレクトリパス(例: リポジトリの example_ComfyUI_workflows)。UI 形式の JSON を再帰的にスキャンし、API 形式などは自動的にスキップします。',
|
||||
'wiz.preflight': '実機 ComfyUI で事前チェック(任意)',
|
||||
'wiz.preflightNone': 'スキップ(オフライン変換)',
|
||||
'wiz.preflightHint': '接続を選ぶと、変換結果をその ComfyUI のノート・モデルと照合します。',
|
||||
'wiz.pickFiles': 'ローカルファイル',
|
||||
'wiz.pickFilesBtn': 'JSON ファイルを選択(複数可)',
|
||||
'wiz.pickFilesHint': 'ComfyUI UI 形式のワークフロー JSON を選んでください。複数選択できます。',
|
||||
'wiz.parsed': '{n} ファイルを読み込みました',
|
||||
'wiz.notUi': '{n} ファイルは UI 形式でないため(nodes 配列なし)スキップしました',
|
||||
'wiz.noFiles': '先に取り込むファイルを選んでください',
|
||||
'wiz.runDry': '事前チェックを実行(保存しません)',
|
||||
'wiz.running': 'チェック中…',
|
||||
'wiz.total': '全体 {n} ファイル',
|
||||
'wiz.ok': '正常 {n}',
|
||||
'wiz.warn': '警告 {n}',
|
||||
'wiz.fail': '失敗 {n}',
|
||||
'wiz.confirmCount': '要確認 {n}',
|
||||
'wiz.importable': '取り込み可 {n}',
|
||||
'wiz.reportHint': '事前チェックは何も書き込みません。警告は用途を補えば有効な状態で取り込まれ、要確認・失敗のファイルは取り込まれません。',
|
||||
'wiz.filterLabel': 'レポートの絞り込み',
|
||||
'wiz.filterWarn': '警告のみ',
|
||||
'wiz.filterBlocked': '取り込み不可',
|
||||
'wiz.searchPh': 'ファイル名を検索…',
|
||||
'wiz.includeAria': '{name} を取り込む',
|
||||
'wiz.skipReason': '取り込み不可: {reason}',
|
||||
'wiz.issues': '変換メモ {n} 件',
|
||||
'wiz.usagePick': '用途:',
|
||||
'wiz.usageNone': '今はスキップ(取り込み後に要確認)',
|
||||
'wiz.selectedN': '{n} ファイルを取り込みます',
|
||||
'wiz.excludedM': '{m} 件は取り込み不可',
|
||||
'wiz.import': '取り込む',
|
||||
'wiz.importing': '取り込み中…',
|
||||
'wiz.back': '戻る',
|
||||
'wiz.imported': '{n} 件取り込み完了',
|
||||
'wiz.skipped': '{n} 件スキップ',
|
||||
'wiz.conflict': '既に存在(重複は自動スキップ)',
|
||||
'wiz.usageApplied': '新規取り込み {n} 件に用途を設定しました',
|
||||
'wiz.done': '完了',
|
||||
'wiz.again': '続けて取り込む',
|
||||
'wiz.resultNote': '内容が同一のファイルは自動スキップされ、再実行しても重複は作られません。',
|
||||
'wiz.importedToast': '{n} 件のワークフローを取り込みました',
|
||||
'wiz.warnTag': '警告',
|
||||
'wiz.confirmTag': '要確認',
|
||||
'wiz.preflightTag': '事前チェック不合格',
|
||||
'wiz.importedTag': '取り込み済み #{id}',
|
||||
};
|
||||
|
||||
@@ -1556,6 +1556,7 @@ export const ko: Dict = {
|
||||
'studio.slotMask': '마스크',
|
||||
'studio.slotFirst': '첫 프레임',
|
||||
'studio.slotLast': '마지막 프레임',
|
||||
'studio.slotOptional': '선택',
|
||||
'studio.slotRefVideo': '참조 비디오',
|
||||
'studio.slotAudio': '참조 오디오',
|
||||
'studio.slotPick': '클릭하거나 끌어다 놓기',
|
||||
@@ -1899,7 +1900,7 @@ export const ko: Dict = {
|
||||
'home.stageProgress': '5단계 진행',
|
||||
'tasks.sourceWorkbench': '제작 흐름',
|
||||
'jobst.idSuffix': ' · ComfyUI 프롬프트 ID {id}',
|
||||
'jobst.done': '완료',
|
||||
'jobst.done': '정착',
|
||||
'jobst.doneDetail': 'ComfyUI 실행이 끝나 결과를 저장했습니다',
|
||||
'jobst.dryRun': '자리표시자',
|
||||
'jobst.dryRunDetail': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
|
||||
@@ -2000,6 +2001,15 @@ export const ko: Dict = {
|
||||
'desk.ctxDismiss': '출처 안내 닫기',
|
||||
'desk.ctxFrom': '《{project}》에서',
|
||||
'desk.ctxNoBackfill': '결과는 생성대 기록에만 남고 {stage}에 자동으로 반영되지 않습니다',
|
||||
'desk.ctxBackfill': '생성이 끝나면 결과 카드의 «프로젝트로 보내기»로 {stage}에 돌려보낼 수 있습니다',
|
||||
'desk.attachToProject': '프로젝트로 보내기',
|
||||
'desk.attachHint': '이 결과를 출처로 보내기: {label}',
|
||||
'desk.attaching': '보내는 중…',
|
||||
'desk.attachedTag': '보냄',
|
||||
'desk.attachBack': '프로젝트로',
|
||||
'desk.attachDoneToast': '원본 프로젝트로 보냈습니다. 프로젝트에서 확인하세요',
|
||||
'desk.attachAlreadyToast': '이 결과는 이미 보내졌습니다. 중복이 만들어지지 않았습니다',
|
||||
'desk.attachFailedToast': '보내기 실패: {detail}',
|
||||
'desk.dayDate': '{m}월 {d}일',
|
||||
'desk.download': '다운로드',
|
||||
'desk.dropNoSlot': '파일 {n}개를 넣을 슬롯이 없습니다: 이 워크플로에는 해당 입력이 없습니다',
|
||||
@@ -2078,6 +2088,36 @@ export const ko: Dict = {
|
||||
'desk.wfUntested': '테스트 안 함',
|
||||
'desk.workflow': '워크플로',
|
||||
'desk.yesterday': '어제',
|
||||
// --- 3단 구성 데스크 (HEL-683) ---
|
||||
'desk.bandChange': "워크플로 변경",
|
||||
'desk.bayCloudBody': "클라우드 이미지 / 동영상 모델(gpt-image2, nano-banana, 지멍, 클링 등)은 아직 이 제품에 연결되지 않았습니다. 연결 후 실제 기능과 함께 표시되며, 사용할 수 없는 항목은 나타나지 않습니다.",
|
||||
'desk.bayCloudTitle': "클라우드 생성 미연결",
|
||||
'desk.bayCount': "{n} / {total}개 워크플로",
|
||||
'desk.bayFavOnly': "즐겨찾기",
|
||||
'desk.bayMore': "{n}개 더 보기",
|
||||
'desk.bayNoFamily': "미식별 모델",
|
||||
'desk.bayPending': "확인 대기 {n}개(용도 / 출력 미확정)",
|
||||
'desk.bayReqAria': "입력 요구 사항",
|
||||
'desk.bayReqTitle': "입력 요구 사항",
|
||||
'desk.baySearchPh': "이름, 설명, 모델, 용도 검색…",
|
||||
'desk.baySourceCloud': "클라우드",
|
||||
'desk.baySourceLabel': "생성 소스",
|
||||
'desk.baySourceLocal': "로컬",
|
||||
'desk.modeAll': "전체",
|
||||
'desk.modeEmpty': "이 모드에서 사용 가능한 워크플로가 아직 없습니다",
|
||||
'desk.modeRailAria': "작업 모드",
|
||||
'desk.modeRailTitle': "작업 모드",
|
||||
'desk.pvBackCreate': "만들기로 돌아가기",
|
||||
'desk.pvCurrent': "현재 워크플로",
|
||||
'desk.pvEmpty': "왼쪽에서 워크플로를 선택하면 입력 요구 사항이 표시됩니다",
|
||||
'desk.pvFav': "즐겨찾기에 추가",
|
||||
'desk.pvLastUsed': "최근 사용",
|
||||
'desk.pvParamCount': "매개변수 수",
|
||||
'desk.pvSlotsLoading': "입력 슬롯 읽는 중…",
|
||||
'desk.pvUnfav': "즐겨찾기에서 제거",
|
||||
'desk.pvUse': "이 워크플로 사용",
|
||||
'desk.railFav': "즐겨찾기",
|
||||
'desk.railRecent': "최근 사용",
|
||||
'wfd.stepParse': 'JSON 파싱',
|
||||
'wfd.stepNodes': '노드 식별',
|
||||
'wfd.stepModels': '모델 확인',
|
||||
@@ -2094,4 +2134,136 @@ export const ko: Dict = {
|
||||
'wfd.nodesPickSuggest': '저장하기 전에 아래에서 용도를 선택하고 출력 매핑을 완료하세요.',
|
||||
'wfd.showRaw': '원본 JSON 보기',
|
||||
'wfd.hideRaw': '원본 JSON 접기',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': 'UI 형식 워크플로 일괄 가져오기, 검색·필터·주석 관리. 전체 JSON은 작업 시에만 로드',
|
||||
'wl.batchImport': '일괄 가져오기',
|
||||
'wl.dedupe': '중복 정리',
|
||||
'wl.dedupeTitle': '중복 워크플로 삭제',
|
||||
'wl.dedupeConfirm': '이름과 내용이 완전히 같은 항목을 삭제합니다(가장 먼저 가져온 것 유지). 이름이 같아도 내용이 다르면 그대로 둡니다. 계속할까요?',
|
||||
'wl.dedupeDone': '중복 워크플로 {n}건 삭제',
|
||||
'wl.dedupeNone': '중복 워크플로가 없습니다',
|
||||
'wl.favCount': '즐겨찾기 {n}건',
|
||||
'wl.searchPh': '이름·설명·태그·모델 검색… (/ 키)',
|
||||
'wl.filterLabel': '필터',
|
||||
'wl.filterAll': '전체',
|
||||
'wl.filterFav': '즐겨찾기',
|
||||
'wl.filterPending': '확인 필요',
|
||||
'wl.filterDisabled': '비활성',
|
||||
'wl.favAria': '즐겨찾기 추가',
|
||||
'wl.unfavAria': '즐겨찾기 해제',
|
||||
'wl.categoryLabel': '분류',
|
||||
'wl.categoryAll': '전체 분류',
|
||||
'wl.familyLabel': '모델 계열',
|
||||
'wl.familyAll': '전체 계열',
|
||||
'wl.mediaLabel': '미디어',
|
||||
'wl.mediaAll': '전체 미디어',
|
||||
'wl.mediaImage': '이미지',
|
||||
'wl.mediaVideo': '영상',
|
||||
'wl.mediaAudio': '오디오',
|
||||
'wl.mediaIn': '입력',
|
||||
'wl.mediaOut': '출력',
|
||||
'wl.sortLabel': '정렬',
|
||||
'wl.sortRecent': '즐겨찾기 / 최근',
|
||||
'wl.sortName': '이름',
|
||||
'wl.resetFilters': '필터 해제',
|
||||
'wl.noMatch': '조건에 맞는 워크플로가 없습니다',
|
||||
'wl.resultCount': '{n} / {m}',
|
||||
'wl.selected': '{n}건 선택됨',
|
||||
'wl.batchLabel': '일괄 작업',
|
||||
'wl.batchAnnotate': '일괄 주석',
|
||||
'wl.batchEnable': '활성화',
|
||||
'wl.batchDisable': '비활성화',
|
||||
'wl.clearSel': '선택 해제',
|
||||
'wl.batchDone': '{n}건 업데이트',
|
||||
'wl.batchPartial': '{n}건 성공, {m}건 실패(용도와 출력 먼저 확정)',
|
||||
'wl.selectAll': '나열된 워크플로 모두 선택',
|
||||
'wl.rowCheckAria': '{name} 선택',
|
||||
'wl.headMeta': '메타데이터',
|
||||
'wl.headActions': '작업',
|
||||
'wl.enableAria': '{name} 활성화/비활성화',
|
||||
'wl.enableBlocked': '활성화 전에 용도와 출력 노드를 확정하세요',
|
||||
'wl.editMeta': '메타데이터 편집',
|
||||
'wl.lastUsed': '마지막 사용',
|
||||
'wl.moreCount': '{n}건 더 보기(전체 {m}건)',
|
||||
'wfCat.basic': '기본 처리',
|
||||
'wfCat.audioTts': '음성 생성',
|
||||
'wfCat.music': '음악 생성',
|
||||
'wfCat.image': '이미지 생성',
|
||||
'wfCat.video': '영상 생성',
|
||||
'wfCat.digitalHuman': '디지털 휴먼',
|
||||
'wfCat.motionTransfer': '모션 전이',
|
||||
'md.title': '워크플로 주석',
|
||||
'md.batchTitle': '일괄 주석({n}건)',
|
||||
'md.batchHint': '입력한 필드만 적용됩니다. 이름은 찾기→바꾸기로 변경, 태그는 추가됩니다.',
|
||||
'md.name': '표시 이름',
|
||||
'md.nameFind': '찾기',
|
||||
'md.nameFindPh': '바꿀 문자…',
|
||||
'md.nameReplace': '바꾸기',
|
||||
'md.nameReplacePh': '대체 문자…',
|
||||
'md.nameHint': '「찾기」를 비우면 이름이 유지됩니다.',
|
||||
'md.description': '설명',
|
||||
'md.descriptionPh': '이 워크플로가 하는 일을 한 줄로…',
|
||||
'md.tags': '태그',
|
||||
'md.tagsPh': '예: 빠름, 고품질',
|
||||
'md.tagsHint': '쉼표로 구분. 일괄 모드에서는 추가됩니다.',
|
||||
'md.category': '분류',
|
||||
'md.modelFamily': '모델 계열',
|
||||
'md.apply': '적용',
|
||||
'md.applied': '{n}건 업데이트',
|
||||
'md.appliedOne': '업데이트됨',
|
||||
'md.partial': '{n}건 성공, {m}건 실패',
|
||||
'wiz.title': '워크플로 일괄 가져오기',
|
||||
'wiz.stepSource': '원본',
|
||||
'wiz.stepReport': '사전 검사',
|
||||
'wiz.stepResult': '결과',
|
||||
'wiz.sourceLabel': '가져오기 원본',
|
||||
'wiz.modeScan': '서버 디렉터리 스캔',
|
||||
'wiz.modeFiles': '로컬 파일 선택',
|
||||
'wiz.rootLabel': '서버 디렉터리',
|
||||
'wiz.rootHint': '서버의 디렉터리 경로(예: 저장소의 example_ComfyUI_workflows). UI 형식 JSON을 재귀적으로 스캔하며 API 형식 등은 건너뜁니다.',
|
||||
'wiz.preflight': '실제 ComfyUI 사전 검사(선택)',
|
||||
'wiz.preflightNone': '건너뛰기(오프라인 변환)',
|
||||
'wiz.preflightHint': '연결을 선택하면 변환 결과를 해당 ComfyUI의 노드·모델과 대조합니다.',
|
||||
'wiz.pickFiles': '로컬 파일',
|
||||
'wiz.pickFilesBtn': 'JSON 파일 선택(여러 개)',
|
||||
'wiz.pickFilesHint': 'ComfyUI UI 형식 워크플로 JSON을 선택하세요. 다중 선택이 가능합니다.',
|
||||
'wiz.parsed': '{n}개 파일 읽음',
|
||||
'wiz.notUi': '{n}개 파일이 UI 형식이 아니어서(nodes 배열 없음) 건너뛰었습니다',
|
||||
'wiz.noFiles': '먼저 가져올 파일을 선택하세요',
|
||||
'wiz.runDry': '사전 검사 실행(저장 안 함)',
|
||||
'wiz.running': '검사 중…',
|
||||
'wiz.total': '총 {n}개 파일',
|
||||
'wiz.ok': '정상 {n}',
|
||||
'wiz.warn': '경고 {n}',
|
||||
'wiz.fail': '실패 {n}',
|
||||
'wiz.confirmCount': '확인 필요 {n}',
|
||||
'wiz.importable': '가져오기 가능 {n}',
|
||||
'wiz.reportHint': '사전 검사는 아무것도 저장하지 않습니다. 경고 항목은 용도를 지정하면 활성화 상태로 가져오며, 확인 필요·실패 항목은 절대 가져오지 않습니다.',
|
||||
'wiz.filterLabel': '보고서 필터',
|
||||
'wiz.filterWarn': '경고만',
|
||||
'wiz.filterBlocked': '가져올 수 없음',
|
||||
'wiz.searchPh': '파일 이름 검색…',
|
||||
'wiz.includeAria': '{name} 가져오기',
|
||||
'wiz.skipReason': '가져올 수 없음: {reason}',
|
||||
'wiz.issues': '변환 메모 {n}건',
|
||||
'wiz.usagePick': '용도:',
|
||||
'wiz.usageNone': '지금은 건너뜀(가져온 뒤 확인 필요)',
|
||||
'wiz.selectedN': '{n}개 파일을 가져옵니다',
|
||||
'wiz.excludedM': '{m}개 가져올 수 없음',
|
||||
'wiz.import': '가져오기',
|
||||
'wiz.importing': '가져오는 중…',
|
||||
'wiz.back': '이전',
|
||||
'wiz.imported': '{n}건 가져옴',
|
||||
'wiz.skipped': '{n}건 건너뜀',
|
||||
'wiz.conflict': '이미 있음(중복 자동 건너뜀)',
|
||||
'wiz.usageApplied': '새로 가져온 {n}건에 용도 지정',
|
||||
'wiz.done': '완료',
|
||||
'wiz.again': '다시 가져오기',
|
||||
'wiz.resultNote': '내용이 같은 파일은 자동으로 건너뛰며, 다시 실행해도 중복이 생기지 않습니다.',
|
||||
'wiz.importedToast': '워크플로 {n}건을 가져왔습니다',
|
||||
'wiz.warnTag': '경고',
|
||||
'wiz.confirmTag': '확인 필요',
|
||||
'wiz.preflightTag': '사전 검사 실패',
|
||||
'wiz.importedTag': '가져옴 #{id}',
|
||||
};
|
||||
|
||||
@@ -1555,6 +1555,7 @@ export const zh: Dict = {
|
||||
'studio.slotMask': '遮罩',
|
||||
'studio.slotFirst': '首帧',
|
||||
'studio.slotLast': '尾帧',
|
||||
'studio.slotOptional': '可选',
|
||||
'studio.slotRefVideo': '参考视频',
|
||||
'studio.slotAudio': '参考音频',
|
||||
'studio.slotPick': '点击或拖入',
|
||||
@@ -1772,11 +1773,11 @@ export const zh: Dict = {
|
||||
'workbench.pickerFootHint': '也可以在项目总览直接「从创意开始」或「导入剧本」进入工作台。',
|
||||
'workbench.stageProgress': '五段进度',
|
||||
'workbench.activeJobs': '{n} 个任务进行中',
|
||||
'workbench.stage.script': '剧本创作',
|
||||
'workbench.stage.style': '视觉设计',
|
||||
'workbench.stage.script': '故事构思',
|
||||
'workbench.stage.style': '视觉方向',
|
||||
'workbench.stage.storyboard': '分镜设计',
|
||||
'workbench.stage.assets': '素材资产',
|
||||
'workbench.stage.shot': '镜头脚本',
|
||||
'workbench.stage.assets': '素材创作',
|
||||
'workbench.stage.shot': '镜头制作',
|
||||
'workbench.lockedStep': '先完成并确认「{stage}」后解锁',
|
||||
'workbench.state.todo': '待开始',
|
||||
'workbench.state.running': '生成中',
|
||||
@@ -1899,8 +1900,8 @@ export const zh: Dict = {
|
||||
'home.stageProgress': '五段进度',
|
||||
'tasks.sourceWorkbench': '创作流程',
|
||||
'jobst.idSuffix': ' · ComfyUI 任务 ID {id}',
|
||||
'jobst.done': '完成',
|
||||
'jobst.doneDetail': 'ComfyUI 已执行完成,结果已保存',
|
||||
'jobst.done': '定影',
|
||||
'jobst.doneDetail': '生成完成,结果已定影保存',
|
||||
'jobst.dryRun': '占位结果',
|
||||
'jobst.dryRunDetail': '占位结果:当前开启了 Dry-run,没有调用 ComfyUI,不是真实生成',
|
||||
'jobst.noOutput': '无产出',
|
||||
@@ -2000,6 +2001,15 @@ export const zh: Dict = {
|
||||
'desk.ctxDismiss': '关闭来源提示',
|
||||
'desk.ctxFrom': '来自《{project}》',
|
||||
'desk.ctxNoBackfill': '结果只留在生成台记录,不会自动回填到{stage}',
|
||||
'desk.ctxBackfill': '生成完成后,可在结果卡「回流到源项目」把结果送回{stage}',
|
||||
'desk.attachToProject': '回流到源项目',
|
||||
'desk.attachHint': '把该结果回流到来源:{label}',
|
||||
'desk.attaching': '回流中…',
|
||||
'desk.attachedTag': '已回流',
|
||||
'desk.attachBack': '返回项目',
|
||||
'desk.attachDoneToast': '已回流到源项目,返回项目即可查看',
|
||||
'desk.attachAlreadyToast': '该结果已回流过,未产生重复',
|
||||
'desk.attachFailedToast': '回流失败:{detail}',
|
||||
'desk.dayDate': '{m}月{d}日',
|
||||
'desk.download': '下载',
|
||||
'desk.dropNoSlot': '{n} 个文件没有可放的槽位:当前工作流没有接这类输入',
|
||||
@@ -2008,7 +2018,7 @@ export const zh: Dict = {
|
||||
'desk.durSec': '{s} 秒',
|
||||
'desk.elapsed': '用时',
|
||||
'desk.emptyFailed': '没有失败的记录',
|
||||
'desk.emptyFeed': '还没有记录。在下方写提示词,点「生成」。',
|
||||
'desk.emptyFeed': '还没有记录。在右侧写下描述,点「生成」。',
|
||||
'desk.filterAria': '筛选记录',
|
||||
'desk.filterAll': '全部',
|
||||
'desk.filterImage': '图片',
|
||||
@@ -2053,7 +2063,7 @@ export const zh: Dict = {
|
||||
'desk.removeRecord': '移除记录',
|
||||
'desk.restoreOrig': '恢复原值',
|
||||
'desk.reuse': '复用',
|
||||
'desk.reuseToComposer': '复用到输入台',
|
||||
'desk.reuseToComposer': '复用创作参数',
|
||||
'desk.seedRandom': '随机',
|
||||
'desk.sendToSlot': '送入当前图片槽',
|
||||
'desk.sent': '已提交',
|
||||
@@ -2078,6 +2088,36 @@ export const zh: Dict = {
|
||||
'desk.wfUntested': '未试运行',
|
||||
'desk.workflow': '工作流',
|
||||
'desk.yesterday': '昨天',
|
||||
// --- 三段式生成台 (HEL-683) ---
|
||||
'desk.bandChange': "更换工作流",
|
||||
'desk.bayCloudBody': "云端生图 / 生视频模型(gpt-image2、nano-banana、即梦、可灵等)尚未接入本产品。接入后这里会按真实能力列出,不会展示无法使用的条目。",
|
||||
'desk.bayCloudTitle': "云端生成未接入",
|
||||
'desk.bayCount': "{n} / {total} 个工作流",
|
||||
'desk.bayFavOnly': "收藏",
|
||||
'desk.bayMore': "再显示 {n} 个",
|
||||
'desk.bayNoFamily': "未识别模型",
|
||||
'desk.bayPending': "{n} 个待确认(用途 / 输出未定)",
|
||||
'desk.bayReqAria': "输入要求",
|
||||
'desk.bayReqTitle': "输入要求",
|
||||
'desk.baySearchPh': "搜索名称、说明、模型、用途…",
|
||||
'desk.baySourceCloud': "云端",
|
||||
'desk.baySourceLabel': "生成来源",
|
||||
'desk.baySourceLocal': "本机",
|
||||
'desk.modeAll': "全部",
|
||||
'desk.modeEmpty': "该模式暂无可用工作流",
|
||||
'desk.modeRailAria': "任务模式",
|
||||
'desk.modeRailTitle': "任务模式",
|
||||
'desk.pvBackCreate': "返回创作",
|
||||
'desk.pvCurrent': "当前工作流",
|
||||
'desk.pvEmpty': "在左侧选择一个工作流查看输入要求",
|
||||
'desk.pvFav': "加入收藏",
|
||||
'desk.pvLastUsed': "最近使用",
|
||||
'desk.pvParamCount': "参数项",
|
||||
'desk.pvSlotsLoading': "正在读取输入槽位…",
|
||||
'desk.pvUnfav': "取消收藏",
|
||||
'desk.pvUse': "使用此工作流",
|
||||
'desk.railFav': "收藏",
|
||||
'desk.railRecent': "最近使用",
|
||||
'wfd.stepParse': '解析 JSON',
|
||||
'wfd.stepNodes': '识别节点',
|
||||
'wfd.stepModels': '核对模型',
|
||||
@@ -2094,4 +2134,136 @@ export const zh: Dict = {
|
||||
'wfd.nodesPickSuggest': '请在下方选择用途并完成输出映射后再保存。',
|
||||
'wfd.showRaw': '查看原始 JSON',
|
||||
'wfd.hideRaw': '收起原始 JSON',
|
||||
|
||||
// --- Workflow library · batch import & management (HEL-682) ---
|
||||
'wl.hint': '批量导入 UI 格式工作流,搜索筛选与标注管理;完整 JSON 只在试运行等操作时按需加载',
|
||||
'wl.batchImport': '批量导入',
|
||||
'wl.dedupe': '去重',
|
||||
'wl.dedupeTitle': '清理重复工作流',
|
||||
'wl.dedupeConfirm': '将删除名称与内容完全相同的重复条目(保留最早导入的一条)。同名但内容不同的是你的变体,不会被动。确定执行?',
|
||||
'wl.dedupeDone': '已删除 {n} 个重复工作流',
|
||||
'wl.dedupeNone': '没有发现重复工作流',
|
||||
'wl.favCount': '{n} 个收藏',
|
||||
'wl.searchPh': '搜索名称、说明、标签、模型…(按 / 聚焦)',
|
||||
'wl.filterLabel': '筛选',
|
||||
'wl.filterAll': '全部',
|
||||
'wl.filterFav': '收藏',
|
||||
'wl.filterPending': '待确认',
|
||||
'wl.filterDisabled': '已停用',
|
||||
'wl.favAria': '收藏',
|
||||
'wl.unfavAria': '取消收藏',
|
||||
'wl.categoryLabel': '分类',
|
||||
'wl.categoryAll': '全部分类',
|
||||
'wl.familyLabel': '模型家族',
|
||||
'wl.familyAll': '全部模型',
|
||||
'wl.mediaLabel': '媒介',
|
||||
'wl.mediaAll': '全部媒介',
|
||||
'wl.mediaImage': '图像',
|
||||
'wl.mediaVideo': '视频',
|
||||
'wl.mediaAudio': '音频',
|
||||
'wl.mediaIn': '输入',
|
||||
'wl.mediaOut': '输出',
|
||||
'wl.sortLabel': '排序',
|
||||
'wl.sortRecent': '收藏 / 最近使用',
|
||||
'wl.sortName': '名称',
|
||||
'wl.resetFilters': '清除筛选',
|
||||
'wl.noMatch': '没有符合条件的工作流',
|
||||
'wl.resultCount': '匹配 {n} / {m}',
|
||||
'wl.selected': '已选 {n} 项',
|
||||
'wl.batchLabel': '批量操作',
|
||||
'wl.batchAnnotate': '批量标注',
|
||||
'wl.batchEnable': '启用',
|
||||
'wl.batchDisable': '停用',
|
||||
'wl.clearSel': '清除选择',
|
||||
'wl.batchDone': '已更新 {n} 项',
|
||||
'wl.batchPartial': '{n} 项成功,{m} 项失败(需先确认用途与输出映射)',
|
||||
'wl.selectAll': '选择当前列出的全部工作流',
|
||||
'wl.rowCheckAria': '选择 {name}',
|
||||
'wl.headMeta': '标注',
|
||||
'wl.headActions': '操作',
|
||||
'wl.enableAria': '启用或停用 {name}',
|
||||
'wl.enableBlocked': '需先确认用途并指定输出节点,才能启用',
|
||||
'wl.editMeta': '编辑标注',
|
||||
'wl.lastUsed': '最近使用',
|
||||
'wl.moreCount': '再显示 {n} 项(共 {m} 项)',
|
||||
'wfCat.basic': '基础处理',
|
||||
'wfCat.audioTts': '语音生成',
|
||||
'wfCat.music': '音乐生成',
|
||||
'wfCat.image': '图像生成',
|
||||
'wfCat.video': '视频生成',
|
||||
'wfCat.digitalHuman': '数字人',
|
||||
'wfCat.motionTransfer': '动作迁移',
|
||||
'md.title': '标注工作流',
|
||||
'md.batchTitle': '批量标注({n} 项)',
|
||||
'md.batchHint': '只有填写了的字段会被应用;显示名按「查找→替换」批量重命名,标签为追加。',
|
||||
'md.name': '显示名',
|
||||
'md.nameFind': '查找',
|
||||
'md.nameFindPh': '要替换的文字…',
|
||||
'md.nameReplace': '替换为',
|
||||
'md.nameReplacePh': '替换后的文字…',
|
||||
'md.nameHint': '留空「查找」则不改名。',
|
||||
'md.description': '说明',
|
||||
'md.descriptionPh': '一句话说明这个工作流做什么…',
|
||||
'md.tags': '标签',
|
||||
'md.tagsPh': '如:快速, 高质量',
|
||||
'md.tagsHint': '用逗号分隔;批量模式下为追加标签。',
|
||||
'md.category': '分类',
|
||||
'md.modelFamily': '模型家族',
|
||||
'md.apply': '应用',
|
||||
'md.applied': '已更新 {n} 项',
|
||||
'md.appliedOne': '已更新',
|
||||
'md.partial': '{n} 项成功,{m} 项失败',
|
||||
'wiz.title': '批量导入工作流',
|
||||
'wiz.stepSource': '选择来源',
|
||||
'wiz.stepReport': '预检报告',
|
||||
'wiz.stepResult': '导入结果',
|
||||
'wiz.sourceLabel': '导入来源',
|
||||
'wiz.modeScan': '扫描服务器目录',
|
||||
'wiz.modeFiles': '选择本地文件',
|
||||
'wiz.rootLabel': '服务器目录',
|
||||
'wiz.rootHint': '服务器上的目录路径(如仓库的 example_ComfyUI_workflows),将递归扫描其中的 ComfyUI 界面格式 JSON;API 格式与其他 JSON 自动跳过。',
|
||||
'wiz.preflight': '对真实 ComfyUI 预检(可选)',
|
||||
'wiz.preflightNone': '跳过(离线转换)',
|
||||
'wiz.preflightHint': '选择连接后,转换结果会对照该 ComfyUI 的节点与模型做对象级检查。',
|
||||
'wiz.pickFiles': '本地文件',
|
||||
'wiz.pickFilesBtn': '选择 JSON 文件(可多选)',
|
||||
'wiz.pickFilesHint': '选择 ComfyUI 界面格式(UI 格式)的工作流 JSON,可一次选多个。',
|
||||
'wiz.parsed': '已读取 {n} 个文件',
|
||||
'wiz.notUi': '{n} 个文件不是 UI 格式(缺少 nodes 数组),已忽略',
|
||||
'wiz.noFiles': '请先选择要导入的文件',
|
||||
'wiz.runDry': '开始预检(不写入)',
|
||||
'wiz.running': '预检中…',
|
||||
'wiz.total': '共 {n} 个文件',
|
||||
'wiz.ok': '正常 {n}',
|
||||
'wiz.warn': '警告 {n}',
|
||||
'wiz.fail': '失败 {n}',
|
||||
'wiz.confirmCount': '需人工确认 {n}',
|
||||
'wiz.importable': '可导入 {n}',
|
||||
'wiz.reportHint': '预检不写入数据库。warn 项可补充用途后以启用状态导入;「需人工确认」与转换失败项不会被导入,也不会被标为可运行。',
|
||||
'wiz.filterLabel': '报告筛选',
|
||||
'wiz.filterWarn': '仅看警告',
|
||||
'wiz.filterBlocked': '不可导入',
|
||||
'wiz.searchPh': '搜索文件名…',
|
||||
'wiz.includeAria': '导入 {name}',
|
||||
'wiz.skipReason': '不可导入:{reason}',
|
||||
'wiz.issues': '{n} 条转换提示',
|
||||
'wiz.usagePick': '补充用途:',
|
||||
'wiz.usageNone': '暂不补充(导入后待确认)',
|
||||
'wiz.selectedN': '将导入 {n} 个文件',
|
||||
'wiz.excludedM': '{m} 个不可导入',
|
||||
'wiz.import': '确认导入',
|
||||
'wiz.importing': '导入中…',
|
||||
'wiz.back': '上一步',
|
||||
'wiz.imported': '导入成功 {n}',
|
||||
'wiz.skipped': '跳过 {n}',
|
||||
'wiz.conflict': '已存在(重复导入自动跳过)',
|
||||
'wiz.usageApplied': '已为 {n} 个新导入项补充用途',
|
||||
'wiz.done': '完成',
|
||||
'wiz.again': '再导一批',
|
||||
'wiz.resultNote': '内容相同的重复文件会被自动跳过,重复执行不会产生重复条目。',
|
||||
'wiz.importedToast': '已导入 {n} 个工作流',
|
||||
'wiz.warnTag': '警告',
|
||||
'wiz.confirmTag': '需人工确认',
|
||||
'wiz.preflightTag': '预检未通过',
|
||||
'wiz.importedTag': '已导入 #{id}',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Public reference imagery retained for the user-requested interface reconstruction.
|
||||
* Source: https://cn.lk666.ai/home?menu=zhinengti (story agent), 2026-10-14.
|
||||
* No account imagery, private history, authentication data, or remote calls. */
|
||||
export const agentStyles = [
|
||||
{
|
||||
name: '韩漫唯美',
|
||||
image: 'hanmanweimei.jpg',
|
||||
prompt: '细腻唯美的韩漫风格,柔和光线与人物情绪',
|
||||
},
|
||||
{
|
||||
name: '日系动画',
|
||||
image: 'rixidonghua.jpg',
|
||||
prompt: '日系动画风格,清晰线条与富有层次的色彩',
|
||||
},
|
||||
{
|
||||
name: '纯正国风',
|
||||
image: 'chunzhengguofeng.jpg',
|
||||
prompt: '国风水墨意境,传统色彩与写意构图',
|
||||
},
|
||||
{
|
||||
name: '激燃极光',
|
||||
image: 'jiranjiguang.jpg',
|
||||
prompt: '强烈动感、鲜明对比与戏剧化光效',
|
||||
},
|
||||
{
|
||||
name: '3D盲盒',
|
||||
image: '3dmanghe.jpg',
|
||||
prompt: '可爱的三维盲盒人物,圆润造型与柔和材质',
|
||||
},
|
||||
{
|
||||
name: '趣味黏土',
|
||||
image: 'quweiniantu.jpg',
|
||||
prompt: '手工黏土质感,温暖色彩与趣味造型',
|
||||
},
|
||||
{
|
||||
name: '美漫硬朗',
|
||||
image: 'meimanyinglang.jpg',
|
||||
prompt: '硬朗美漫风格,强烈阴影与有力线条',
|
||||
},
|
||||
{
|
||||
name: '写实画报',
|
||||
image: 'xieshihuabao.jpg',
|
||||
prompt: '写实电影画报风格,自然质感与精致光线',
|
||||
},
|
||||
{
|
||||
name: '治愈绘本',
|
||||
image: 'zhiyuhuiben.jpg',
|
||||
prompt: '温暖治愈的手绘绘本,柔和色彩与亲切人物',
|
||||
},
|
||||
{
|
||||
name: '冒险动漫',
|
||||
image: 'haizeiwangfengge.jpg',
|
||||
prompt: '明快的冒险动漫风格,夸张表情与充满活力的画面',
|
||||
},
|
||||
];
|
||||
export const agentThemes: Record<
|
||||
string,
|
||||
{ title: string; hint: string; color: string; hue: number }
|
||||
> = {
|
||||
script: {
|
||||
title: '把一句灵感,\n写成一个好故事。',
|
||||
hint: '打磨人物、冲突与故事节奏',
|
||||
color: '#f5b042',
|
||||
hue: 0,
|
||||
},
|
||||
style: {
|
||||
title: '让脑海里的画面,\n拥有自己的风格。',
|
||||
hint: '确定画风、色彩与光线',
|
||||
color: '#cba0ff',
|
||||
hue: 20,
|
||||
},
|
||||
storyboard: {
|
||||
title: '把故事的每一刻,\n放进恰当的镜头。',
|
||||
hint: '拆解构图、动作与镜头节奏',
|
||||
color: '#73ceee',
|
||||
hue: 85,
|
||||
},
|
||||
assets: {
|
||||
title: '人物、场景与细节,\n一起走进作品里。',
|
||||
hint: '整理角色、场景与参考素材',
|
||||
color: '#69d4bc',
|
||||
hue: 145,
|
||||
},
|
||||
shot: {
|
||||
title: '让静止的想象,\n有了流动的瞬间。',
|
||||
hint: '镜头提示词、图片与视频工具',
|
||||
color: '#ff95b0',
|
||||
hue: 300,
|
||||
},
|
||||
};
|
||||
@@ -85,7 +85,7 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border, #232733);
|
||||
border-top: 1px solid var(--border);
|
||||
min-height: 74px;
|
||||
}
|
||||
.label {
|
||||
@@ -108,7 +108,7 @@
|
||||
.open {
|
||||
display: block;
|
||||
position: relative;
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
@@ -128,8 +128,8 @@
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #fff;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
pointer-events: none;
|
||||
@@ -153,7 +153,7 @@
|
||||
opacity: 1;
|
||||
}
|
||||
.danger {
|
||||
color: #e5697a;
|
||||
color: var(--err);
|
||||
}
|
||||
.empty {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
gap: 4px;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
@@ -133,10 +133,10 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
.btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.danger {
|
||||
color: #e5697a;
|
||||
color: var(--err);
|
||||
}
|
||||
.add-grid {
|
||||
display: grid;
|
||||
@@ -145,7 +145,7 @@
|
||||
padding: 0 12px 10px;
|
||||
}
|
||||
.add-item {
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
@@ -154,7 +154,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.add-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.objects {
|
||||
flex: 1;
|
||||
@@ -171,10 +171,10 @@
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.row.selected {
|
||||
background: rgba(99, 102, 241, 0.22);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
@@ -217,8 +217,8 @@
|
||||
}
|
||||
.rename {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: inherit;
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
@@ -233,6 +233,6 @@
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
opacity: 0.45;
|
||||
border-top: 1px solid var(--border, #232733);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
section {
|
||||
border-top: 1px solid var(--border, #232733);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.collapse {
|
||||
@@ -257,8 +257,8 @@
|
||||
input[type='number'],
|
||||
select {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: inherit;
|
||||
border-radius: 4px;
|
||||
padding: 3px 5px;
|
||||
@@ -279,7 +279,7 @@
|
||||
gap: 6px;
|
||||
}
|
||||
.joint {
|
||||
border: 1px solid var(--border, #232733);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
@@ -298,8 +298,8 @@
|
||||
}
|
||||
.capture {
|
||||
margin-top: auto;
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: rgba(99, 102, 241, 0.18);
|
||||
border: 1px solid var(--accent-line);
|
||||
background: var(--accent-soft);
|
||||
color: inherit;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
@@ -307,6 +307,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.capture:hover {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.item.active {
|
||||
background: rgba(99, 102, 241, 0.22);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.dot {
|
||||
width: 7px;
|
||||
@@ -265,7 +265,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot.run {
|
||||
background: var(--accent, #6366f1);
|
||||
background: var(--run);
|
||||
animation: dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes dot-pulse {
|
||||
@@ -295,6 +295,6 @@
|
||||
display: flex;
|
||||
}
|
||||
.del:hover {
|
||||
color: #e5697a;
|
||||
color: var(--err);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -841,7 +841,7 @@ let exporting = false;
|
||||
height: 100%;
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
background: #14161c;
|
||||
background: var(--bg);
|
||||
position: relative;
|
||||
}
|
||||
.viewport :global(canvas) {
|
||||
@@ -866,7 +866,7 @@ let exporting = false;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
background: color-mix(in srgb, var(--on-media) 14%, transparent);
|
||||
}
|
||||
.composition-guide .thirds-v:nth-of-type(1) {
|
||||
left: 33.33%;
|
||||
@@ -879,7 +879,7 @@ let exporting = false;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
background: color-mix(in srgb, var(--on-media) 14%, transparent);
|
||||
}
|
||||
.composition-guide .thirds-h:nth-of-type(3) {
|
||||
top: 33.33%;
|
||||
@@ -894,7 +894,7 @@ let exporting = false;
|
||||
width: 14px;
|
||||
height: 1px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
background: color-mix(in srgb, var(--on-media) 35%, transparent);
|
||||
}
|
||||
.composition-guide .cross-v {
|
||||
position: absolute;
|
||||
@@ -903,6 +903,6 @@ let exporting = false;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
background: color-mix(in srgb, var(--on-media) 35%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--border, #232733);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
@@ -207,7 +207,7 @@
|
||||
justify-content: center;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
@@ -216,7 +216,7 @@
|
||||
padding: 0 6px;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.45;
|
||||
@@ -227,10 +227,10 @@
|
||||
gap: 4px;
|
||||
}
|
||||
.obj-add {
|
||||
color: #7dd88a;
|
||||
color: var(--ok);
|
||||
}
|
||||
.export {
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
@@ -240,7 +240,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.export:hover:not(:disabled) {
|
||||
border-color: var(--accent, #6366f1);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.export:disabled {
|
||||
opacity: 0.45;
|
||||
@@ -255,8 +255,8 @@
|
||||
}
|
||||
.dur input {
|
||||
width: 46px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: inherit;
|
||||
border-radius: 4px;
|
||||
padding: 3px 5px;
|
||||
@@ -280,14 +280,14 @@
|
||||
position: absolute;
|
||||
inset: 11px 0;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
width: 2px;
|
||||
background: var(--accent, #6366f1);
|
||||
background: var(--accent);
|
||||
transform: translateX(-1px);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -298,8 +298,8 @@
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
transform: translate(-5px, -50%) rotate(45deg);
|
||||
background: #e0b34d;
|
||||
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||
background: var(--warn);
|
||||
border: 1px solid var(--bg);
|
||||
cursor: grab;
|
||||
padding: 0;
|
||||
z-index: 2;
|
||||
|
||||
@@ -89,8 +89,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
background: rgba(16, 18, 24, 0.82);
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--bg-2) 85%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
backdrop-filter: blur(4px);
|
||||
@@ -103,7 +103,7 @@
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border, #2a2f3a);
|
||||
background: var(--border);
|
||||
margin: 0 2px;
|
||||
}
|
||||
.btn {
|
||||
@@ -119,12 +119,12 @@
|
||||
opacity: 0.85;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-4);
|
||||
opacity: 1;
|
||||
}
|
||||
.btn.on {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
border-color: var(--accent, #6366f1);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent-line);
|
||||
opacity: 1;
|
||||
}
|
||||
.btn:disabled {
|
||||
@@ -132,9 +132,9 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.capture {
|
||||
border-color: var(--border, #2a2f3a);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.capture:hover {
|
||||
border-color: var(--accent, #6366f1);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
/** Approved HEL-726 themes. Colours live only in static/themes.css. */
|
||||
export const THEMES = [
|
||||
{
|
||||
id: "night",
|
||||
name: "夜片场",
|
||||
english: "Nocturne",
|
||||
description: "墨黑与钨丝金 · 沉浸式创作",
|
||||
},
|
||||
{
|
||||
id: "day",
|
||||
name: "日光棚",
|
||||
english: "Daylight",
|
||||
description: "暖白纸面 · 白天办公与长时间审稿",
|
||||
},
|
||||
{
|
||||
id: "grade",
|
||||
name: "调色间",
|
||||
english: "Grading",
|
||||
description: "中性深灰 · 判断作品色彩",
|
||||
},
|
||||
{
|
||||
id: "bluehour",
|
||||
name: "蓝调时刻",
|
||||
english: "Blue Hour",
|
||||
description: "靛蓝与珊瑚 · 提案与灵感发散",
|
||||
},
|
||||
] as const;
|
||||
export type ThemeId = (typeof THEMES)[number]["id"];
|
||||
export const THEME_KEY = "aigc-theme";
|
||||
export const theme = writable<ThemeId>("night");
|
||||
export const themeStorageUnavailable = writable(false);
|
||||
|
||||
export function isTheme(value: unknown): value is ThemeId {
|
||||
return THEMES.some((item) => item.id === value);
|
||||
}
|
||||
|
||||
export function setTheme(id: ThemeId) {
|
||||
if (typeof document === "undefined" || !isTheme(id)) return;
|
||||
// Only the root attribute changes: editors, uploads and jobs stay mounted.
|
||||
document.documentElement.dataset.theme = id;
|
||||
theme.set(id);
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, id);
|
||||
themeStorageUnavailable.set(false);
|
||||
} catch {
|
||||
themeStorageUnavailable.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
const current = document.documentElement.dataset.theme;
|
||||
theme.set(isTheme(current) ? current : "night");
|
||||
try {
|
||||
localStorage.getItem(THEME_KEY);
|
||||
} catch {
|
||||
themeStorageUnavailable.set(true);
|
||||
}
|
||||
const sync = (event: StorageEvent) => {
|
||||
if (event.key !== THEME_KEY && event.key !== null) return;
|
||||
const id = isTheme(event.newValue) ? event.newValue : "night";
|
||||
document.documentElement.dataset.theme = id;
|
||||
theme.set(id);
|
||||
};
|
||||
window.addEventListener("storage", sync);
|
||||
return () => window.removeEventListener("storage", sync);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user