Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
316fba97e1 | ||
|
|
1c00587a56 | ||
|
|
7c49250267 | ||
|
|
c5aa575ac3 | ||
|
|
6623ffa04c | ||
|
|
0ee8280ddb | ||
|
|
1fc8bdb8bb | ||
|
|
2c3c05277b | ||
|
|
2689f5354a | ||
|
|
7bd8c29faa | ||
|
|
4f3293fc02 | ||
|
|
4c87c6dea0 | ||
|
|
d882fd62d8 | ||
|
|
b4c7334f45 | ||
|
|
6064bd805d | ||
|
|
c3deaf90bb | ||
|
|
949cd2ecad | ||
|
|
0c620e61fe | ||
|
|
6f4d50f4f0 | ||
|
|
8b03291327 | ||
|
|
225fcb49ea | ||
|
|
70a860ce2a | ||
|
|
e42212a16f | ||
|
|
f5f19f0366 | ||
|
|
bfe7b20b73 | ||
|
|
2ed9d25c24 |
@@ -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())
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -18,6 +19,17 @@ logger = logging.getLogger("calliope.comfyui")
|
||||
# bad endpoint hangs every queued job for two minutes before failing.
|
||||
HEALTH_TIMEOUT_SEC = 5.0
|
||||
|
||||
# /object_info fetch is a few MB on big instances; the preflight check
|
||||
# (HEL-642/643) caches it per endpoint for this long. Manual refresh
|
||||
# (`force_refresh=True`) bypasses the TTL.
|
||||
OBJECT_INFO_TTL_SEC = 300.0
|
||||
_object_info_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def clear_object_info_cache() -> None:
|
||||
"""Drop cached /object_info payloads (tests swap fake endpoints in)."""
|
||||
_object_info_cache.clear()
|
||||
|
||||
|
||||
def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
|
||||
"""Raise with ComfyUI's own error body instead of an opaque status line.
|
||||
@@ -88,6 +100,33 @@ class ComfyUIClient:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _cache_key(self) -> str:
|
||||
auth = self._headers.get("Authorization") or next(
|
||||
(v for k, v in self._headers.items() if k.lower() not in ("authorization",)), ""
|
||||
)
|
||||
return f"{self.base_url}|{auth}"
|
||||
|
||||
async def object_info(self, force_refresh: bool = False) -> dict[str, Any]:
|
||||
"""The server's full ``/object_info`` node catalogue, cached per endpoint.
|
||||
|
||||
Cached for OBJECT_INFO_TTL_SEC; ``force_refresh`` re-fetches (the
|
||||
connections page "重新检查" button after the user installs nodes).
|
||||
Raises on transport/HTTP errors so callers can report honestly.
|
||||
"""
|
||||
key = self._cache_key()
|
||||
now = time.monotonic()
|
||||
if not force_refresh:
|
||||
hit = _object_info_cache.get(key)
|
||||
if hit and now - hit[0] < OBJECT_INFO_TTL_SEC:
|
||||
return hit[1]
|
||||
resp = await self._http.get(f"{self.base_url}/object_info", timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("ComfyUI 返回的 /object_info 不是对象")
|
||||
_object_info_cache[key] = (now, data)
|
||||
return data
|
||||
|
||||
async def upload_image(self, path: Path, subfolder: str = "calliope") -> str:
|
||||
data = path.read_bytes()
|
||||
files = {"image": (path.name, data, "application/octet-stream")}
|
||||
|
||||
@@ -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,
|
||||
@@ -281,17 +283,45 @@ def resolution_selector_size(aspect_ratio: str, megapixels: float, multiple: int
|
||||
)
|
||||
|
||||
|
||||
# ComfyUI Autogrow image inputs a studio form fills as one 1…max list.
|
||||
# The workflow wires ``<group>.<prefix>N``; extra images are cloned LoadImage
|
||||
# nodes added at patch time (patcher.py). This registry only beautifies known
|
||||
# classes (label + real max from /object_info); detection itself is generic —
|
||||
# any ``<group>.<prefix>N`` run of image-loader links folds into a group.
|
||||
IMAGE_GROUPS: dict[str, dict[str, dict[str, Any]]] = {
|
||||
# ComfyUI Autogrow media inputs a studio form fills as one 1…max list
|
||||
# (HEL-643 generalizes the original image-only registry to three media
|
||||
# kinds). The workflow wires ``<group>.<prefix>N``; extra items are cloned
|
||||
# loader nodes added at patch time (patcher.py). This registry only
|
||||
# beautifies known classes (label + real max + hint key); detection itself
|
||||
# is generic — any ``<group>.<prefix>N`` run of same-family media-loader
|
||||
# links folds into a group, image / video / audio alike.
|
||||
MEDIA_GROUPS: dict[str, dict[str, dict[str, Any]]] = {
|
||||
"MiniMaxH3ReferenceToVideo": {
|
||||
"ref_images": {"prefix": "ref_image_", "max": 9, "label": "参考图"},
|
||||
"ref_images": {
|
||||
"prefix": "ref_image_",
|
||||
"max": 9,
|
||||
"label": "参考图",
|
||||
"kind": "image",
|
||||
},
|
||||
"ref_videos": {
|
||||
"prefix": "ref_video_",
|
||||
"max": 3,
|
||||
"label": "参考视频",
|
||||
"kind": "video",
|
||||
},
|
||||
"ref_audios": {
|
||||
"prefix": "ref_audio_",
|
||||
"max": 3,
|
||||
"label": "参考音频",
|
||||
"kind": "audio",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Backwards-compatible alias for the original image-only view.
|
||||
IMAGE_GROUPS = MEDIA_GROUPS
|
||||
|
||||
# Consuming-node classes whose groups carry official usage limits worth a
|
||||
# hint in the UI. Purely cosmetic — detection never depends on it.
|
||||
_MEDIA_HINTS: dict[str, str] = {
|
||||
"MiniMaxH3ReferenceToVideo": "minimax_h3_ref2va",
|
||||
}
|
||||
|
||||
# ``ref_images.ref_image_0`` / ``images.image_1`` style Autogrow input names.
|
||||
_GROUP_INPUT_RE = re.compile(
|
||||
r"^(?P<group>[A-Za-z0-9_+-]+)\.(?P<rest>[A-Za-z0-9_+-]+?)(?P<n>\d+)$"
|
||||
@@ -305,9 +335,27 @@ _FRAME_SLOT_ROLES: dict[str, tuple[str, str]] = {
|
||||
"end_image": ("last_frame", "尾帧"),
|
||||
}
|
||||
|
||||
# Group media kind → (schema kind, role, default label, default max).
|
||||
_GROUP_KIND_META: dict[str, tuple[str, str, str, int]] = {
|
||||
"image": ("image_list", "reference_image", "参考图", 9),
|
||||
"video": ("video_list", "reference_video", "参考视频", 9),
|
||||
"audio": ("audio_list", "reference_audio", "参考音频", 9),
|
||||
}
|
||||
|
||||
# Loader class family for each media kind (registry.py sets).
|
||||
_GROUP_SOURCE_CLASSES: dict[str, frozenset[str]] = {
|
||||
"image": IMAGE_CLASSES,
|
||||
"video": VIDEO_CLASSES,
|
||||
"audio": AUDIO_CLASSES,
|
||||
}
|
||||
|
||||
|
||||
def media_group_spec(class_type: str, group: str) -> dict[str, Any] | None:
|
||||
return MEDIA_GROUPS.get(class_type, {}).get(group)
|
||||
|
||||
|
||||
def image_group_spec(class_type: str, group: str) -> dict[str, Any] | None:
|
||||
return IMAGE_GROUPS.get(class_type, {}).get(group)
|
||||
return media_group_spec(class_type, group)
|
||||
|
||||
|
||||
def image_group_prefix(node: dict[str, Any], group: str) -> str | None:
|
||||
@@ -346,14 +394,14 @@ def image_group_slots(
|
||||
|
||||
def _detected_group_runs(
|
||||
workflow: dict[str, Any], node: dict[str, Any]
|
||||
) -> list[tuple[str, str, list[str]]]:
|
||||
"""``[(group, prefix, source LoadImage ids)]`` for generic Autogrow inputs.
|
||||
) -> list[tuple[str, str, list[str], str]]:
|
||||
"""``[(group, prefix, source loader ids, media kind)]`` for Autogrow inputs.
|
||||
|
||||
A run qualifies when the numbers are consecutive, every slot links to an
|
||||
image-loader class, and the run either starts at 0 (single-slot Autogrow
|
||||
exports like H3's ``ref_image_0``) or has ≥2 slots. Single runs numbered
|
||||
from 1 (e.g. TextEncodeQwenImage21's ``images.image_1``) stay ordinary
|
||||
image slots.
|
||||
A run qualifies when the numbers are consecutive, every slot links to a
|
||||
loader of the SAME media family (image / video / audio), and the run
|
||||
either starts at 0 (single-slot Autogrow exports like H3's
|
||||
``ref_image_0``) or has ≥2 slots. Single runs numbered from 1 (e.g.
|
||||
TextEncodeQwenImage21's ``images.image_1``) stay ordinary image slots.
|
||||
"""
|
||||
runs: dict[tuple[str, str], dict[int, Any]] = {}
|
||||
for name, val in (node.get("inputs") or {}).items():
|
||||
@@ -361,7 +409,7 @@ def _detected_group_runs(
|
||||
if not m or not (isinstance(val, list) and len(val) == 2):
|
||||
continue
|
||||
runs.setdefault((m.group("group"), m.group("rest")), {})[int(m.group("n"))] = val
|
||||
out: list[tuple[str, str, list[str]]] = []
|
||||
out: list[tuple[str, str, list[str], str]] = []
|
||||
for (group, prefix), slots in sorted(runs.items()):
|
||||
ns = sorted(slots)
|
||||
if any(b - a != 1 for a, b in zip(ns, ns[1:])):
|
||||
@@ -369,41 +417,57 @@ def _detected_group_runs(
|
||||
if not (ns[0] == 0 or len(ns) >= 2):
|
||||
continue
|
||||
sources: list[str] = []
|
||||
family: str | None = None
|
||||
for n in ns:
|
||||
link = slots[n]
|
||||
src = workflow.get(str(link[0]))
|
||||
if not (isinstance(src, dict) and src.get("class_type") in IMAGE_CLASSES):
|
||||
sources = []
|
||||
if not isinstance(src, dict):
|
||||
family = None
|
||||
break
|
||||
ct = str(src.get("class_type") or "")
|
||||
kinds = [
|
||||
kind
|
||||
for kind, classes in _GROUP_SOURCE_CLASSES.items()
|
||||
if ct in classes
|
||||
]
|
||||
if len(kinds) != 1 or (family is not None and kinds[0] != family):
|
||||
family = None
|
||||
break
|
||||
family = kinds[0]
|
||||
sources.append(str(link[0]))
|
||||
if sources:
|
||||
out.append((group, prefix, sources))
|
||||
if family and sources:
|
||||
out.append((group, prefix, sources, family))
|
||||
return out
|
||||
|
||||
|
||||
def _image_groups(
|
||||
def _media_groups(
|
||||
workflow: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], set[str]]:
|
||||
"""Group entries for Autogrow reference inputs + the LoadImage ids they absorb."""
|
||||
"""Group entries for Autogrow reference inputs (image/video/audio) plus
|
||||
the loader node ids they absorb."""
|
||||
entries: list[dict[str, Any]] = []
|
||||
absorbed: set[str] = set()
|
||||
for nid, node in sorted(
|
||||
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
|
||||
key=lambda kv: _node_sort_key(kv[0]),
|
||||
):
|
||||
for group, _prefix, sources in _detected_group_runs(workflow, node):
|
||||
spec = image_group_spec(str(node.get("class_type") or ""), group) or {}
|
||||
class_type = str(node.get("class_type") or "")
|
||||
for group, _prefix, sources, media_kind in _detected_group_runs(workflow, node):
|
||||
spec = media_group_spec(class_type, group) or {}
|
||||
kind, role, default_label, default_max = _GROUP_KIND_META[media_kind]
|
||||
absorbed.update(sources)
|
||||
hint = _MEDIA_HINTS.get(class_type)
|
||||
entries.append(
|
||||
_entry(
|
||||
f"{nid}{FIELD_KEY_SEP}{group}",
|
||||
str(spec.get("label") or "参考图"),
|
||||
"reference_image",
|
||||
"image_list",
|
||||
str(spec.get("label") or default_label),
|
||||
role,
|
||||
kind,
|
||||
None,
|
||||
True,
|
||||
min=1,
|
||||
max=spec.get("max", 9),
|
||||
max=spec.get("max", default_max),
|
||||
media_hint=hint,
|
||||
)
|
||||
)
|
||||
return entries, absorbed
|
||||
@@ -449,7 +513,7 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
)
|
||||
|
||||
texts: list[tuple[str, dict[str, Any], str, bool]] = [] # (id, node, field, negative)
|
||||
groups, grouped_images = _image_groups(workflow)
|
||||
groups, grouped_loaders = _media_groups(workflow)
|
||||
media: list[dict[str, Any]] = []
|
||||
numbers: list[dict[str, Any]] = []
|
||||
taken_roles: set[str] = set()
|
||||
@@ -475,7 +539,7 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
lowered = title.lower()
|
||||
|
||||
if ct in IMAGE_CLASSES or ct in _MASK_IMAGE_CLASSES:
|
||||
if nid in grouped_images:
|
||||
if nid in grouped_loaders:
|
||||
continue
|
||||
is_mask = ct in _MASK_IMAGE_CLASSES or "mask" in lowered or "遮罩" in lowered
|
||||
slot = _image_slot(nid, workflow, consumers)
|
||||
@@ -504,6 +568,10 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
)
|
||||
continue
|
||||
if ct in VIDEO_CLASSES or ct in AUDIO_CLASSES:
|
||||
if nid in grouped_loaders:
|
||||
# Absorbed into an Autogrow media group (HEL-643) — the
|
||||
# group entry owns its upload slots.
|
||||
continue
|
||||
kind = "video" if ct in VIDEO_CLASSES else "audio"
|
||||
field = resolve_patch_field(node)
|
||||
has_default = isinstance(inputs.get(field), str) and bool(inputs.get(field))
|
||||
@@ -623,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]]
|
||||
@@ -666,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:
|
||||
|
||||
@@ -5,12 +5,45 @@ from __future__ import annotations
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.registry import IMAGE_CLASSES, class_to_patch_field
|
||||
from calliope.comfyui.registry import (
|
||||
AUDIO_CLASSES,
|
||||
IMAGE_CLASSES,
|
||||
VIDEO_CLASSES,
|
||||
VIDEO_FILE_CLASSES,
|
||||
class_to_patch_field,
|
||||
optional_frame_sibling,
|
||||
)
|
||||
|
||||
# Input keys of the form "<nodeId>@<widget>" target one named widget on a
|
||||
# node (auto-detected inputs: KSampler seed/steps/cfg, latent width/height…).
|
||||
FIELD_KEY_SEP = "@"
|
||||
|
||||
# Media-loader families and the Chinese slot label used on cloned nodes.
|
||||
_GROUP_LABELS: tuple[tuple[frozenset[str], str], ...] = (
|
||||
(IMAGE_CLASSES, "参考图"),
|
||||
(VIDEO_CLASSES, "参考视频"),
|
||||
(AUDIO_CLASSES, "参考音频"),
|
||||
)
|
||||
|
||||
|
||||
def _loader_label(class_type: str) -> str:
|
||||
for classes, label in _GROUP_LABELS:
|
||||
if class_type in classes:
|
||||
return label
|
||||
return "参考素材"
|
||||
|
||||
|
||||
def _loader_field(class_type: str, inputs: dict[str, Any]) -> str:
|
||||
"""The widget a loader node of this class stores its file value in."""
|
||||
if class_type in IMAGE_CLASSES:
|
||||
return "image"
|
||||
if class_type in VIDEO_CLASSES:
|
||||
return "file" if class_type in VIDEO_FILE_CLASSES else "video"
|
||||
if class_type in AUDIO_CLASSES:
|
||||
# VHS_LoadAudio names its widget "audio:" (with the colon).
|
||||
return "audio" if "audio" in inputs else "audio:"
|
||||
return "image"
|
||||
|
||||
|
||||
def _resolve_field(field: str, inputs: dict[str, Any]) -> str:
|
||||
"""Map the computed patch field onto a key that exists on this node.
|
||||
@@ -43,15 +76,16 @@ def _next_node_id(workflow: dict[str, Any]) -> str:
|
||||
return str(max(numeric, default=0) + 1)
|
||||
|
||||
|
||||
def apply_image_group(
|
||||
def apply_media_group(
|
||||
workflow: dict[str, Any], node_id: str, group: str, paths: list[str]
|
||||
) -> None:
|
||||
"""Rewire an Autogrow image group to exactly ``paths``, in order.
|
||||
"""Rewire an Autogrow media group (image/video/audio) to exactly ``paths``.
|
||||
|
||||
``<group>.<prefix>0 … N-1`` each get their own LoadImage: the workflow's
|
||||
existing ones are reused in slot order, extra ones are clones of the
|
||||
first, and slots/loaders left over from the export are removed so no
|
||||
stale image reaches ComfyUI.
|
||||
``<group>.<prefix>0 … N-1`` each get their own loader node: the
|
||||
workflow's existing ones are reused in slot order, extra ones are clones
|
||||
of the first, and slots/loaders left over from the export are removed so
|
||||
no stale media reaches ComfyUI. File values are local paths —
|
||||
``prepare_media_inputs`` uploads them right before queueing.
|
||||
"""
|
||||
from calliope.comfyui.parser import image_group_prefix, image_group_slots
|
||||
|
||||
@@ -61,7 +95,6 @@ def apply_image_group(
|
||||
prefix = image_group_prefix(node, group)
|
||||
if prefix is None:
|
||||
return
|
||||
label = "参考图"
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
slots = image_group_slots(node, group, prefix)
|
||||
loaders: list[str] = []
|
||||
@@ -69,11 +102,14 @@ def apply_image_group(
|
||||
inputs.pop(name, None)
|
||||
if isinstance(link, list) and len(link) == 2:
|
||||
src = workflow.get(str(link[0]))
|
||||
if isinstance(src, dict) and src.get("class_type") in IMAGE_CLASSES:
|
||||
if isinstance(src, dict) and str(src.get("class_type") or "") in (
|
||||
IMAGE_CLASSES | VIDEO_CLASSES | AUDIO_CLASSES
|
||||
):
|
||||
loaders.append(str(link[0]))
|
||||
if not loaders:
|
||||
return
|
||||
template = copy.deepcopy(workflow[loaders[0]])
|
||||
label = _loader_label(str(template.get("class_type") or ""))
|
||||
|
||||
still_used: set[str] = set()
|
||||
for nid, other in workflow.items():
|
||||
@@ -94,7 +130,8 @@ def apply_image_group(
|
||||
nid = _next_node_id(workflow)
|
||||
loader = copy.deepcopy(template)
|
||||
loader_inputs = dict(loader.get("inputs") or {})
|
||||
loader_inputs["image"] = path
|
||||
field = _loader_field(str(loader.get("class_type") or ""), loader_inputs)
|
||||
loader_inputs[field] = path
|
||||
loader["inputs"] = loader_inputs
|
||||
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{label} {i + 1}"}
|
||||
workflow[nid] = loader
|
||||
@@ -102,6 +139,30 @@ def apply_image_group(
|
||||
node["inputs"] = inputs
|
||||
|
||||
|
||||
# Original name kept: image groups were the first (and most common) family.
|
||||
apply_image_group = apply_media_group
|
||||
|
||||
|
||||
def apply_optional_frame(
|
||||
workflow: dict[str, Any], node_id: str, field: str, sibling: str, path: str
|
||||
) -> None:
|
||||
"""Wire ``path`` into an unwired optional frame input (H3 ``last_frame``)
|
||||
through a new LoadImage cloned from the ``sibling`` frame's loader."""
|
||||
node = workflow[node_id]
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
src = workflow.get(str(inputs[sibling][0]))
|
||||
if isinstance(src, dict) and str(src.get("class_type") or "") in IMAGE_CLASSES:
|
||||
loader = copy.deepcopy(src)
|
||||
else:
|
||||
loader = {"class_type": "LoadImage", "inputs": {}}
|
||||
loader["inputs"] = {**(loader.get("inputs") or {}), "image": path}
|
||||
loader["_meta"] = {"title": "尾帧" if field == "last_frame" else field}
|
||||
nid = _next_node_id(workflow)
|
||||
workflow[nid] = loader
|
||||
inputs[field] = [nid, 0]
|
||||
node["inputs"] = inputs
|
||||
|
||||
|
||||
def patch_workflow(
|
||||
base: dict[str, Any],
|
||||
values_by_node_id: dict[str, Any],
|
||||
@@ -125,7 +186,11 @@ def patch_workflow(
|
||||
if isinstance(paths, list):
|
||||
clean = [p for p in paths if isinstance(p, str) and p.strip()]
|
||||
if clean:
|
||||
apply_image_group(patched, key, field, clean)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""L2 connection preflight: check a workflow against a live /object_info (HEL-642/643).
|
||||
|
||||
Layer 1 (lint.py) judges the JSON on its own; this module judges the JSON
|
||||
against what the target ComfyUI actually has installed. Two generic rules,
|
||||
zero hard-coding for any specific workflow or model name:
|
||||
|
||||
1. Node existence — every ``class_type`` in the JSON must exist as a key of
|
||||
the server's /object_info catalogue; missing ones are custom nodes the
|
||||
user has not installed.
|
||||
2. Combo value legality — for every combo input the server declares (an
|
||||
input whose spec carries a candidate list — unet_name/clip_name/
|
||||
sampler/scheduler/…), the JSON's current widget value must be one of the
|
||||
candidates; a miss usually means a model file or component is missing.
|
||||
|
||||
File-upload widgets (LoadImage.image and friends) are skipped: their combo
|
||||
lists are the server's input directory at export time and the value is
|
||||
replaced by an upload before every submit, so a miss there says nothing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.registry import AUDIO_CLASSES, IMAGE_CLASSES, VIDEO_CLASSES
|
||||
|
||||
# Media-loader classes and the widget names that hold their file values —
|
||||
# upload-backed combos that cannot be judged before an upload happens.
|
||||
_MEDIA_LOADER_FIELDS: tuple[frozenset[str], frozenset[str]] = (
|
||||
(IMAGE_CLASSES, frozenset({"image"})),
|
||||
(AUDIO_CLASSES, frozenset({"audio", "audio:"})),
|
||||
(VIDEO_CLASSES, frozenset({"file", "video"})),
|
||||
)
|
||||
|
||||
# object_info widget-config keys that mark an input as file-upload backed.
|
||||
_UPLOAD_CONFIG_KEYS = ("image_upload", "audio_upload", "video_upload", "upload")
|
||||
|
||||
_MAX_CANDIDATES_SHOWN = 6
|
||||
|
||||
|
||||
def _node_title(node: dict[str, Any]) -> str:
|
||||
title = (node.get("_meta") or {}).get("title")
|
||||
return str(title).strip() if title else ""
|
||||
|
||||
|
||||
def _is_upload_backed(class_type: str, input_name: str, config: Any) -> bool:
|
||||
if isinstance(config, dict) and any(config.get(k) for k in _UPLOAD_CONFIG_KEYS):
|
||||
return True
|
||||
for classes, fields in _MEDIA_LOADER_FIELDS:
|
||||
if class_type in classes and input_name in fields:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _combo_candidates(spec: Any) -> list[str] | None:
|
||||
"""The candidate list when ``spec`` is a combo input, else None.
|
||||
|
||||
A combo spec is ``[["a", "b"], {config}]`` — first element a list of
|
||||
plain strings. Typed inputs (``["FLOAT", {…}]``) return None.
|
||||
"""
|
||||
if not (isinstance(spec, list) and len(spec) >= 1):
|
||||
return None
|
||||
values = spec[0]
|
||||
if not isinstance(values, list) or not values:
|
||||
return None
|
||||
if not all(isinstance(v, str) for v in values):
|
||||
return None
|
||||
return values
|
||||
|
||||
|
||||
def check_workflow_against(
|
||||
object_info: dict[str, Any], workflow: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Findings for every problem the target server's catalogue reveals.
|
||||
|
||||
Each finding is ``{level, code, node_id, node_title, message, hint}``
|
||||
(``level`` is always ``"error"`` for now — both rules are hard misses).
|
||||
"""
|
||||
findings: list[dict[str, Any]] = []
|
||||
if not isinstance(object_info, dict) or not isinstance(workflow, dict):
|
||||
return findings
|
||||
for node_id, node in workflow.items():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
class_type = str(node.get("class_type") or "")
|
||||
title = _node_title(node)
|
||||
label = f"节点 {node_id}「{title}」" if title else f"节点 {node_id}"
|
||||
spec = object_info.get(class_type)
|
||||
if not isinstance(spec, dict):
|
||||
findings.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "missing_node",
|
||||
"node_id": str(node_id),
|
||||
"node_title": title,
|
||||
"message": (
|
||||
f"缺少自定义节点 {class_type or '(未知类名)'}({label})——"
|
||||
"该 ComfyUI 未安装此节点,需先安装对应的节点包"
|
||||
),
|
||||
"hint": "安装节点包后回到本页点「重新检查」即可复查,无需重新导入。",
|
||||
}
|
||||
)
|
||||
continue
|
||||
declared = (spec.get("input") or {}) if isinstance(spec.get("input"), dict) else {}
|
||||
inputs = node.get("inputs") or {}
|
||||
for section in ("required", "optional"):
|
||||
entries = declared.get(section)
|
||||
if not isinstance(entries, dict):
|
||||
continue
|
||||
for input_name, in_spec in entries.items():
|
||||
value = inputs.get(input_name)
|
||||
# Wired inputs (links) and absent widgets are not combos to judge.
|
||||
if isinstance(value, (list, dict)) or value is None:
|
||||
continue
|
||||
config = in_spec[1] if isinstance(in_spec, list) and len(in_spec) > 1 else None
|
||||
if _is_upload_backed(class_type, str(input_name), config):
|
||||
continue
|
||||
candidates = _combo_candidates(in_spec)
|
||||
if candidates is None:
|
||||
continue
|
||||
if value in candidates:
|
||||
continue
|
||||
shown = "、".join(candidates[:_MAX_CANDIDATES_SHOWN])
|
||||
if len(candidates) > _MAX_CANDIDATES_SHOWN:
|
||||
shown += f" 等 {len(candidates)} 项"
|
||||
findings.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "value_not_in_list",
|
||||
"node_id": str(node_id),
|
||||
"node_title": title,
|
||||
"message": (
|
||||
f"{label}:值「{value}」不在 ComfyUI 的可选列表"
|
||||
f"(参数 {input_name})——多为对应的模型文件或组件未安装"
|
||||
),
|
||||
"hint": f"该 ComfyUI 上可选:{shown}" if shown else None,
|
||||
}
|
||||
)
|
||||
return findings
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Unified class_type → kind + patch-field registry (MuseAgent port)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
ComfyInputKind = Literal["text", "textarea", "number", "image", "image_url", "audio", "video"]
|
||||
ComfyOutputKind = Literal["image", "video", "other"]
|
||||
@@ -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
@@ -63,20 +63,24 @@ def validate_input_values(
|
||||
errors.append(f"「{label}」不能大于 {_fmt(hi)}")
|
||||
continue
|
||||
clean[key] = value
|
||||
elif kind == "image_list":
|
||||
elif kind in ("image_list", "video_list", "audio_list"):
|
||||
paths = [raw] if isinstance(raw, str) else raw
|
||||
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
|
||||
errors.append(f"「{label}」的取值格式不对,请重新上传")
|
||||
continue
|
||||
paths = [p for p in paths if p.strip()]
|
||||
unit = _LIST_UNITS[kind]
|
||||
noun = _LIST_NOUNS[kind]
|
||||
hi = entry.get("max")
|
||||
if hi is not None and len(paths) > hi:
|
||||
errors.append(f"{label}最多 {hi} 张(当前 {len(paths)} 张),请删除多余的图片")
|
||||
errors.append(
|
||||
f"{label}最多 {hi} {unit}(当前 {len(paths)} {unit}),请删除多余的{noun}"
|
||||
)
|
||||
continue
|
||||
bad = [
|
||||
f"{label} {i}:{msg}"
|
||||
for i, p in enumerate(paths, 1)
|
||||
if (msg := _image_file_problem(p))
|
||||
if (msg := _media_file_problem(p, kind))
|
||||
]
|
||||
if bad:
|
||||
errors.extend(bad)
|
||||
@@ -98,10 +102,11 @@ def validate_input_values(
|
||||
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
|
||||
if key not in clean and not has_fallback:
|
||||
errors.append(f"请填写「{label}」")
|
||||
elif required and entry.get("kind") == "image_list":
|
||||
elif required and entry.get("kind") in ("image_list", "video_list", "audio_list"):
|
||||
lo = entry.get("min") or 1
|
||||
unit = _LIST_UNITS[entry.get("kind")]
|
||||
if len(clean.get(key) or []) < lo and not any(label in e for e in errors):
|
||||
errors.append(f"需要至少 {lo} 张{label}")
|
||||
errors.append(f"需要至少 {lo} {unit}{label}")
|
||||
elif required and entry.get("kind") == "image" and entry.get("role") != "mask":
|
||||
if key not in clean:
|
||||
errors.append(f"请上传{label}")
|
||||
@@ -109,19 +114,33 @@ def validate_input_values(
|
||||
|
||||
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp"})
|
||||
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mov"})
|
||||
_AUDIO_EXTS = frozenset({".mp3", ".wav", ".m4a"})
|
||||
_LIST_UNITS = {"image_list": "张", "video_list": "段", "audio_list": "段"}
|
||||
_LIST_NOUNS = {"image_list": "图片", "video_list": "视频", "audio_list": "音频"}
|
||||
_LIST_EXTS = {"image_list": _IMAGE_EXTS, "video_list": _VIDEO_EXTS, "audio_list": _AUDIO_EXTS}
|
||||
_LIST_FORMAT_HINT = {
|
||||
"image_list": "格式不支持,请使用 PNG / JPG / WebP",
|
||||
"video_list": "格式不支持,请使用 MP4 / WebM / MOV",
|
||||
"audio_list": "格式不支持,请使用 MP3 / WAV / M4A",
|
||||
}
|
||||
|
||||
|
||||
def _image_file_problem(path: str) -> str | None:
|
||||
"""Why ``path`` cannot be a studio reference image (None = fine)."""
|
||||
def _media_file_problem(path: str, kind: str) -> str | None:
|
||||
"""Why ``path`` cannot fill a media-list slot of this kind (None = fine)."""
|
||||
from calliope import config
|
||||
|
||||
target = Path(path)
|
||||
if target.suffix.lower() not in _IMAGE_EXTS:
|
||||
return "格式不支持,请使用 PNG / JPG / WebP"
|
||||
if target.suffix.lower() not in _LIST_EXTS[kind]:
|
||||
return _LIST_FORMAT_HINT[kind]
|
||||
try:
|
||||
target.resolve().relative_to(config.settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return "只能使用在本页上传或从生成结果选入的图片"
|
||||
return f"只能使用在本页上传或从生成结果选入的{_LIST_NOUNS[kind]}"
|
||||
if not target.is_file():
|
||||
return "文件已不存在,请重新上传"
|
||||
return None
|
||||
|
||||
|
||||
def _image_file_problem(path: str) -> str | None:
|
||||
return _media_file_problem(path, "image_list")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -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")
|
||||
@@ -518,6 +578,11 @@ async def migrate_db(db_path: Path) -> None:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN comfy_prompt_id TEXT")
|
||||
if "phase" not in job_cols:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN phase TEXT")
|
||||
# HEL-643: where a job came from — 'user' (every normal generation) or
|
||||
# 'workflow_test' (L3 试运行). Existing rows default to 'user', semantics
|
||||
# unchanged; test jobs get isolated artifacts and no entity write-back.
|
||||
if "origin" not in job_cols:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'user'")
|
||||
# Build Scene's per-scene sessions: origin stamps WHERE a blind session was
|
||||
# born ('chat' = user-created in AI Canvas, 'scene' = auto-created by the
|
||||
# Build Scene rail). The session list excludes origin='scene' so scene
|
||||
|
||||
@@ -243,10 +243,76 @@ 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):
|
||||
workflow_json: dict[str, Any]
|
||||
# HEL-643: optional target connection — given, the analyze response also
|
||||
# carries an L2 connection check (node/model availability via /object_info).
|
||||
endpoint_id: str | None = None
|
||||
# Bypass the /object_info cache (user re-checks after installing nodes).
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class WorkflowCheckRequest(BaseModel):
|
||||
"""L2 re-check of a saved workflow against a live ComfyUI (HEL-643)."""
|
||||
endpoint_id: str | None = None
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class WorkflowTestRequest(BaseModel):
|
||||
"""Launch one real test run of a workflow (HEL-643 L3)."""
|
||||
endpoint_id: str | None = None
|
||||
input_values: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class JobCreate(BaseModel):
|
||||
|
||||
@@ -36,17 +36,25 @@ class QueueManager:
|
||||
scene_id: int | None = None,
|
||||
clip_id: int | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
origin: str = "user",
|
||||
) -> dict[str, Any]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
payload = self._stamp_endpoint(conn, workflow_id, dict(payload or {}))
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id, status, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?)
|
||||
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id,
|
||||
status, payload_json, origin)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||
""",
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload)),
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload), origin),
|
||||
)
|
||||
# HEL-681: every enqueued job counts as a use (最近使用).
|
||||
if workflow_id is not None:
|
||||
conn.execute(
|
||||
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(workflow_id,),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
return row_to_dict(row)
|
||||
|
||||
@@ -21,6 +21,11 @@ from calliope.db import get_db
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.export.runner import run_export
|
||||
from calliope.queue.manager import queue_manager
|
||||
from calliope.queue.workflow_test import (
|
||||
TEST_ORIGIN,
|
||||
prune_workflow_tests,
|
||||
workflow_test_dir,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("calliope.worker")
|
||||
|
||||
@@ -164,6 +169,16 @@ class QueueWorker:
|
||||
"message": f"{self._job_label(job)} failed",
|
||||
},
|
||||
)
|
||||
if str(job.get("origin") or "") == TEST_ORIGIN and job.get("workflow_id"):
|
||||
# Retention: keep only the newest records (and their
|
||||
# artifacts) per workflow (HEL-643).
|
||||
try:
|
||||
prune_workflow_tests(int(job["workflow_id"]))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Pruning workflow-test records for workflow %s failed",
|
||||
job["workflow_id"],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Worker loop error")
|
||||
await asyncio.sleep(config.settings.queue_poll_interval_sec)
|
||||
@@ -173,6 +188,7 @@ class QueueWorker:
|
||||
payload = json.loads(job["payload_json"] or "{}")
|
||||
project_id = job["project_id"]
|
||||
kind = job["kind"]
|
||||
is_test = str(job.get("origin") or "") == TEST_ORIGIN
|
||||
use_dry = bool(config.settings.dry_run)
|
||||
|
||||
if kind == "export":
|
||||
@@ -183,6 +199,13 @@ class QueueWorker:
|
||||
base_url, headers, endpoint_label = self._resolve_endpoint(payload)
|
||||
client = ComfyUIClient(base_url, headers=headers)
|
||||
try:
|
||||
# A workflow test must be a REAL run — a dry-run placeholder that
|
||||
# passes as "测试通过" is exactly the false positive HEL-643 bans.
|
||||
if use_dry and is_test:
|
||||
raise RuntimeError(
|
||||
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
|
||||
"请到「设置」关闭 Dry-run 后重新测试"
|
||||
)
|
||||
if use_dry:
|
||||
queue_manager.set_phase(job["id"], "dry_run")
|
||||
return await self._dry_run(job, payload)
|
||||
@@ -277,7 +300,12 @@ class QueueWorker:
|
||||
"请到「ComfyUI 连接」页修正该节点的媒体类型。"
|
||||
)
|
||||
queue_manager.set_phase(job["id"], "saving")
|
||||
dest_dir = config.settings.assets_dir / str(project_id) / kind
|
||||
if is_test:
|
||||
# Test artifacts live in a scratch dir, isolated from project
|
||||
# media and never written back to entities (HEL-643).
|
||||
dest_dir = workflow_test_dir(int(workflow_id))
|
||||
else:
|
||||
dest_dir = config.settings.assets_dir / str(project_id) / kind
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
paths: list[str] = []
|
||||
for meta in outputs_meta:
|
||||
@@ -291,7 +319,8 @@ class QueueWorker:
|
||||
)
|
||||
paths.append(str(dest))
|
||||
|
||||
self._apply_outputs_to_entities(job, payload, paths)
|
||||
if not is_test:
|
||||
self._apply_outputs_to_entities(job, payload, paths)
|
||||
queue_manager.set_phase(job["id"], "finished")
|
||||
return paths
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Workflow-test job helpers (HEL-643).
|
||||
|
||||
A test job (jobs.origin = 'workflow_test') runs the FULL real pipeline —
|
||||
queue → ComfyUI → outputs — but its artifacts are isolated from project
|
||||
entities: files land under ``assets/_workflow_test/{workflow_id}/`` and are
|
||||
never written back to characters/locations/scenes/clips.
|
||||
|
||||
This module owns the artifact directory, the retention policy (keep the
|
||||
newest N records per workflow) and the purge paths used by "清除测试记录"
|
||||
and workflow deletion.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from calliope import config
|
||||
from calliope.db import get_db
|
||||
|
||||
logger = logging.getLogger("calliope.queue")
|
||||
|
||||
TEST_ORIGIN = "workflow_test"
|
||||
TEST_SOURCE = "workflow_test"
|
||||
# Records (and artifacts) kept per workflow before older ones are pruned.
|
||||
TEST_KEEP = 5
|
||||
|
||||
|
||||
def workflow_test_dir(workflow_id: int) -> Path:
|
||||
return config.settings.assets_dir / "_workflow_test" / str(workflow_id)
|
||||
|
||||
|
||||
def _safe_unlink(path_str: str) -> bool:
|
||||
"""Delete a file if it resolves under assets_dir (missing = already gone)."""
|
||||
try:
|
||||
target = Path(path_str).resolve()
|
||||
target.relative_to(config.settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
if not target.is_file():
|
||||
return False
|
||||
try:
|
||||
target.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _unlink_job_outputs(job: dict[str, Any]) -> None:
|
||||
try:
|
||||
paths = json.loads(job.get("output_paths_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
if not isinstance(paths, list):
|
||||
return
|
||||
for p in paths:
|
||||
if isinstance(p, str) and p.strip():
|
||||
_safe_unlink(p)
|
||||
|
||||
|
||||
def _remove_test_dir_if_empty(workflow_id: int) -> None:
|
||||
d = workflow_test_dir(workflow_id)
|
||||
try:
|
||||
if d.is_dir() and not any(d.iterdir()):
|
||||
d.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _test_job_public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(job)
|
||||
out["payload"] = json.loads(job.get("payload_json") or "{}")
|
||||
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
|
||||
out.pop("payload_json", None)
|
||||
out.pop("output_paths_json", None)
|
||||
return out
|
||||
|
||||
|
||||
def list_test_jobs(workflow_id: int, limit: int = 20) -> list[dict[str, Any]]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" ORDER BY id DESC LIMIT ?",
|
||||
(workflow_id, TEST_ORIGIN, limit),
|
||||
).fetchall()
|
||||
return [_test_job_public(dict(r)) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def find_active_test_job(workflow_id: int, endpoint_id: str | None) -> dict[str, Any] | None:
|
||||
"""The unfinished test job for (workflow, endpoint), if one exists.
|
||||
|
||||
Idempotency guard for the "测试工作流" button: a second click re-uses the
|
||||
in-flight job instead of enqueuing a duplicate.
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" AND status IN ('pending', 'running') ORDER BY id DESC",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
for r in rows:
|
||||
job = dict(r)
|
||||
try:
|
||||
payload = json.loads(job.get("payload_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
payload = {}
|
||||
snap = payload.get("endpoint") or {}
|
||||
if not isinstance(snap, dict):
|
||||
snap = {}
|
||||
if (snap.get("id") or None) == (endpoint_id or None):
|
||||
return _test_job_public(job)
|
||||
return None
|
||||
|
||||
|
||||
def prune_workflow_tests(workflow_id: int, keep: int = TEST_KEEP) -> int:
|
||||
"""Keep only the newest ``keep`` test records (older rows + files go)."""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" ORDER BY id DESC LIMIT -1 OFFSET ?",
|
||||
(workflow_id, TEST_ORIGIN, max(keep, 0)),
|
||||
).fetchall()
|
||||
removed = 0
|
||||
for r in rows:
|
||||
job = dict(r)
|
||||
if job.get("status") in ("pending", "running"):
|
||||
continue
|
||||
_unlink_job_outputs(job)
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (job["id"],))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_remove_test_dir_if_empty(workflow_id)
|
||||
if removed:
|
||||
logger.info("Pruned %s old workflow-test records for workflow %s", removed, workflow_id)
|
||||
return removed
|
||||
|
||||
|
||||
def purge_workflow_tests(workflow_id: int, *, allow_active: bool = False) -> int:
|
||||
"""Delete EVERY test record and artifact for a workflow.
|
||||
|
||||
Returns the removed row count; raises RuntimeError when an unfinished
|
||||
test job is still in flight (unless ``allow_active``).
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
if not allow_active:
|
||||
active = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" AND status IN ('pending', 'running')",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchone()["n"]
|
||||
if active:
|
||||
raise RuntimeError(
|
||||
f"该工作流还有 {active} 个进行中的测试任务,请先等待完成或取消,再清除测试记录"
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchall()
|
||||
removed = 0
|
||||
for r in rows:
|
||||
_unlink_job_outputs(dict(r))
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (r["id"],))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
shutil.rmtree(workflow_test_dir(workflow_id), ignore_errors=True)
|
||||
return removed
|
||||
@@ -197,7 +197,14 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
return [_job_public(j) for j in queue_manager.list_jobs(pid, None, limit)]
|
||||
# Workflow-test jobs (HEL-643) ride the same scratch project but must not
|
||||
# surface in the studio windows' history — they carry their own dialog.
|
||||
jobs = [
|
||||
j
|
||||
for j in queue_manager.list_jobs(pid, None, limit)
|
||||
if str(j.get("origin") or "user") != "workflow_test"
|
||||
]
|
||||
return [_job_public(j) for j in jobs]
|
||||
|
||||
|
||||
@router.delete("/jobs/{job_id}")
|
||||
|
||||
@@ -5,7 +5,9 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from calliope.comfyui.lint import workflow_warnings
|
||||
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,
|
||||
@@ -19,14 +21,43 @@ from calliope.comfyui.parser import (
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
from calliope.comfyui.preflight import check_workflow_against
|
||||
from calliope.comfyui.profiles import detect_prompt_profile
|
||||
from calliope.comfyui.roles import usage_to_kind
|
||||
from calliope.comfyui.ui_convert import convert_ui_workflow
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, row_to_dict
|
||||
from calliope.models.schemas import WorkflowAnalyze, WorkflowCreate, WorkflowUpdate
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.models.schemas import (
|
||||
WorkflowAnalyze,
|
||||
WorkflowCheckRequest,
|
||||
WorkflowConvertRequest,
|
||||
WorkflowCreate,
|
||||
WorkflowImportBatchRequest,
|
||||
WorkflowImportScanRequest,
|
||||
WorkflowTestRequest,
|
||||
WorkflowUIFileItem,
|
||||
WorkflowUpdate,
|
||||
)
|
||||
from calliope.queue.manager import queue_manager
|
||||
from calliope.queue.workflow_test import (
|
||||
TEST_SOURCE,
|
||||
find_active_test_job,
|
||||
list_test_jobs,
|
||||
purge_workflow_tests,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# HEL-681: metadata columns the lightweight list view exposes (everything
|
||||
# except the heavy workflow_json / input_schema / output_map payloads).
|
||||
_META_COLUMNS = (
|
||||
"id, name, kind, usage, is_enabled, category, model_family, media_io, tags,"
|
||||
" favorite, last_used_at, description, source, source_path, prompt_profile,"
|
||||
" created_at, endpoint_id, usage_source, output_map_source"
|
||||
)
|
||||
|
||||
|
||||
def _serialize_workflow(row: Any) -> dict[str, Any]:
|
||||
data = row_to_dict(row)
|
||||
@@ -44,15 +75,68 @@ 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]:
|
||||
"""L2 preflight (HEL-643): workflow vs the target ComfyUI's /object_info.
|
||||
|
||||
An unreachable connection degrades to ``reachable: False`` with an honest
|
||||
「无法核查」 message — it never blocks and never pretends to pass.
|
||||
"""
|
||||
endpoint = settings.resolve_comfyui_endpoint(endpoint_id)
|
||||
if endpoint is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"endpoint_id": endpoint_id,
|
||||
"endpoint_name": None,
|
||||
"findings": [],
|
||||
"message": "没有可用的 ComfyUI 连接,无法核查节点与模型",
|
||||
}
|
||||
base = {
|
||||
"endpoint_id": endpoint.get("id"),
|
||||
"endpoint_name": endpoint.get("name"),
|
||||
"base_url": endpoint.get("base_url"),
|
||||
}
|
||||
client = client_for(endpoint)
|
||||
try:
|
||||
object_info = await client.object_info(force_refresh=refresh)
|
||||
except Exception as exc:
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"findings": [],
|
||||
"message": f"无法核查(连不上该 ComfyUI):{type(exc).__name__}",
|
||||
}
|
||||
finally:
|
||||
await client.close()
|
||||
findings = check_workflow_against(object_info, workflow_json)
|
||||
ok = not any(f.get("level") == "error" for f in findings)
|
||||
return {**base, "ok": ok, "reachable": True, "findings": findings}
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
|
||||
inputs = parse_dynamic_inputs(payload.workflow_json)
|
||||
outputs = parse_dynamic_outputs(payload.workflow_json)
|
||||
judgment = classify_usage(payload.workflow_json)
|
||||
return {
|
||||
result: dict[str, Any] = {
|
||||
"inputs": inputs,
|
||||
"outputs": outputs,
|
||||
"suggested_profile": detect_prompt_profile(payload.workflow_json),
|
||||
@@ -64,6 +148,13 @@ async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
|
||||
"usage_evidence": judgment["evidence"],
|
||||
"usable_usage": judgment["usable_usage"],
|
||||
}
|
||||
# L2 (optional): with a target connection the analyze response also
|
||||
# reports which nodes/models that ComfyUI actually has (HEL-643).
|
||||
if payload.endpoint_id:
|
||||
result["connection_check"] = await _connection_check(
|
||||
payload.endpoint_id, payload.workflow_json, payload.refresh
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _stored_schema(payload_schema: list[dict[str, Any]] | None, workflow: dict[str, Any]) -> str:
|
||||
@@ -133,16 +224,86 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _last_test_summary(job: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Slim "上次测试" record for a workflow row (queried live, never stored)."""
|
||||
try:
|
||||
outputs = json.loads(job.get("output_paths_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
outputs = []
|
||||
return {
|
||||
"id": job["id"],
|
||||
"status": job.get("status"),
|
||||
"phase": job.get("phase"),
|
||||
"error": job.get("error"),
|
||||
"created_at": job.get("created_at"),
|
||||
"completed_at": job.get("completed_at"),
|
||||
"output_count": len(outputs) if isinstance(outputs, list) else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_workflows() -> list[dict[str, Any]]:
|
||||
async def list_workflows(view: str | None = None) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
if view == "meta":
|
||||
# HEL-681: metadata-only list for 150+ scale browsing — no
|
||||
# workflow_json, no per-row re-parse. `needs_mapping` here is the
|
||||
# cheap pending check (usage/output_map not yet settled); rows
|
||||
# that never generated keep showing as pending exactly like the
|
||||
# full view reports them.
|
||||
rows = conn.execute(
|
||||
f"SELECT {_META_COLUMNS} FROM workflows ORDER BY favorite DESC,"
|
||||
" last_used_at DESC, id DESC"
|
||||
).fetchall()
|
||||
out = []
|
||||
for row in rows:
|
||||
data = row_to_dict(row)
|
||||
data["is_enabled"] = bool(data.get("is_enabled"))
|
||||
data["favorite"] = bool(data.get("favorite"))
|
||||
data["media_io"] = _load_json_col(data.get("media_io"), {"in": [], "out": []})
|
||||
data["tags"] = _load_json_col(data.get("tags"), [])
|
||||
data["needs_mapping"] = data.get("usage") is None or data.get(
|
||||
"output_map_source"
|
||||
) is None
|
||||
out.append(data)
|
||||
return out
|
||||
rows = conn.execute("SELECT * FROM workflows ORDER BY id DESC").fetchall()
|
||||
return [_serialize_workflow(r) for r in rows]
|
||||
out = [_serialize_workflow(r) for r in rows]
|
||||
# 上次测试 state is read live from the newest test job per workflow —
|
||||
# no cached column that could go stale (HEL-643).
|
||||
latest: dict[int, dict[str, Any]] = {}
|
||||
for jr in conn.execute(
|
||||
"SELECT * FROM jobs WHERE origin = 'workflow_test' AND id IN"
|
||||
" (SELECT MAX(id) FROM jobs WHERE origin = 'workflow_test' GROUP BY workflow_id)"
|
||||
).fetchall():
|
||||
latest[int(jr["workflow_id"])] = _last_test_summary(dict(jr))
|
||||
for data in out:
|
||||
data["last_test"] = latest.get(int(data["id"]))
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/check")
|
||||
async def check_workflow(workflow_id: int, payload: WorkflowCheckRequest) -> dict[str, Any]:
|
||||
"""L2 re-check of a saved workflow against its (or a chosen) connection.
|
||||
|
||||
The user installs missing nodes/models in ComfyUI, comes back here and
|
||||
re-checks — no re-import needed.
|
||||
"""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT workflow_json, endpoint_id FROM workflows WHERE id = ?", (workflow_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
endpoint_id = payload.endpoint_id or row["endpoint_id"]
|
||||
return await _connection_check(endpoint_id, json.loads(row["workflow_json"]), payload.refresh)
|
||||
|
||||
|
||||
@router.post("/dedupe")
|
||||
async def dedupe_workflows() -> dict[str, Any]:
|
||||
"""Delete byte-identical duplicate workflows, keeping the lowest id.
|
||||
@@ -282,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
|
||||
@@ -341,6 +508,119 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
|
||||
conn.close()
|
||||
|
||||
|
||||
def _job_public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(job)
|
||||
out["payload"] = json.loads(job.get("payload_json") or "{}")
|
||||
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
|
||||
out.pop("payload_json", None)
|
||||
out.pop("output_paths_json", None)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/test")
|
||||
async def test_workflow(workflow_id: int, payload: WorkflowTestRequest) -> dict[str, Any]:
|
||||
"""Launch (or re-attach to) one REAL test run of a workflow (HEL-643 L3).
|
||||
|
||||
The job goes through the ordinary queue → ComfyUI → outputs pipeline;
|
||||
only its artifacts and bookkeeping are isolated (origin='workflow_test').
|
||||
A dry-run setting is refused outright — placeholder output must never
|
||||
count as a test pass. Repeat clicks reuse the in-flight job.
|
||||
"""
|
||||
from calliope import config
|
||||
|
||||
if config.settings.dry_run:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
|
||||
"请到「设置」关闭 Dry-run 后再测试"
|
||||
),
|
||||
)
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
wf = conn.execute(
|
||||
"SELECT id, kind, workflow_json, input_schema, output_map, output_map_source,"
|
||||
" endpoint_id FROM workflows WHERE id = ?",
|
||||
(workflow_id,),
|
||||
).fetchone()
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
workflow_json = json.loads(wf["workflow_json"])
|
||||
broken = blocking_problem(workflow_json)
|
||||
if broken:
|
||||
raise HTTPException(status_code=400, detail=broken)
|
||||
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
|
||||
)
|
||||
input_values, problems = validate_input_values(schema, payload.input_values)
|
||||
if problems:
|
||||
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
|
||||
|
||||
endpoint_id = payload.endpoint_id or wf["endpoint_id"] or None
|
||||
snapshot = endpoint_snapshot(endpoint_id)
|
||||
active = find_active_test_job(workflow_id, (snapshot or {}).get("id"))
|
||||
if active is not None:
|
||||
return {"ok": True, "job": _job_public(active), "reused": True}
|
||||
|
||||
from calliope.routers.playground import ensure_playground_project
|
||||
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
job = queue_manager.enqueue(
|
||||
project_id=pid,
|
||||
kind=wf["kind"],
|
||||
workflow_id=workflow_id,
|
||||
scene_id=None,
|
||||
payload={
|
||||
"input_values": input_values,
|
||||
"source": TEST_SOURCE,
|
||||
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
|
||||
},
|
||||
origin="workflow_test",
|
||||
)
|
||||
await event_bus.publish(
|
||||
"job.created",
|
||||
{"job_id": job["id"], "kind": job["kind"], "source": TEST_SOURCE,
|
||||
"project_id": job["project_id"], "workflow_test": True},
|
||||
)
|
||||
return {"ok": True, "job": _job_public(job), "reused": False}
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/tests")
|
||||
async def list_workflow_tests(workflow_id: int) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
return list_test_jobs(workflow_id)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/clear-tests")
|
||||
async def clear_workflow_tests(workflow_id: int) -> dict[str, Any]:
|
||||
"""Delete every test record and artifact for this workflow."""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
try:
|
||||
removed = purge_workflow_tests(workflow_id)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
await event_bus.publish("job.deleted", {"workflow_id": workflow_id, "source": TEST_SOURCE})
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}")
|
||||
async def delete_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
conn = get_db(settings.db_path)
|
||||
@@ -366,8 +646,288 @@ async def delete_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
"请先等任务完成或取消任务,再删除该工作流"
|
||||
),
|
||||
)
|
||||
# Test records/artifacts belong to the workflow — cascade (HEL-643).
|
||||
# The 409 above guarantees none are in flight here.
|
||||
purge_workflow_tests(workflow_id, allow_active=True)
|
||||
conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HEL-681 (S2): UI→API conversion dry-run + batch import + repo scan.
|
||||
# The convert endpoint never writes; import-batch converts again server-side
|
||||
# and skips anything uncertain (needs_confirmation / lint-blocked / conflict)
|
||||
# — repeated runs import nothing new, so the batch is idempotent by design.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _convert_one(item: WorkflowUIFileItem) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
"""(report, converted workflow) for one UI file — shared by every
|
||||
convert/import path so the analysis can never drift between them."""
|
||||
result = convert_ui_workflow(item.workflow_json)
|
||||
stem = (
|
||||
item.source_path.replace("\\", "/").rsplit("/", 1)[-1].removesuffix(".json")
|
||||
if item.source_path
|
||||
else None
|
||||
)
|
||||
name = item.name or stem or "未命名工作流"
|
||||
report: dict[str, Any] = {
|
||||
"name": name,
|
||||
"source_path": item.source_path,
|
||||
"status": result.status,
|
||||
"needs_confirmation": result.needs_confirmation,
|
||||
"issues": [
|
||||
{"node_id": i.node_id, "code": i.code, "level": i.level, "message": i.message}
|
||||
for i in result.issues
|
||||
],
|
||||
}
|
||||
if result.workflow is None:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = "转换失败:" + (
|
||||
result.issues[0].message if result.issues else "未知错误"
|
||||
)
|
||||
return report, None
|
||||
suggested = suggested_metadata(result.workflow, item.source_path, item.workflow_json)
|
||||
report["suggested"] = suggested
|
||||
# The import gate re-runs the product's own checks — a converted graph
|
||||
# that the lint would block (missing model link etc.) is reported, not
|
||||
# silently imported.
|
||||
broken = blocking_problem(result.workflow)
|
||||
if broken:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = broken
|
||||
report["status"] = "warn"
|
||||
return report, result.workflow
|
||||
if result.needs_confirmation:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = "存在无法可靠映射的控件,需人工确认后重导"
|
||||
return report, result.workflow
|
||||
report["importable"] = True
|
||||
return report, result.workflow
|
||||
|
||||
|
||||
@router.post("/convert")
|
||||
async def convert_workflows(payload: WorkflowConvertRequest) -> dict[str, Any]:
|
||||
"""Dry-run UI→API conversion for a batch of files: per-file ok/warn/fail,
|
||||
issues, suggested metadata and importability. Nothing is stored."""
|
||||
files = []
|
||||
object_info: dict[str, Any] | None = None
|
||||
client = None
|
||||
if payload.endpoint_id:
|
||||
endpoint = settings.resolve_comfyui_endpoint(payload.endpoint_id)
|
||||
if endpoint is not None:
|
||||
client = client_for(endpoint)
|
||||
try:
|
||||
object_info = await client.object_info(force_refresh=payload.refresh)
|
||||
except Exception:
|
||||
object_info = None
|
||||
try:
|
||||
for item in payload.files:
|
||||
report, converted = _convert_one(item)
|
||||
if object_info is not None and report.get("importable") and converted:
|
||||
findings = check_workflow_against(object_info, converted)
|
||||
errors = [f for f in findings if f.get("level") == "error"]
|
||||
report["preflight"] = {
|
||||
"endpoint_id": payload.endpoint_id,
|
||||
"ok": not errors,
|
||||
"findings": findings,
|
||||
}
|
||||
if errors:
|
||||
report["importable"] = False
|
||||
report["skip_reason"] = errors[0].get("message") or "preflight 未通过"
|
||||
if payload.include_workflows:
|
||||
report["workflow_json"] = converted
|
||||
files.append(report)
|
||||
finally:
|
||||
if client is not None:
|
||||
await client.close()
|
||||
counts = {key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")}
|
||||
return {
|
||||
"total": len(files),
|
||||
**counts,
|
||||
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
|
||||
"importable": sum(1 for f in files if f.get("importable")),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def _insert_converted(
|
||||
conn: Any, item: WorkflowUIFileItem, report: dict[str, Any], converted: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Insert one converted workflow row (import-batch's write path).
|
||||
|
||||
Mirrors create_workflow's enable gate: usage must be settled (auto or the
|
||||
wizard's confirmation) and unknown outputs need a mapping; anything less
|
||||
lands disabled exactly like a manual import would.
|
||||
"""
|
||||
suggested = report["suggested"]
|
||||
judgment = classify_usage(converted)
|
||||
usage = item.usage or judgment["usage"]
|
||||
usage_source = "user" if item.usage else "auto"
|
||||
kind = usage_to_kind(usage) or suggested["kind"]
|
||||
profile = detect_prompt_profile(converted)
|
||||
if item.output_map:
|
||||
problems = validate_output_map(converted, item.output_map)
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
output_map = json.dumps(item.output_map)
|
||||
output_map_source = "user"
|
||||
else:
|
||||
auto_map = auto_output_map(converted)
|
||||
output_map = json.dumps(auto_map) if auto_map else None
|
||||
output_map_source = "auto" if auto_map else None
|
||||
mapping_ok = not needs_user_output_map(converted) or bool(item.output_map)
|
||||
workflow_text = json.dumps(converted)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM workflows WHERE name = ? AND kind = ? AND workflow_json = ?",
|
||||
(report["name"], kind, workflow_text),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return {
|
||||
"result": "skipped",
|
||||
"skip_reason": "conflict",
|
||||
"existing_id": existing["id"],
|
||||
}
|
||||
is_enabled = 1 if (usage and mapping_ok) else 0
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO workflows (name, kind, workflow_json, input_schema, output_schema,
|
||||
description, prompt_profile, endpoint_id, is_enabled,
|
||||
usage, usage_source, output_map, output_map_source,
|
||||
category, model_family, media_io, tags, favorite,
|
||||
source, source_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'ui_convert', ?)
|
||||
""",
|
||||
(
|
||||
report["name"],
|
||||
kind,
|
||||
workflow_text,
|
||||
json.dumps(
|
||||
item.input_schema if item.input_schema else parse_dynamic_inputs(converted)
|
||||
),
|
||||
json.dumps(parse_dynamic_outputs(converted)),
|
||||
item.description if item.description is not None else suggested["description"],
|
||||
profile,
|
||||
is_enabled,
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
suggested["category"],
|
||||
suggested["model_family"],
|
||||
json.dumps({"in": suggested["media_in"], "out": suggested["media_out"]}),
|
||||
json.dumps(item.tags or []),
|
||||
item.source_path,
|
||||
),
|
||||
)
|
||||
return {"result": "imported", "id": cur.lastrowid}
|
||||
|
||||
|
||||
async def _run_import(items: list[WorkflowUIFileItem]) -> dict[str, Any]:
|
||||
reports: list[dict[str, Any]] = []
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
for item in items:
|
||||
report, converted = _convert_one(item)
|
||||
if not report.get("importable"):
|
||||
report["result"] = "skipped"
|
||||
reports.append(report)
|
||||
continue
|
||||
try:
|
||||
outcome = _insert_converted(conn, item, report, converted)
|
||||
except HTTPException as exc:
|
||||
report["result"] = "failed"
|
||||
report["skip_reason"] = str(exc.detail)
|
||||
reports.append(report)
|
||||
continue
|
||||
conn.commit()
|
||||
report.update(outcome)
|
||||
reports.append(report)
|
||||
finally:
|
||||
conn.close()
|
||||
imported = [r for r in reports if r.get("result") == "imported"]
|
||||
skipped = [
|
||||
{"name": r["name"], "source_path": r["source_path"], "reason": r.get("skip_reason")}
|
||||
for r in reports
|
||||
if r.get("result") != "imported"
|
||||
]
|
||||
return {
|
||||
"total": len(reports),
|
||||
"imported": len(imported),
|
||||
"skipped": len(skipped),
|
||||
"imported_ids": [r["id"] for r in imported],
|
||||
"skipped_files": skipped,
|
||||
"files": reports,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/import-batch")
|
||||
async def import_workflows_batch(payload: WorkflowImportBatchRequest) -> dict[str, Any]:
|
||||
"""Batch-import UI-format workflows: convert server-side, insert what is
|
||||
cleanly convertible, skip conflicts and anything uncertain. Re-running
|
||||
with the same files creates zero new rows."""
|
||||
return await _run_import(payload.files)
|
||||
|
||||
|
||||
@router.post("/import-scan")
|
||||
async def import_workflows_scan(payload: WorkflowImportScanRequest) -> dict[str, Any]:
|
||||
"""Scan a server-side directory for UI-format workflow JSONs and run the
|
||||
convert (dry_run, default) or import pipeline over every file found."""
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(payload.root)
|
||||
if not root.is_absolute():
|
||||
candidates = [
|
||||
Path.cwd() / root,
|
||||
Path(__file__).resolve().parents[4] / root,
|
||||
]
|
||||
root = next((c for c in candidates if c.is_dir()), root)
|
||||
if not root.is_dir():
|
||||
raise HTTPException(status_code=404, detail=f"目录不存在:{payload.root}")
|
||||
items: list[WorkflowUIFileItem] = []
|
||||
for path in sorted(root.rglob("*.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if not (isinstance(data, dict) and isinstance(data.get("nodes"), list)):
|
||||
continue # API-format or non-workflow files: not this pipeline's job
|
||||
items.append(
|
||||
WorkflowUIFileItem(
|
||||
source_path=str(path.relative_to(root)), workflow_json=data
|
||||
)
|
||||
)
|
||||
if payload.dry_run:
|
||||
files = [_convert_one(item)[0] for item in items]
|
||||
counts = {
|
||||
key: sum(1 for f in files if f["status"] == key) for key in ("ok", "warn", "fail")
|
||||
}
|
||||
return {
|
||||
"total": len(files),
|
||||
**counts,
|
||||
"needs_confirmation": sum(1 for f in files if f["needs_confirmation"]),
|
||||
"importable": sum(1 for f in files if f.get("importable")),
|
||||
"files": files,
|
||||
}
|
||||
return await _run_import(items)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/touch")
|
||||
async def touch_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
"""Record a use (最近使用) — called by the generate pipeline on enqueue."""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
conn.execute(
|
||||
"UPDATE workflows SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(workflow_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -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,266 @@
|
||||
"""媒体组泛化(HEL-643 批次 3):参考视频/参考音频按 JSON 实际连线进入
|
||||
input_schema 与提交流程;无连线则无入口;05_ref2va 只保留参考图。
|
||||
|
||||
视频/音频参考的真实 H3 JSON 暂无可执行样本(.15 无 ComfyUI),本套用
|
||||
自造 fixture + fake ComfyUI 验证解析/表单/回填/上传全链路(模拟链路)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import PNG_1PX, _real_run
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uploads_dir(client):
|
||||
d = settings.assets_dir / "uploads"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _load_05() -> dict:
|
||||
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _ref2va_workflow(*, images=1, videos=1, audios=1) -> dict:
|
||||
"""H3 Ref2VA 形状的自造 JSON:图/视频/音频各一组 Autogrow 连线。"""
|
||||
wf: dict = {
|
||||
"1": {
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"inputs": {"value": "提示词"},
|
||||
},
|
||||
"92": {
|
||||
"class_type": "SaveVideo",
|
||||
"inputs": {"video": ["131", 0]},
|
||||
},
|
||||
"131": {
|
||||
"class_type": "MiniMaxH3ReferenceToVideo",
|
||||
"inputs": {"prompt": ["1", 0]},
|
||||
},
|
||||
"901": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
|
||||
"902": {"class_type": "LoadVideo", "inputs": {"file": "ref.mp4", "force_rate": 0}},
|
||||
"903": {"class_type": "LoadAudio", "inputs": {"audio": "ref.wav"}},
|
||||
}
|
||||
ins = wf["131"]["inputs"]
|
||||
for i in range(images):
|
||||
ins[f"ref_images.ref_image_{i}"] = ["901", 0]
|
||||
for i in range(videos):
|
||||
ins[f"ref_videos.ref_video_{i}"] = ["902", 0]
|
||||
for i in range(audios):
|
||||
ins[f"ref_audios.ref_audio_{i}"] = ["903", 0]
|
||||
return wf
|
||||
|
||||
|
||||
def _entry(schema: list[dict], key_suffix: str) -> dict | None:
|
||||
for e in schema:
|
||||
if e["nodeId"].endswith("@" + key_suffix):
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema: 入口只由实际连线决定
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ref2va_schema_exposes_three_media_groups():
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow())
|
||||
img = _entry(schema, "ref_images")
|
||||
vid = _entry(schema, "ref_videos")
|
||||
aud = _entry(schema, "ref_audios")
|
||||
assert img and img["kind"] == "image_list" and img["role"] == "reference_image"
|
||||
assert img["max"] == 9 and img["label"] == "参考图"
|
||||
assert vid and vid["kind"] == "video_list" and vid["role"] == "reference_video"
|
||||
assert vid["max"] == 3 and vid["label"] == "参考视频"
|
||||
assert aud and aud["kind"] == "audio_list" and aud["role"] == "reference_audio"
|
||||
assert aud["max"] == 3 and aud["label"] == "参考音频"
|
||||
# 官方限额提示按节点类名驱动(通用检测不携带 hint)
|
||||
assert img["media_hint"] == vid["media_hint"] == aud["media_hint"] == "minimax_h3_ref2va"
|
||||
# 被组吸收的 loader 不再单独出现
|
||||
assert not any(e["nodeId"] == "902" for e in schema)
|
||||
assert not any(e["nodeId"] == "903" for e in schema)
|
||||
|
||||
|
||||
def test_unwired_media_kinds_produce_no_entry():
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow(videos=0, audios=0))
|
||||
assert _entry(schema, "ref_images") is not None
|
||||
assert _entry(schema, "ref_videos") is None
|
||||
assert _entry(schema, "ref_audios") is None
|
||||
|
||||
|
||||
def test_generic_class_gets_group_without_hint():
|
||||
wf = _ref2va_workflow(videos=0, audios=0)
|
||||
wf["20"] = {"class_type": "GenericRefNode", "inputs": {}}
|
||||
wf["701"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "a.mp4"}}
|
||||
wf["702"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "b.mp4"}}
|
||||
wf["20"]["inputs"]["media.clip_0"] = ["701", 0]
|
||||
wf["20"]["inputs"]["media.clip_1"] = ["702", 0]
|
||||
schema = parse_dynamic_inputs(wf)
|
||||
g = _entry(schema, "media")
|
||||
assert g and g["kind"] == "video_list" and g["max"] == 9
|
||||
assert "media_hint" not in g
|
||||
|
||||
|
||||
def test_05_ref2va_fixture_stays_image_only():
|
||||
schema = parse_dynamic_inputs(_load_05())
|
||||
lists = [e for e in schema if e["kind"].endswith("_list")]
|
||||
assert len(lists) == 1
|
||||
assert lists[0]["kind"] == "image_list" and lists[0]["max"] == 9
|
||||
assert lists[0]["nodeId"] == "131@ref_images"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _media_file(path: str, data: bytes) -> str:
|
||||
p = Path(path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(data)
|
||||
return str(p)
|
||||
|
||||
|
||||
def test_validate_video_and_audio_lists(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow())
|
||||
img_key = _entry(schema, "ref_images")["nodeId"]
|
||||
vid_key = _entry(schema, "ref_videos")["nodeId"]
|
||||
aud_key = _entry(schema, "ref_audios")["nodeId"]
|
||||
png = _media_file(str(uploads_dir / "ok.png"), PNG_1PX)
|
||||
mp4 = _media_file(str(uploads_dir / "ok.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "ok.mp3"), b"00")
|
||||
|
||||
clean, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == [] and clean[vid_key] == [mp4] and clean[aud_key] == [mp3]
|
||||
|
||||
# 错误格式(图片文件填视频槽 / 视频文件填音频槽)
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [png], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["参考视频 1:格式不支持,请使用 MP4 / WebM / MOV"]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp4]}
|
||||
)
|
||||
assert errs == ["参考音频 1:格式不支持,请使用 MP3 / WAV / M4A"]
|
||||
|
||||
# 上限(H3 官方 3 段)与最少 1 段
|
||||
four = [_media_file(str(uploads_dir / f"v{i}.mp4"), b"00") for i in range(4)]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: four, aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["参考视频最多 3 段(当前 4 段),请删除多余的视频"]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["需要至少 1 段参考视频"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patcher + full chain against fake ComfyUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patcher_clones_and_backfills_each_media_kind(uploads_dir):
|
||||
wf = _ref2va_workflow()
|
||||
schema = parse_dynamic_inputs(wf)
|
||||
pngs = [_media_file(str(uploads_dir / "r1.png"), PNG_1PX),
|
||||
_media_file(str(uploads_dir / "r2.png"), PNG_1PX)]
|
||||
mp4 = _media_file(str(uploads_dir / "v1.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "a1.mp3"), b"00")
|
||||
patched = patch_workflow(
|
||||
wf,
|
||||
{
|
||||
_entry(schema, "ref_images")["nodeId"]: pngs,
|
||||
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
||||
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
||||
},
|
||||
)
|
||||
ins = patched["131"]["inputs"]
|
||||
image_links = [v for k, v in ins.items() if k.startswith("ref_images.")]
|
||||
video_links = [v for k, v in ins.items() if k.startswith("ref_videos.")]
|
||||
audio_links = [v for k, v in ins.items() if k.startswith("ref_audios.")]
|
||||
assert len(image_links) == 2 and len(video_links) == 1 and len(audio_links) == 1
|
||||
loaders = {nid: n for nid, n in patched.items()
|
||||
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")}
|
||||
imgs = [n for n in loaders.values() if n["class_type"] == "LoadImage"]
|
||||
vids = [n for n in loaders.values() if n["class_type"] == "LoadVideo"]
|
||||
auds = [n for n in loaders.values() if n["class_type"] == "LoadAudio"]
|
||||
assert len(imgs) == 2 and all(n["inputs"]["image"] in pngs for n in imgs)
|
||||
assert vids and vids[0]["inputs"]["file"] == mp4
|
||||
assert auds and auds[0]["inputs"]["audio"] == mp3
|
||||
# 克隆出的节点带中文槽位标题
|
||||
assert {n["_meta"]["title"] for n in imgs} == {"参考图 1", "参考图 2"}
|
||||
assert vids[0]["_meta"]["title"] == "参考视频 1"
|
||||
assert auds[0]["_meta"]["title"] == "参考音频 1"
|
||||
|
||||
|
||||
def test_full_chain_uploads_and_backfills_media_groups(client, fake_comfy, uploads_dir):
|
||||
"""提交 → 上传 → 回填:fake ComfyUI 收到的 prompt 里每类 loader 都
|
||||
指向已上传文件(模拟链路:fake 端点,非真机 H3)。"""
|
||||
wf = _ref2va_workflow()
|
||||
png = _media_file(str(uploads_dir / "ref.png"), PNG_1PX)
|
||||
mp4 = _media_file(str(uploads_dir / "ref.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "ref.mp3"), b"00")
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "媒体组链路", "workflow_json": wf, "usage": "ref2video"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf_id = r.json()["id"]
|
||||
schema = r.json()["input_schema"]
|
||||
|
||||
with _real_run(client, fake_comfy):
|
||||
fake_comfy.out_name = "media_out.mp4"
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test",
|
||||
json={
|
||||
"input_values": {
|
||||
_entry(schema, "ref_images")["nodeId"]: [png],
|
||||
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
||||
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job_id = r.json()["job"]["id"]
|
||||
|
||||
deadline = time.time() + 25
|
||||
job = {}
|
||||
while time.time() < deadline:
|
||||
job = client.get(f"/api/jobs/{job_id}").json()
|
||||
if job["status"] not in ("pending", "running"):
|
||||
break
|
||||
time.sleep(0.4)
|
||||
assert job["status"] == "done", job.get("error")
|
||||
|
||||
assert len(fake_comfy.uploads) >= 3 # 图、视频、音频各一次
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
loaders = [n for n in queued.values()
|
||||
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")]
|
||||
by_field = {
|
||||
(n["class_type"], k): v
|
||||
for n in loaders
|
||||
for k, v in n["inputs"].items()
|
||||
if isinstance(v, str) and "uploaded-input" in v
|
||||
}
|
||||
assert ("LoadImage", "image") in by_field
|
||||
assert ("LoadVideo", "file") in by_field
|
||||
assert ("LoadAudio", "audio") in by_field
|
||||
# 组连线数与提交数一致
|
||||
ins = queued["131"]["inputs"]
|
||||
assert len([k for k in ins if k.startswith("ref_images.")]) == 1
|
||||
assert len([k for k in ins if k.startswith("ref_videos.")]) == 1
|
||||
assert len([k for k in ins if k.startswith("ref_audios.")]) == 1
|
||||
@@ -50,6 +50,9 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
},
|
||||
)
|
||||
return
|
||||
if self.path == "/object_info":
|
||||
self._json(200, dict(getattr(self.server, "object_info", None) or {}))
|
||||
return
|
||||
if self.path.startswith("/history/"):
|
||||
pid = self.path[len("/history/") :]
|
||||
entry = self.server.history.get(pid)
|
||||
@@ -78,9 +81,13 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
{"filename": self.server.out_name, "subfolder": "", "type": "output"}
|
||||
]
|
||||
}
|
||||
outputs = (
|
||||
{} if getattr(self.server, "drop_outputs", False) else
|
||||
{self._output_node(body): out_entry}
|
||||
)
|
||||
self.server.history[prompt_id] = {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": {self._output_node(body): out_entry},
|
||||
"outputs": outputs,
|
||||
"prompt": [],
|
||||
}
|
||||
self.server.prompts.append(json.loads(body.decode("utf-8") or "{}"))
|
||||
@@ -135,6 +142,8 @@ def fake_comfy():
|
||||
server.out_name = "out-1.png"
|
||||
server.outputs_key = "images"
|
||||
server.forced_output_node = None
|
||||
server.object_info = {}
|
||||
server.drop_outputs = False
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"""HEL-681: UI→API converter unit tests + ground-truth cross-checks.
|
||||
|
||||
The synthetic graphs pin the structural semantics (reroute / bypass / mute /
|
||||
subgraph expansion / Set-Get variables / control_after_generate pairing);
|
||||
the corpus section runs the real 146-file library and the repo's API exports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.lint import workflow_warnings
|
||||
from calliope.comfyui.ui_convert import convert_ui_workflow
|
||||
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
|
||||
def _node(nid, ntype, *, mode=0, inputs=None, outputs=None, wv=None, wvn=None, title=None):
|
||||
node = {"id": nid, "type": ntype, "mode": mode, "inputs": inputs or [], "order": 0}
|
||||
if outputs is not None:
|
||||
node["outputs"] = outputs
|
||||
if wv is not None:
|
||||
node["widgets_values"] = wv
|
||||
if wvn is not None:
|
||||
node["widgets_values_named"] = wvn
|
||||
if title:
|
||||
node["title"] = title
|
||||
return node
|
||||
|
||||
|
||||
def _in_slot(name, type_, link=None, widget=None):
|
||||
entry = {"name": name, "type": type_, "link": link}
|
||||
if widget:
|
||||
entry["widget"] = {"name": widget}
|
||||
return entry
|
||||
|
||||
|
||||
def _out(name, type_, links=None):
|
||||
return {"name": name, "type": type_, "links": links or [], "slot_index": 0}
|
||||
|
||||
|
||||
def _base_graph():
|
||||
"""text2image: loader → prompts/latent → KSampler → VAE → SaveImage."""
|
||||
nodes = [
|
||||
_node(
|
||||
1,
|
||||
"CheckpointLoaderSimple",
|
||||
outputs=[_out("MODEL", "MODEL"), _out("CLIP", "CLIP"), _out("VAE", "VAE")],
|
||||
wv=["model.safetensors"],
|
||||
inputs=[_in_slot("ckpt_name", "COMBO", widget="ckpt_name")],
|
||||
),
|
||||
_node(
|
||||
2,
|
||||
"CLIPTextEncode",
|
||||
inputs=[
|
||||
_in_slot("clip", "CLIP", link=1),
|
||||
_in_slot("text", "STRING", widget="text"),
|
||||
],
|
||||
outputs=[_out("CONDITIONING", "CONDITIONING")],
|
||||
wv=["a cat"],
|
||||
),
|
||||
_node(
|
||||
3,
|
||||
"CLIPTextEncode",
|
||||
inputs=[
|
||||
_in_slot("clip", "CLIP", link=2),
|
||||
_in_slot("text", "STRING", widget="text"),
|
||||
],
|
||||
outputs=[_out("CONDITIONING", "CONDITIONING")],
|
||||
wv=["bad quality"],
|
||||
),
|
||||
_node(
|
||||
4,
|
||||
"EmptyLatentImage",
|
||||
inputs=[
|
||||
_in_slot("width", "INT", widget="width"),
|
||||
_in_slot("height", "INT", widget="height"),
|
||||
_in_slot("batch_size", "INT", widget="batch_size"),
|
||||
],
|
||||
outputs=[_out("LATENT", "LATENT")],
|
||||
wv=[512, 512, 1],
|
||||
),
|
||||
_node(
|
||||
5,
|
||||
"KSampler",
|
||||
inputs=[
|
||||
_in_slot("model", "MODEL", link=3),
|
||||
_in_slot("positive", "CONDITIONING", link=4),
|
||||
_in_slot("negative", "CONDITIONING", link=5),
|
||||
_in_slot("latent_image", "LATENT", link=6),
|
||||
_in_slot("seed", "INT", widget="seed"),
|
||||
_in_slot("steps", "INT", widget="steps"),
|
||||
_in_slot("cfg", "FLOAT", widget="cfg"),
|
||||
_in_slot("sampler_name", "COMBO", widget="sampler_name"),
|
||||
_in_slot("scheduler", "COMBO", widget="scheduler"),
|
||||
_in_slot("denoise", "FLOAT", widget="denoise"),
|
||||
],
|
||||
outputs=[_out("LATENT", "LATENT")],
|
||||
# seed 42 + control "fixed" rides together, then five widgets
|
||||
wv=[42, "fixed", 20, 7, "euler", "normal", 1.0],
|
||||
),
|
||||
_node(
|
||||
6,
|
||||
"VAEDecode",
|
||||
inputs=[
|
||||
_in_slot("samples", "LATENT", link=7),
|
||||
_in_slot("vae", "VAE", link=8),
|
||||
],
|
||||
outputs=[_out("IMAGE", "IMAGE")],
|
||||
),
|
||||
_node(
|
||||
7,
|
||||
"SaveImage",
|
||||
inputs=[
|
||||
_in_slot("images", "IMAGE", link=9),
|
||||
_in_slot("filename_prefix", "STRING", widget="filename_prefix"),
|
||||
],
|
||||
wv=["test"],
|
||||
),
|
||||
]
|
||||
links = [
|
||||
[1, 1, 1, 2, 0, "CLIP"],
|
||||
[2, 1, 1, 3, 0, "CLIP"],
|
||||
[3, 1, 0, 5, 0, "MODEL"],
|
||||
[4, 2, 0, 5, 1, "CONDITIONING"],
|
||||
[5, 3, 0, 5, 2, "CONDITIONING"],
|
||||
[6, 4, 0, 5, 3, "LATENT"],
|
||||
[7, 5, 0, 6, 0, "LATENT"],
|
||||
[8, 1, 2, 6, 1, "VAE"],
|
||||
[9, 6, 0, 7, 0, "IMAGE"],
|
||||
]
|
||||
return {"nodes": nodes, "links": links, "last_node_id": 7, "last_link_id": 9}
|
||||
|
||||
|
||||
def _convert(graph):
|
||||
return convert_ui_workflow(graph)
|
||||
|
||||
|
||||
def test_clean_conversion_maps_everything():
|
||||
result = _convert(_base_graph())
|
||||
assert result.status == "ok"
|
||||
assert result.needs_confirmation is False
|
||||
wf = result.workflow
|
||||
assert set(wf) == {"1", "2", "3", "4", "5", "6", "7"}
|
||||
ks = wf["5"]
|
||||
assert ks["class_type"] == "KSampler"
|
||||
assert ks["inputs"]["seed"] == 42 # control_after_generate consumed silently
|
||||
assert ks["inputs"]["steps"] == 20
|
||||
assert ks["inputs"]["denoise"] == 1.0
|
||||
assert ks["inputs"]["model"] == ["1", 0]
|
||||
assert ks["inputs"]["latent_image"] == ["4", 0]
|
||||
# lint on the converted graph must be error-free
|
||||
assert not [w for w in workflow_warnings(wf) if w["level"] == "error"]
|
||||
|
||||
|
||||
def test_upload_button_widgets_are_consumed_but_never_emitted():
|
||||
graph = _base_graph()
|
||||
load = _node(
|
||||
8,
|
||||
"LoadImage",
|
||||
inputs=[
|
||||
_in_slot("image", "COMBO", widget="image"),
|
||||
_in_slot("upload", "IMAGEUPLOAD", widget="upload"),
|
||||
],
|
||||
outputs=[_out("IMAGE", "IMAGE")],
|
||||
wv=["sample.png", "image"],
|
||||
)
|
||||
save = _node(
|
||||
9,
|
||||
"PreviewImage",
|
||||
inputs=[
|
||||
_in_slot("images", "IMAGE", link=20),
|
||||
_in_slot("filename_prefix", "STRING", widget="filename_prefix"),
|
||||
],
|
||||
wv=["preview"],
|
||||
)
|
||||
graph["nodes"] += [load, save]
|
||||
graph["links"].append([20, 8, 0, 9, 0, "IMAGE"])
|
||||
result = _convert(graph)
|
||||
assert result.workflow["8"]["inputs"] == {"image": "sample.png"}
|
||||
|
||||
|
||||
def test_reroute_pass_through():
|
||||
graph = _base_graph()
|
||||
reroute = _node(8, "Reroute", inputs=[_in_slot("(in)", "*", link=9)])
|
||||
graph["nodes"].append(reroute)
|
||||
# SaveImage now fed by the reroute instead of VAEDecode directly
|
||||
graph["nodes"][6]["inputs"][0]["link"] = 10
|
||||
graph["links"].append([10, 8, 0, 7, 0, "IMAGE"])
|
||||
result = _convert(graph)
|
||||
assert result.workflow["7"]["inputs"]["images"] == ["6", 0]
|
||||
assert "8" not in result.workflow
|
||||
|
||||
|
||||
def test_bypass_pass_through_and_mute_drop():
|
||||
graph = _base_graph()
|
||||
# bypass the KSampler: VAEDecode.samples must fall back to the latent
|
||||
graph["nodes"][4]["mode"] = 4
|
||||
result = _convert(graph)
|
||||
assert result.workflow["6"]["inputs"]["samples"] == ["4", 0]
|
||||
# mute the negative prompt: the link input is dropped with a warning
|
||||
graph = _base_graph()
|
||||
graph["nodes"][2]["mode"] = 2
|
||||
result = _convert(graph)
|
||||
assert "negative" not in result.workflow["5"]["inputs"]
|
||||
assert any(i.code == "muted_input_dropped" for i in result.issues)
|
||||
|
||||
|
||||
def test_widget_entry_with_muted_upstream_falls_back_to_value():
|
||||
graph = _base_graph()
|
||||
# feed seed from a muted PrimitiveInt via a converted widget input
|
||||
prim = _node(
|
||||
9,
|
||||
"PrimitiveInt",
|
||||
mode=2,
|
||||
inputs=[_in_slot("value", "INT", widget="value"), _in_slot("control", "COMBO", widget="control")],
|
||||
wv=[7],
|
||||
)
|
||||
graph["nodes"].append(prim)
|
||||
graph["nodes"][4]["inputs"][4]["link"] = 11 # seed ← primitive
|
||||
graph["links"].append([11, 9, 0, 5, 4, "INT"])
|
||||
result = _convert(graph)
|
||||
assert result.workflow["5"]["inputs"]["seed"] == 42
|
||||
|
||||
|
||||
def test_subgraph_expansion_and_boundary_wiring():
|
||||
graph = _base_graph()
|
||||
# pack the EmptyLatentImage into a subgraph exposing width/height widgets
|
||||
del graph["nodes"][3]
|
||||
graph["nodes"] = [n for n in graph["nodes"] if n["id"] != 4]
|
||||
sub_id = "aaaa-bbbb-cccc-dddd-eeee"
|
||||
packed = _node(
|
||||
20,
|
||||
sub_id,
|
||||
inputs=[
|
||||
_in_slot("width", "INT", widget="width"),
|
||||
_in_slot("height", "INT", widget="height"),
|
||||
],
|
||||
outputs=[_out("LATENT", "LATENT")],
|
||||
wv=[640, 384],
|
||||
)
|
||||
graph["nodes"].append(packed)
|
||||
# KSampler latent_image ← packed output
|
||||
graph["links"] = [l for l in graph["links"] if l[0] != 6]
|
||||
graph["links"].append([6, 20, 0, 5, 3, "LATENT"])
|
||||
graph["definitions"] = {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": sub_id,
|
||||
"name": "Latent",
|
||||
"inputs": [
|
||||
{"name": "width", "type": "INT"},
|
||||
{"name": "height", "type": "INT"},
|
||||
],
|
||||
"outputs": [{"name": "LATENT", "type": "LATENT"}],
|
||||
"inputNode": {"id": -10},
|
||||
"outputNode": {"id": -20},
|
||||
"nodes": [
|
||||
_node(
|
||||
5,
|
||||
"EmptyLatentImage",
|
||||
inputs=[
|
||||
_in_slot("width", "INT", widget="width", link=101),
|
||||
_in_slot("height", "INT", widget="height", link=102),
|
||||
_in_slot("batch_size", "INT", widget="batch_size"),
|
||||
],
|
||||
outputs=[_out("LATENT", "LATENT")],
|
||||
wv=[512, 512, 1],
|
||||
),
|
||||
],
|
||||
"links": [
|
||||
{"id": 101, "origin_id": -10, "origin_slot": 0, "target_id": 5, "target_slot": 0, "type": "INT"},
|
||||
{"id": 102, "origin_id": -10, "origin_slot": 1, "target_id": 5, "target_slot": 1, "type": "INT"},
|
||||
{"id": 103, "origin_id": 5, "origin_slot": 0, "target_id": -20, "target_slot": 0, "type": "LATENT"},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
result = _convert(graph)
|
||||
wf = result.workflow
|
||||
assert "20:5" in wf # qualified packed:inner id, ComfyUI's own scheme
|
||||
inner = wf["20:5"]
|
||||
assert inner["class_type"] == "EmptyLatentImage"
|
||||
# exposed widget values from the packed instance, not the inner defaults
|
||||
assert inner["inputs"]["width"] == 640
|
||||
assert inner["inputs"]["height"] == 384
|
||||
assert inner["inputs"]["batch_size"] == 1
|
||||
assert wf["5"]["inputs"]["latent_image"] == ["20:5", 0]
|
||||
# now wire the packed width slot from outside: boundary becomes a link
|
||||
res_node = _node(
|
||||
21,
|
||||
"ResolutionSelector",
|
||||
inputs=[_in_slot("aspect_ratio", "COMBO", widget="aspect_ratio")],
|
||||
outputs=[_out("width", "INT"), _out("height", "INT")],
|
||||
wv=["1:1"],
|
||||
)
|
||||
graph["nodes"].append(res_node)
|
||||
graph["nodes"][-2]["inputs"][0]["link"] = 12 # packed width ← selector
|
||||
graph["links"].append([12, 21, 0, 20, 0, "INT"])
|
||||
wf2 = _convert(graph).workflow
|
||||
assert wf2["20:5"]["inputs"]["width"] == ["21", 0]
|
||||
assert wf2["20:5"]["inputs"]["height"] == 384
|
||||
|
||||
|
||||
def test_set_get_variables_resolve_to_captured_source():
|
||||
graph = _base_graph()
|
||||
setter = _node(8, "SetNode", inputs=[_in_slot("*", "*", link=8)], wv=["the_vae"])
|
||||
getter = _node(9, "GetNode", wv=["the_vae"], outputs=[_out("VAE", "VAE")])
|
||||
graph["nodes"] += [setter, getter]
|
||||
# VAEDecode.vae now fed via the variable
|
||||
graph["nodes"][5]["inputs"][1]["link"] = 13
|
||||
graph["links"] = [l for l in graph["links"] if l[0] != 8]
|
||||
graph["links"] += [
|
||||
[8, 1, 2, 8, 0, "VAE"],
|
||||
[13, 9, 0, 6, 1, "VAE"],
|
||||
]
|
||||
result = _convert(graph)
|
||||
assert "8" not in result.workflow and "9" not in result.workflow
|
||||
assert result.workflow["6"]["inputs"]["vae"] == ["1", 2]
|
||||
|
||||
|
||||
def test_unknown_widget_order_lands_in_needs_confirmation():
|
||||
graph = _base_graph()
|
||||
# an old-frontend node: positional widgets, no widget markers, no registry
|
||||
graph["nodes"].append(
|
||||
_node(10, "MysteryCustomNode", inputs=[], outputs=[_out("IMAGE", "IMAGE")], wv=[1, 2, 3])
|
||||
)
|
||||
save = _node(
|
||||
11,
|
||||
"PreviewImage",
|
||||
inputs=[_in_slot("images", "IMAGE", link=30)],
|
||||
wv=["preview"],
|
||||
)
|
||||
graph["nodes"].append(save)
|
||||
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
|
||||
result = _convert(graph)
|
||||
assert result.needs_confirmation is True
|
||||
assert any(i.code == "widget_unmapped" for i in result.issues)
|
||||
# the node is still converted structurally — the report carries the doubt
|
||||
assert "10" in result.workflow
|
||||
|
||||
|
||||
def test_not_ui_format_fails_fast():
|
||||
result = convert_ui_workflow({"3": {"class_type": "SaveImage", "inputs": {}}})
|
||||
assert result.status == "fail"
|
||||
assert result.workflow is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real corpus: the 146 UI files + ground-truth API cross-checks (对拍).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
pytestmark_convert = pytest.mark.skipif(
|
||||
not EXAMPLES.is_dir(), reason="example_ComfyUI_workflows not checked out"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
|
||||
def test_corpus_146_files_all_convert():
|
||||
ui_files = [
|
||||
p
|
||||
for p in sorted(EXAMPLES.rglob("*.json"))
|
||||
if isinstance(json.loads(p.read_text(encoding="utf-8")), dict)
|
||||
and isinstance(json.loads(p.read_text(encoding="utf-8")).get("nodes"), list)
|
||||
]
|
||||
assert len(ui_files) == 146
|
||||
statuses = {"ok": 0, "warn": 0, "fail": 0}
|
||||
needs_confirmation = 0
|
||||
for path in ui_files:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
result = convert_ui_workflow(data)
|
||||
statuses[result.status] += 1
|
||||
needs_confirmation += result.needs_confirmation
|
||||
assert result.workflow, f"{path.name} produced no workflow"
|
||||
# structural sanity: no dangling links in any conversion
|
||||
nodes = result.workflow
|
||||
for nid, node in nodes.items():
|
||||
for name, val in node.get("inputs", {}).items():
|
||||
if isinstance(val, list):
|
||||
assert str(val[0]) in nodes, f"{path.name}: {nid}.{name} dangles"
|
||||
assert statuses["fail"] == 0
|
||||
assert statuses["ok"] + statuses["warn"] == 146
|
||||
# files whose widget order cannot be trusted are flagged, never hidden
|
||||
assert 0 < needs_confirmation < 10
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
|
||||
def test_cross_check_against_ground_truth_exports():
|
||||
"""The H3 t2v UI export converts to the same node classes the author's
|
||||
own API export carries; shared classes agree on scalar input names."""
|
||||
ui = json.loads(
|
||||
(EXAMPLES / "4-视频生成/Minimax-h3/minimax_h3_t2v_文生视频.json").read_text(encoding="utf-8")
|
||||
)
|
||||
api = json.loads(
|
||||
(EXAMPLES / "minimax_h3-Turbo_Text2Video_20260813_API.json").read_text(encoding="utf-8")
|
||||
)
|
||||
result = convert_ui_workflow(ui)
|
||||
assert result.status in ("ok", "warn")
|
||||
converted = result.workflow
|
||||
api_classes = {n["class_type"] for n in api.values()}
|
||||
# Every scalar name the conversion emits for a shared class must be an
|
||||
# input name the author's own export knows — wiring may differ between
|
||||
# versions (a muted branch falls back to its widget value), but the NAME
|
||||
# mapping must never invent or swap inputs.
|
||||
for cls in api_classes & {n["class_type"] for n in converted.values()}:
|
||||
api_names = {
|
||||
key
|
||||
for n in api.values()
|
||||
if n["class_type"] == cls
|
||||
for key in n.get("inputs", {})
|
||||
}
|
||||
conv_names = {
|
||||
key
|
||||
for n in converted.values()
|
||||
if n["class_type"] == cls
|
||||
for key, v in n.get("inputs", {}).items()
|
||||
if not isinstance(v, list)
|
||||
}
|
||||
assert conv_names <= api_names, f"class {cls} invents inputs: {conv_names - api_names}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
|
||||
def test_packed_krea2_conversion_shape_matches_export_scheme():
|
||||
"""The krea2 turbo file (packed subgraph) converts ok, expands inner nodes
|
||||
under `<packed>:<inner>` ids and maps the sampler widgets exactly — the
|
||||
same scheme the author's own Krea2 API export uses (ids like "171:153")."""
|
||||
ui = json.loads(
|
||||
(EXAMPLES / "3-图像生成/krea2/krea2_turbo_文生图.json").read_text(encoding="utf-8")
|
||||
)
|
||||
api = json.loads(
|
||||
(EXAMPLES / "Krea2_t2i_20260818_API.json").read_text(encoding="utf-8")
|
||||
)
|
||||
result = convert_ui_workflow(ui)
|
||||
assert result.status == "ok"
|
||||
assert any(":" in nid for nid in result.workflow) # expanded subgraph nodes
|
||||
assert any("171:" in nid for nid in api) # ground truth uses the same scheme
|
||||
ks = [
|
||||
n
|
||||
for n in result.workflow.values()
|
||||
if n["class_type"] == "KSampler" and not isinstance(n["inputs"].get("seed"), list)
|
||||
]
|
||||
assert ks, "inner KSampler with widget-mapped seed expected"
|
||||
assert {"seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"} <= set(
|
||||
ks[0]["inputs"]
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
"""HEL-681 (S2): batch import endpoints, meta list view, migration safety.
|
||||
|
||||
Everything runs on a temp DB via the shared client fixture — the real
|
||||
146-file library is never touched, matching the card's「不导入生产数据」boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.db import migrate_db
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
|
||||
def _ui_graph():
|
||||
"""Minimal clean t2i UI graph (same shape as test_ui_convert's)."""
|
||||
return {
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "ckpt_name", "type": "COMBO", "widget": {"name": "ckpt_name"}, "link": None}],
|
||||
"outputs": [
|
||||
{"name": "MODEL", "type": "MODEL", "links": [3], "slot_index": 0},
|
||||
{"name": "CLIP", "type": "CLIP", "links": [1, 2], "slot_index": 1},
|
||||
{"name": "VAE", "type": "VAE", "links": [8], "slot_index": 2},
|
||||
],
|
||||
"widgets_values": ["qwen_image_2.safetensors"],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 1},
|
||||
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None},
|
||||
],
|
||||
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [4], "slot_index": 0}],
|
||||
"widgets_values": ["a cat"],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "clip", "type": "CLIP", "link": 2},
|
||||
{"name": "text", "type": "STRING", "widget": {"name": "text"}, "link": None},
|
||||
],
|
||||
"outputs": [{"name": "CONDITIONING", "type": "CONDITIONING", "links": [5], "slot_index": 0}],
|
||||
"widgets_values": ["bad"],
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "EmptyLatentImage",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "width", "type": "INT", "widget": {"name": "width"}, "link": None},
|
||||
{"name": "height", "type": "INT", "widget": {"name": "height"}, "link": None},
|
||||
{"name": "batch_size", "type": "INT", "widget": {"name": "batch_size"}, "link": None},
|
||||
],
|
||||
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [6], "slot_index": 0}],
|
||||
"widgets_values": [512, 512, 1],
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "KSampler",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "model", "type": "MODEL", "link": 3},
|
||||
{"name": "positive", "type": "CONDITIONING", "link": 4},
|
||||
{"name": "negative", "type": "CONDITIONING", "link": 5},
|
||||
{"name": "latent_image", "type": "LATENT", "link": 6},
|
||||
{"name": "seed", "type": "INT", "widget": {"name": "seed"}, "link": None},
|
||||
{"name": "steps", "type": "INT", "widget": {"name": "steps"}, "link": None},
|
||||
{"name": "cfg", "type": "FLOAT", "widget": {"name": "cfg"}, "link": None},
|
||||
{"name": "sampler_name", "type": "COMBO", "widget": {"name": "sampler_name"}, "link": None},
|
||||
{"name": "scheduler", "type": "COMBO", "widget": {"name": "scheduler"}, "link": None},
|
||||
{"name": "denoise", "type": "FLOAT", "widget": {"name": "denoise"}, "link": None},
|
||||
],
|
||||
"outputs": [{"name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0}],
|
||||
"widgets_values": [42, "fixed", 20, 7, "euler", "normal", 1.0],
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "VAEDecode",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "samples", "type": "LATENT", "link": 7},
|
||||
{"name": "vae", "type": "VAE", "link": 8},
|
||||
],
|
||||
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0}],
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SaveImage",
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "images", "type": "IMAGE", "link": 9},
|
||||
{"name": "filename_prefix", "type": "STRING", "widget": {"name": "filename_prefix"}, "link": None},
|
||||
],
|
||||
"widgets_values": ["test"],
|
||||
},
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 1, 2, 0, "CLIP"],
|
||||
[2, 1, 1, 3, 0, "CLIP"],
|
||||
[3, 1, 0, 5, 0, "MODEL"],
|
||||
[4, 2, 0, 5, 1, "CONDITIONING"],
|
||||
[5, 3, 0, 5, 2, "CONDITIONING"],
|
||||
[6, 4, 0, 5, 3, "LATENT"],
|
||||
[7, 5, 0, 6, 0, "LATENT"],
|
||||
[8, 1, 2, 6, 1, "VAE"],
|
||||
[9, 6, 0, 7, 0, "IMAGE"],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _api_workflow():
|
||||
return json.loads(
|
||||
(EXAMPLES / "video_minimax_h3_r2v_2ref_API.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def test_convert_dry_run_reports_and_stores_nothing(client):
|
||||
payload = {
|
||||
"files": [
|
||||
{
|
||||
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
|
||||
"workflow_json": _ui_graph(),
|
||||
}
|
||||
],
|
||||
"include_workflows": True,
|
||||
}
|
||||
resp = client.post("/api/workflows/convert", json=payload)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 1 and body["ok"] == 1 and body["importable"] == 1
|
||||
entry = body["files"][0]
|
||||
assert entry["name"] == "test_t2i"
|
||||
assert entry["workflow_json"]["5"]["inputs"]["seed"] == 42
|
||||
assert entry["suggested"]["usage"] == "text2image"
|
||||
assert entry["suggested"]["category"] == "image"
|
||||
assert entry["suggested"]["model_family"] == "Qwen"
|
||||
assert entry["suggested"]["media_out"] == ["image"]
|
||||
# dry-run must not write anything
|
||||
assert client.get("/api/workflows").json() == []
|
||||
|
||||
|
||||
def test_convert_needs_confirmation_is_flagged_not_runnable(client):
|
||||
graph = _ui_graph()
|
||||
graph["nodes"].append(
|
||||
{
|
||||
"id": 10,
|
||||
"type": "MysteryCustomNode",
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [30], "slot_index": 0}],
|
||||
"widgets_values": [1, 2, 3],
|
||||
}
|
||||
)
|
||||
graph["nodes"].append(
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PreviewImage",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "images", "type": "IMAGE", "link": 30}],
|
||||
"widgets_values": ["p"],
|
||||
}
|
||||
)
|
||||
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
|
||||
resp = client.post(
|
||||
"/api/workflows/convert",
|
||||
json={"files": [{"source_path": "x/mystery.json", "workflow_json": graph}]},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["needs_confirmation"] == 1
|
||||
assert body["importable"] == 0
|
||||
assert "需人工确认" in body["files"][0]["skip_reason"]
|
||||
|
||||
|
||||
def test_import_batch_then_rerun_is_idempotent(client):
|
||||
files = [
|
||||
{
|
||||
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
|
||||
"workflow_json": _ui_graph(),
|
||||
}
|
||||
]
|
||||
first = client.post("/api/workflows/import-batch", json={"files": files})
|
||||
assert first.status_code == 200
|
||||
body = first.json()
|
||||
assert body["imported"] == 1 and body["skipped"] == 0
|
||||
row_id = body["imported_ids"][0]
|
||||
|
||||
# second run with identical file: conflict skip, no duplicates
|
||||
second = client.post("/api/workflows/import-batch", json={"files": files})
|
||||
body2 = second.json()
|
||||
assert body2["imported"] == 0 and body2["skipped"] == 1
|
||||
assert body2["skipped_files"][0]["reason"] == "conflict"
|
||||
listing = client.get("/api/workflows").json()
|
||||
assert len(listing) == 1
|
||||
|
||||
detail = client.get(f"/api/workflows/{row_id}").json()
|
||||
assert detail["usage"] == "text2image"
|
||||
assert detail["is_enabled"] is True
|
||||
assert detail["category"] == "image"
|
||||
assert detail["model_family"] == "Qwen"
|
||||
assert detail["media_io"] == {"in": [], "out": ["image"]}
|
||||
assert detail["source"] == "ui_convert"
|
||||
assert detail["source_path"] == "3-图像生成/Qwen-Image-2.1/test_t2i.json"
|
||||
|
||||
|
||||
def test_import_batch_skips_needs_confirmation_files(client):
|
||||
graph = _ui_graph()
|
||||
graph["nodes"].append(
|
||||
{
|
||||
"id": 10,
|
||||
"type": "MysteryCustomNode",
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [{"name": "IMAGE", "type": "IMAGE", "links": [30], "slot_index": 0}],
|
||||
"widgets_values": [1, 2, 3],
|
||||
}
|
||||
)
|
||||
graph["nodes"].append(
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PreviewImage",
|
||||
"mode": 0,
|
||||
"inputs": [{"name": "images", "type": "IMAGE", "link": 30}],
|
||||
"widgets_values": ["p"],
|
||||
}
|
||||
)
|
||||
graph["links"].append([30, 10, 0, 11, 0, "IMAGE"])
|
||||
resp = client.post(
|
||||
"/api/workflows/import-batch",
|
||||
json={"files": [{"name": "mystery", "workflow_json": graph}]},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["imported"] == 0
|
||||
assert "需人工确认" in body["skipped_files"][0]["reason"]
|
||||
assert client.get("/api/workflows").json() == []
|
||||
|
||||
|
||||
def test_warn_sample_imports_with_human_confirmation(client):
|
||||
"""A warn file (muted branch → input dropped) still imports when the
|
||||
wizard supplies the settled usage — the manual-correction path."""
|
||||
graph = _ui_graph()
|
||||
graph["nodes"][2]["mode"] = 2 # mute the negative prompt branch
|
||||
dry = client.post(
|
||||
"/api/workflows/convert",
|
||||
json={"files": [{"name": "warned", "workflow_json": graph}]},
|
||||
).json()
|
||||
assert dry["warn"] == 1 and dry["importable"] == 1
|
||||
resp = client.post(
|
||||
"/api/workflows/import-batch",
|
||||
json={"files": [{"name": "warned", "workflow_json": graph, "usage": "text2image"}]},
|
||||
)
|
||||
body = resp.json()
|
||||
assert body["imported"] == 1
|
||||
detail = client.get(f"/api/workflows/{body['imported_ids'][0]}").json()
|
||||
assert detail["is_enabled"] is True
|
||||
assert "negative" not in detail["workflow_json"]["5"]["inputs"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not EXAMPLES.is_dir(), reason="example library missing")
|
||||
def test_import_scan_dry_run_over_repo_library(client):
|
||||
resp = client.post(
|
||||
"/api/workflows/import-scan",
|
||||
json={"root": str(EXAMPLES), "dry_run": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 146
|
||||
assert body["fail"] == 0
|
||||
assert body["ok"] + body["warn"] == 146
|
||||
assert body["needs_confirmation"] > 0 # the 3 uncensored files stay honest
|
||||
assert body["importable"] < body["total"]
|
||||
# a dry scan must leave the DB untouched
|
||||
assert client.get("/api/workflows").json() == []
|
||||
|
||||
|
||||
def test_import_scan_commit_from_tmp_directory(client, tmp_path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "demo.json").write_text(json.dumps(_ui_graph()), encoding="utf-8")
|
||||
(tmp_path / "plain_api.json").write_text(json.dumps(_api_workflow()), encoding="utf-8")
|
||||
(tmp_path / "not_a_workflow.json").write_text(json.dumps({"hello": 1}), encoding="utf-8")
|
||||
resp = client.post(
|
||||
"/api/workflows/import-scan",
|
||||
json={"root": str(tmp_path), "dry_run": False},
|
||||
)
|
||||
body = resp.json()
|
||||
# only the one UI file went through this pipeline; API-format and junk skipped
|
||||
assert body["imported"] == 1
|
||||
listing = client.get("/api/workflows").json()
|
||||
assert len(listing) == 1
|
||||
assert listing[0]["source_path"] == "sub/demo.json"
|
||||
# rerun: idempotent
|
||||
again = client.post(
|
||||
"/api/workflows/import-scan",
|
||||
json={"root": str(tmp_path), "dry_run": False},
|
||||
).json()
|
||||
assert again["imported"] == 0
|
||||
assert len(client.get("/api/workflows").json()) == 1
|
||||
|
||||
|
||||
def test_meta_view_has_metadata_but_no_full_json(client):
|
||||
client.post(
|
||||
"/api/workflows/import-batch",
|
||||
json={
|
||||
"files": [
|
||||
{
|
||||
"source_path": "3-图像生成/Qwen-Image-2.1/test_t2i.json",
|
||||
"workflow_json": _ui_graph(),
|
||||
"tags": ["极速"],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
meta = client.get("/api/workflows", params={"view": "meta"}).json()
|
||||
assert len(meta) == 1
|
||||
row = meta[0]
|
||||
assert "workflow_json" not in row
|
||||
assert "input_schema" not in row
|
||||
assert "output_schema" not in row
|
||||
assert row["category"] == "image"
|
||||
assert row["model_family"] == "Qwen"
|
||||
assert row["media_io"] == {"in": [], "out": ["image"]}
|
||||
assert row["tags"] == ["极速"]
|
||||
assert row["favorite"] is False
|
||||
assert row["needs_mapping"] is False
|
||||
|
||||
|
||||
def test_legacy_list_shape_and_old_records_unchanged(client):
|
||||
"""Old imports (API format via POST /api/workflows) keep working; the
|
||||
legacy list still carries workflow_json and the new columns default."""
|
||||
created = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "旧导入", "workflow_json": _api_workflow()},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
legacy = client.get("/api/workflows").json()
|
||||
assert len(legacy) == 1
|
||||
row = legacy[0]
|
||||
assert isinstance(row["workflow_json"], dict)
|
||||
assert "usage" in row and "output_map" in row
|
||||
assert row["favorite"] is False
|
||||
assert row["tags"] == []
|
||||
assert row["media_io"] == {"in": [], "out": []}
|
||||
meta = client.get("/api/workflows", params={"view": "meta"}).json()
|
||||
assert meta[0]["needs_mapping"] is False or meta[0]["needs_mapping"] is True
|
||||
assert meta[0]["source"] is None
|
||||
|
||||
|
||||
def test_favorite_tags_patch_and_touch(client):
|
||||
wid = client.post(
|
||||
"/api/workflows/import-batch",
|
||||
json={"files": [{"name": "fav", "workflow_json": _ui_graph()}]},
|
||||
).json()["imported_ids"][0]
|
||||
resp = client.patch(f"/api/workflows/{wid}", json={"favorite": True, "tags": ["高质量"]})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["favorite"] is True
|
||||
assert resp.json()["tags"] == ["高质量"]
|
||||
touch = client.post(f"/api/workflows/{wid}/touch")
|
||||
assert touch.status_code == 200
|
||||
meta = client.get("/api/workflows", params={"view": "meta"}).json()
|
||||
assert meta[0]["last_used_at"] is not None
|
||||
assert meta[0]["favorite"] is True
|
||||
|
||||
|
||||
def test_migration_adds_columns_on_old_database(tmp_path):
|
||||
"""A pre-S2 database (workflows table without the metadata columns)
|
||||
migrates in place; existing rows survive with defaults."""
|
||||
db_path = tmp_path / "old.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE workflows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('image', 'video')),
|
||||
workflow_json TEXT NOT NULL,
|
||||
description TEXT,
|
||||
prompt_profile TEXT NOT NULL DEFAULT 'prose',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
usage TEXT,
|
||||
usage_source TEXT,
|
||||
output_map TEXT,
|
||||
output_map_source TEXT
|
||||
);
|
||||
INSERT INTO workflows (name, kind, workflow_json)
|
||||
VALUES ('legacy', 'video', '{"1": {"class_type": "SaveImage", "inputs": {}}}');
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(migrate_db(db_path))
|
||||
conn = sqlite3.connect(db_path)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(workflows)").fetchall()}
|
||||
assert {
|
||||
"category",
|
||||
"model_family",
|
||||
"media_io",
|
||||
"tags",
|
||||
"favorite",
|
||||
"last_used_at",
|
||||
"source",
|
||||
"source_path",
|
||||
} <= cols
|
||||
row = conn.execute("SELECT name, favorite, tags, category FROM workflows").fetchone()
|
||||
assert row == ("legacy", 0, None, None)
|
||||
conn.close()
|
||||
@@ -0,0 +1,272 @@
|
||||
"""L2 连接核查(HEL-643 批次 1):导入/复查时用目标 ComfyUI 的 /object_info
|
||||
预检节点与模型可用性。全部基于 fake ComfyUI 的脚本化 /object_info,
|
||||
不针对任何固定 JSON 或模型名写死判定。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.client import clear_object_info_cache
|
||||
from calliope.comfyui.preflight import check_workflow_against
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import _url
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _load_05() -> dict:
|
||||
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def build_object_info(workflow: dict) -> dict:
|
||||
"""A catalogue where every widget value the JSON carries is legal.
|
||||
|
||||
String widgets become combos whose candidates include the workflow's own
|
||||
value (merged across nodes of the same class); numbers/booleans are typed
|
||||
inputs; wired inputs are link types — none of those can mismatch.
|
||||
"""
|
||||
info: dict = {}
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
ct = str(node.get("class_type") or "")
|
||||
if not ct:
|
||||
continue
|
||||
spec = info.setdefault(ct, {"input": {"required": {}}, "output": ["*"]})
|
||||
required = spec["input"]["required"]
|
||||
for name, val in (node.get("inputs") or {}).items():
|
||||
if isinstance(val, str):
|
||||
# Merge: two nodes of one class with different values → both legal.
|
||||
if name in required and isinstance(required[name][0], list):
|
||||
if val not in required[name][0]:
|
||||
required[name][0] = [*required[name][0], val]
|
||||
continue
|
||||
required[name] = [[val], {}]
|
||||
elif name in required:
|
||||
continue
|
||||
elif isinstance(val, list):
|
||||
required[name] = ["*", {}]
|
||||
elif isinstance(val, bool):
|
||||
required[name] = ["BOOLEAN", {}]
|
||||
elif isinstance(val, (int, float)):
|
||||
required[name] = ["NUMBER", {}]
|
||||
return info
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_cache():
|
||||
clear_object_info_cache()
|
||||
yield
|
||||
clear_object_info_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def endpoint_at(client, fake_comfy): # noqa: F811
|
||||
"""Register a connection pointing at the fake server; yields its id."""
|
||||
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
||||
prev_active = settings.comfyui_active_id
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "预检桩", "base_url": _url(fake_comfy), "auth_type": "none"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
endpoints = r.json()["endpoints"]
|
||||
ep = next(e for e in endpoints if e["base_url"] == _url(fake_comfy))
|
||||
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["base_url"] == _url(fake_comfy)]
|
||||
settings.comfyui_active_id = ep["id"]
|
||||
settings.apply_active_comfyui()
|
||||
try:
|
||||
yield ep["id"]
|
||||
finally:
|
||||
settings.comfyui_endpoints = prev_eps
|
||||
settings.comfyui_active_id = prev_active
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unit_missing_node_reported_with_node_context():
|
||||
wf = {"900": {"class_type": "H3SLAAttention", "inputs": {"enabled": True},
|
||||
"_meta": {"title": "H3 SLA Attention"}}}
|
||||
findings = check_workflow_against({"OtherNode": {}}, wf)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f["code"] == "missing_node" and f["level"] == "error"
|
||||
msg = f["message"]
|
||||
assert "H3SLAAttention" in msg and "900" in msg and "H3 SLA Attention" in msg
|
||||
assert "重新检查" in f["hint"]
|
||||
|
||||
|
||||
def test_unit_combo_value_not_in_list():
|
||||
wf = {"127": {"class_type": "UNETLoader",
|
||||
"inputs": {"unet_name": "missing_model.safetensors", "weight_dtype": "default"}}}
|
||||
info = {"UNETLoader": {"input": {"required": {
|
||||
"unet_name": [["installed_model.safetensors"], {}],
|
||||
"weight_dtype": [["default", "fp8"], {"upload": False}],
|
||||
}}, "output": ["MODEL"]}}
|
||||
findings = check_workflow_against(info, wf)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f["code"] == "value_not_in_list" and f["node_id"] == "127"
|
||||
assert "missing_model.safetensors" in f["message"] and "unet_name" in f["message"]
|
||||
assert "模型文件" in f["message"]
|
||||
assert "installed_model.safetensors" in f["hint"]
|
||||
|
||||
|
||||
def test_unit_upload_backed_and_wired_inputs_skipped():
|
||||
wf = {
|
||||
"1": {"class_type": "LoadImage", "inputs": {"image": "placeholder.png"}},
|
||||
"2": {"class_type": "LoadVideo", "inputs": {"file": "clip.mp4"}},
|
||||
"3": {"class_type": "LoadAudio", "inputs": {"audio": "voice.wav"}},
|
||||
"4": {"class_type": "KSampler", "inputs": {"seed": 7, "model": ["9", 0]}},
|
||||
}
|
||||
info = {
|
||||
"LoadImage": {"input": {"required": {"image": [["other.png"], {"image_upload": True}]}},
|
||||
"output": ["IMAGE"]},
|
||||
"LoadVideo": {"input": {"required": {"file": [[], {}]}}, "output": ["IMAGE"]},
|
||||
"LoadAudio": {"input": {"required": {"audio": [[], {}]}}, "output": ["AUDIO"]},
|
||||
"KSampler": {"input": {"required": {"seed": ["INT", {}], "model": ["MODEL", {}]}},
|
||||
"output": ["IMAGE"]},
|
||||
}
|
||||
assert check_workflow_against(info, wf) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analyze with a target connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_analyze_all_green_against_fake_object_info(client, endpoint_at, fake_comfy):
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
check = r.json()["connection_check"]
|
||||
assert check["reachable"] is True and check["ok"] is True
|
||||
assert check["findings"] == []
|
||||
|
||||
|
||||
def test_analyze_reports_missing_custom_node(client, endpoint_at, fake_comfy):
|
||||
info = build_object_info(_load_05())
|
||||
info.pop("H3SLAAttention")
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["ok"] is False
|
||||
missing = [f for f in check["findings"] if f["code"] == "missing_node"]
|
||||
assert missing and "H3SLAAttention" in missing[0]["message"]
|
||||
assert "900" in missing[0]["message"]
|
||||
|
||||
|
||||
def test_analyze_reports_missing_model_value(client, endpoint_at, fake_comfy):
|
||||
info = build_object_info(_load_05())
|
||||
# The server has a DIFFERENT unet installed than the one the JSON names.
|
||||
info["UNETLoader"]["input"]["required"]["unet_name"] = [["another_unet.safetensors"], {}]
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["ok"] is False
|
||||
bad = [f for f in check["findings"] if f["code"] == "value_not_in_list"]
|
||||
assert bad and bad[0]["node_id"] == "127"
|
||||
assert "minimax_h3_fused_refdelta" in bad[0]["message"]
|
||||
|
||||
|
||||
def test_unreachable_connection_degrades_to_unchecked(client):
|
||||
# A port that nothing listens on: the endpoint resolves, the server doesn't.
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
||||
prev_active = settings.comfyui_active_id
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "断连桩", "base_url": f"http://127.0.0.1:{port}", "auth_type": "none"},
|
||||
)
|
||||
endpoints = r.json()["endpoints"]
|
||||
ep = next(e for e in endpoints if e["base_url"] == f"http://127.0.0.1:{port}")
|
||||
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["id"] == ep["id"]]
|
||||
settings.comfyui_active_id = ep["id"]
|
||||
settings.apply_active_comfyui()
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": ep["id"]},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["reachable"] is False and check["ok"] is False
|
||||
assert "无法核查" in check["message"]
|
||||
assert check["findings"] == []
|
||||
finally:
|
||||
settings.comfyui_endpoints = prev_eps
|
||||
settings.comfyui_active_id = prev_active
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Saved-workflow re-check + cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_workflow(client, workflow: dict, endpoint_id: str | None = None) -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "预检工作流",
|
||||
"workflow_json": workflow,
|
||||
"usage": "ref2video",
|
||||
**({"endpoint_id": endpoint_id} if endpoint_id else {}),
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_saved_workflow_recheck_and_refresh(client, endpoint_at, fake_comfy):
|
||||
wf_id = _create_workflow(client, _load_05(), endpoint_at)
|
||||
info = build_object_info(_load_05())
|
||||
info.pop("UNETLoader")
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(f"/api/workflows/{wf_id}/check", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is False and body["endpoint_id"] == endpoint_at
|
||||
assert any(f["code"] == "missing_node" for f in body["findings"])
|
||||
|
||||
# User installs the node package, comes back and re-checks with refresh.
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
r = client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
|
||||
body = r.json()
|
||||
assert body["ok"] is True and body["findings"] == []
|
||||
|
||||
|
||||
def test_object_info_cached_within_ttl(client, endpoint_at, fake_comfy):
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
for _ in range(2):
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
assert r.json()["connection_check"]["ok"] is True
|
||||
hits = [p for p in fake_comfy.requests if p == "/object_info"]
|
||||
assert len(hits) == 1 # second call served from the 5-minute cache
|
||||
|
||||
wf_id = _create_workflow(client, _load_05())
|
||||
client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
|
||||
hits = [p for p in fake_comfy.requests if p == "/object_info"]
|
||||
assert len(hits) == 2 # manual refresh bypasses the TTL
|
||||
@@ -0,0 +1,197 @@
|
||||
"""「测试工作流」(HEL-643 批次 2)端到端:真实队列 → fake ComfyUI → 产物隔离。
|
||||
|
||||
测试任务与普通任务区分(jobs.origin)、dry-run 拒绝、幂等复用、
|
||||
保留策略与级联清理,全部对着 fake ComfyUI 跑真实链路。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.queue.workflow_test import workflow_test_dir
|
||||
from tests.test_studio_generation import (
|
||||
IMAGE_WORKFLOW,
|
||||
PNG_1PX,
|
||||
_real_run,
|
||||
)
|
||||
|
||||
|
||||
def _wait_terminal(client, job_id: int, timeout: float = 25.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
r = client.get(f"/api/jobs/{job_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
if job["status"] not in ("pending", "running"):
|
||||
return job
|
||||
time.sleep(0.4)
|
||||
raise AssertionError(f"test job {job_id} did not finish in {timeout}s")
|
||||
|
||||
|
||||
def _create_workflow(client, workflow: dict, name: str = "可测工作流") -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": name, "workflow_json": workflow, "usage": "text2image"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_passing_test_downloads_real_output_to_scratch_dir(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="通过用例")
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "测试提示词"}}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["reused"] is False
|
||||
job = _wait_terminal(client, body["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["origin"] == "workflow_test"
|
||||
assert job["payload"]["source"] == "workflow_test"
|
||||
assert job["phase"] == "finished"
|
||||
# 真实拿到可下载输出才算通过:产物落在独立 scratch 目录
|
||||
assert len(job["output_paths"]) >= 1
|
||||
out = Path(job["output_paths"][0])
|
||||
assert out.is_file() and out.read_bytes() == PNG_1PX
|
||||
assert workflow_test_dir(wf_id) in out.parents
|
||||
# 提交给 fake ComfyUI 的是补丁后的真实提示词
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
assert queued["1"]["inputs"]["text"] == "测试提示词"
|
||||
|
||||
|
||||
def test_tasks_list_shows_origin_but_playground_history_hides_it(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="可见性用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job_id = r.json()["job"]["id"]
|
||||
_wait_terminal(client, job_id)
|
||||
|
||||
listed = client.get("/api/jobs?limit=50").json()
|
||||
mine = [j for j in listed if j["id"] == job_id]
|
||||
assert mine and mine[0]["origin"] == "workflow_test"
|
||||
|
||||
pg = client.get("/api/playground/jobs").json()
|
||||
assert all(j["id"] != job_id for j in pg), "test jobs must stay out of studio history"
|
||||
|
||||
|
||||
def test_failed_prompt_reports_chinese_reason(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="排队被拒用例")
|
||||
fake_comfy.fail_prompt = True
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job = _wait_terminal(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "拒绝执行" in (job["error"] or "")
|
||||
|
||||
|
||||
def test_no_output_does_not_pass(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="无输出用例")
|
||||
fake_comfy.drop_outputs = True
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job = _wait_terminal(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "没有返回任何图片或视频" in (job["error"] or "")
|
||||
|
||||
|
||||
def test_dry_run_setting_refuses_real_test(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
settings.dry_run = True # conftest default; _real_run clears it — set again
|
||||
try:
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="dry 拒绝用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
assert r.status_code == 400
|
||||
assert "Dry-run" in r.json()["detail"]
|
||||
finally:
|
||||
settings.dry_run = False
|
||||
|
||||
|
||||
def test_repeat_click_reuses_inflight_job(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="幂等用例")
|
||||
assert client.post("/api/jobs/pause").status_code == 200
|
||||
try:
|
||||
r1 = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "一次就好"}}
|
||||
)
|
||||
assert r1.status_code == 200 and r1.json()["reused"] is False
|
||||
r2 = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "重复点击"}}
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["reused"] is True
|
||||
assert r2.json()["job"]["id"] == r1.json()["job"]["id"]
|
||||
finally:
|
||||
client.post("/api/jobs/resume")
|
||||
client.post(f"/api/jobs/{r1.json()['job']['id']}/cancel")
|
||||
_wait_terminal(client, r1.json()["job"]["id"])
|
||||
|
||||
|
||||
def test_retention_keeps_newest_records_and_prunes_artifacts(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="保留策略用例")
|
||||
for i in range(7):
|
||||
fake_comfy.out_name = f"t{i}.png"
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test",
|
||||
json={"input_values": {"1": f"第{i}次"}},
|
||||
)
|
||||
_wait_terminal(client, r.json()["job"]["id"])
|
||||
rows = client.get(f"/api/workflows/{wf_id}/tests").json()
|
||||
assert len(rows) <= 5
|
||||
kept_ids = {row["id"] for row in rows}
|
||||
# 被清掉的旧记录其产物文件也一并删除
|
||||
leftovers = [p for p in workflow_test_dir(wf_id).iterdir() if p.suffix == ".png"]
|
||||
assert all(p.stem.lstrip("t").isdigit() and int(p.stem[1:]) >= 2 for p in leftovers)
|
||||
assert len(kept_ids) == 5
|
||||
|
||||
|
||||
def test_clear_tests_and_workflow_delete_cascade(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="清理用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
_wait_terminal(client, r.json()["job"]["id"])
|
||||
assert workflow_test_dir(wf_id).is_dir()
|
||||
|
||||
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
|
||||
assert r.status_code == 200 and r.json()["removed"] >= 1
|
||||
assert not workflow_test_dir(wf_id).exists()
|
||||
assert client.get(f"/api/workflows/{wf_id}/tests").json() == []
|
||||
|
||||
# 进行中的测试:先拒绝清除,取消后删除工作流级联清理
|
||||
assert client.post("/api/jobs/pause").status_code == 200
|
||||
try:
|
||||
job_id = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}}
|
||||
).json()["job"]["id"]
|
||||
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
|
||||
assert r.status_code == 409
|
||||
finally:
|
||||
client.post("/api/jobs/resume")
|
||||
client.post(f"/api/jobs/{job_id}/cancel")
|
||||
_wait_terminal(client, job_id)
|
||||
|
||||
# 工作流列表带出「上次测试」实时状态
|
||||
listed = client.get("/api/workflows").json()
|
||||
mine = [w for w in listed if w["id"] == wf_id][0]
|
||||
assert mine["last_test"]["id"] == job_id
|
||||
|
||||
assert client.delete(f"/api/workflows/{wf_id}").status_code == 200
|
||||
assert not workflow_test_dir(wf_id).exists()
|
||||
assert client.get(f"/api/workflows/{wf_id}/tests").status_code == 404
|
||||
|
||||
|
||||
def test_last_test_summary_live_on_list(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_a = _create_workflow(client, IMAGE_WORKFLOW, name="A")
|
||||
wf_b = _create_workflow(client, IMAGE_WORKFLOW, name="B")
|
||||
r = client.post(f"/api/workflows/{wf_a}/test", json={"input_values": {"1": "p"}})
|
||||
done = _wait_terminal(client, r.json()["job"]["id"])
|
||||
listed = {w["id"]: w for w in client.get("/api/workflows").json()}
|
||||
assert listed[wf_a]["last_test"]["status"] == "done"
|
||||
assert listed[wf_a]["last_test"]["output_count"] == len(done["output_paths"])
|
||||
assert listed[wf_b]["last_test"] is None
|
||||
@@ -7,7 +7,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs",
|
||||
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs && node scripts/check-export-frame.mjs",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pure-logic checks for 导出尾帧 (lib/media/exportFrame.ts): which media
|
||||
* times get tried near the end, how the PNG is named, and when a sampled
|
||||
* frame counts as black / empty. The decode + download path itself runs in
|
||||
* a real browser (e2e/lastframe.mjs).
|
||||
*
|
||||
* Run with `npm test` (or `node scripts/check-export-frame.mjs`).
|
||||
*/
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const MODULE = join(HERE, "..", "src", "lib", "media", "exportFrame.ts");
|
||||
|
||||
const {
|
||||
tailSeekTimes,
|
||||
videoBaseName,
|
||||
frameFileName,
|
||||
blankKind,
|
||||
statusErrorCode,
|
||||
TAIL_OFFSETS,
|
||||
} = await import(pathToFileURL(MODULE).href);
|
||||
|
||||
let failures = 0;
|
||||
function check(name, cond, extra = "") {
|
||||
if (cond) console.log(`ok ${name}`);
|
||||
else {
|
||||
failures += 1;
|
||||
console.error(`FAIL ${name}${extra ? ` — ${extra}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- seek targets -------------------------------------------------------
|
||||
|
||||
const t6 = tailSeekTimes(6.04);
|
||||
check(
|
||||
"first try is ~1 frame before the end, never the end itself",
|
||||
t6[0] < 6.04 && 6.04 - t6[0] <= 0.05 + 1e-9,
|
||||
JSON.stringify(t6),
|
||||
);
|
||||
check(
|
||||
"later tries step strictly further back",
|
||||
t6.every((t, i) => i === 0 || t < t6[i - 1]),
|
||||
JSON.stringify(t6),
|
||||
);
|
||||
check(
|
||||
"tries stay within ~1s of the end",
|
||||
t6.length === TAIL_OFFSETS.length && 6.04 - t6[t6.length - 1] <= 1 + 1e-9,
|
||||
);
|
||||
check(
|
||||
"short clip clamps to 0 without duplicates",
|
||||
JSON.stringify(tailSeekTimes(0.1)) === "[0.05,0]",
|
||||
);
|
||||
check(
|
||||
"unknown / zero duration falls back to t=0",
|
||||
JSON.stringify(tailSeekTimes(NaN)) === "[0]" &&
|
||||
JSON.stringify(tailSeekTimes(0)) === "[0]",
|
||||
);
|
||||
|
||||
// ---- file names ---------------------------------------------------------
|
||||
|
||||
const api =
|
||||
"/api/file?path=" +
|
||||
encodeURIComponent(
|
||||
"/opt/aigc-home/data/assets/1/video/05_ref2va_4step_sla_00035_.mp4",
|
||||
);
|
||||
check(
|
||||
"base name from /api/file?path=…",
|
||||
videoBaseName(api) === "05_ref2va_4step_sla_00035_",
|
||||
);
|
||||
check(
|
||||
"base name from a Windows path",
|
||||
videoBaseName(
|
||||
"/api/file?path=" +
|
||||
encodeURIComponent("D:\\calliope\\assets\\镜头 01.webm"),
|
||||
) === "镜头 01",
|
||||
);
|
||||
check(
|
||||
"base name from a plain URL",
|
||||
videoBaseName("http://x.test/media/clip-9.mov?x=1") === "clip-9",
|
||||
);
|
||||
check(
|
||||
"unsafe characters are replaced",
|
||||
!/[:*?"<>|]/.test(
|
||||
videoBaseName("/api/file?path=" + encodeURIComponent('a:b*c?"d<e>f|g.mp4')),
|
||||
),
|
||||
);
|
||||
check(
|
||||
"blob URL without a name falls back to 'video'",
|
||||
videoBaseName("blob:http://x.test/") === "video",
|
||||
);
|
||||
const fname = frameFileName(api, 5.958, "尾帧");
|
||||
check(
|
||||
"PNG name names the video, the tag and the frame time",
|
||||
fname === "05_ref2va_4step_sla_00035__尾帧_5.958s.png",
|
||||
fname,
|
||||
);
|
||||
check(
|
||||
"spaces in the tag become dashes",
|
||||
frameFileName(api, 1, "last frame").includes("_last-frame_1.000s.png"),
|
||||
);
|
||||
|
||||
// ---- black / empty detection -------------------------------------------
|
||||
|
||||
function frame(n, rgba) {
|
||||
const out = new Uint8ClampedArray(n * 4);
|
||||
for (let i = 0; i < n; i++) out.set(rgba(i), i * 4);
|
||||
return out;
|
||||
}
|
||||
const N = 64 * 64;
|
||||
check(
|
||||
"fully transparent sample → empty",
|
||||
blankKind(frame(N, () => [0, 0, 0, 0])) === "empty",
|
||||
);
|
||||
check(
|
||||
"pure black → black",
|
||||
blankKind(frame(N, () => [0, 0, 0, 255])) === "black",
|
||||
);
|
||||
check(
|
||||
"near-black encoder noise (16..18) → black",
|
||||
blankKind(frame(N, (i) => [16 + (i % 3), 16, 16, 255])) === "black",
|
||||
);
|
||||
check(
|
||||
"mid-grey picture → not blank",
|
||||
blankKind(frame(N, () => [128, 128, 128, 255])) === null,
|
||||
);
|
||||
check(
|
||||
"dim but visible picture (≈50) → not blank",
|
||||
blankKind(frame(N, () => [50, 48, 52, 255])) === null,
|
||||
);
|
||||
check(
|
||||
"dark night scene with a bright highlight → not blank",
|
||||
blankKind(
|
||||
frame(N, (i) => (i === 100 ? [250, 240, 200, 255] : [4, 4, 6, 255])),
|
||||
) === null,
|
||||
);
|
||||
|
||||
// ---- HTTP status classification ----------------------------------------
|
||||
|
||||
check("404 → not-found", statusErrorCode(404) === "not-found");
|
||||
check(
|
||||
"403 → forbidden",
|
||||
statusErrorCode(403) === "forbidden" && statusErrorCode(401) === "forbidden",
|
||||
);
|
||||
check("500 → network", statusErrorCode(500) === "network");
|
||||
check(
|
||||
"206 / 200 are not errors",
|
||||
statusErrorCode(206) === null && statusErrorCode(200) === null,
|
||||
);
|
||||
|
||||
if (failures) {
|
||||
console.error(`\n${failures} export-frame check(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("export-frame checks passed.");
|
||||
+101
-2
@@ -138,6 +138,13 @@ import type {
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
WorkflowMeta,
|
||||
WorkflowUIFileInput,
|
||||
WorkflowConvertFileReport,
|
||||
WorkflowConvertReport,
|
||||
WorkflowImportResult,
|
||||
} from "./comfy/types";
|
||||
export type {
|
||||
Job,
|
||||
@@ -150,6 +157,13 @@ export type {
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
WorkflowMeta,
|
||||
WorkflowUIFileInput,
|
||||
WorkflowConvertFileReport,
|
||||
WorkflowConvertReport,
|
||||
WorkflowImportResult,
|
||||
};
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -458,13 +472,94 @@ export const comfy = {
|
||||
|
||||
export const workflows = {
|
||||
list: () => api<Workflow[]>("/api/workflows"),
|
||||
analyze: (workflow_json: Record<string, unknown>) =>
|
||||
/** 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,
|
||||
refresh = false,
|
||||
) =>
|
||||
api<WorkflowAnalyzeResult>("/api/workflows/analyze", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workflow_json }),
|
||||
body: JSON.stringify({
|
||||
workflow_json,
|
||||
...(endpoint_id ? { endpoint_id } : {}),
|
||||
...(refresh ? { refresh } : {}),
|
||||
}),
|
||||
}),
|
||||
reanalyze: (id: number) =>
|
||||
api<Workflow>(`/api/workflows/${id}/reanalyze`, { method: "POST" }),
|
||||
/** L2 re-check against a live ComfyUI (HEL-643): node/model availability. */
|
||||
check: (
|
||||
id: number,
|
||||
payload?: { endpoint_id?: string | null; refresh?: boolean },
|
||||
) =>
|
||||
api<WorkflowConnectionCheck>(`/api/workflows/${id}/check`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
}),
|
||||
/** Launch (or re-attach to) one REAL test run (HEL-643 L3). */
|
||||
test: (
|
||||
id: number,
|
||||
payload: {
|
||||
endpoint_id?: string | null;
|
||||
input_values?: Record<string, unknown>;
|
||||
},
|
||||
) =>
|
||||
api<{ ok: boolean; job: Job; reused: boolean }>(
|
||||
`/api/workflows/${id}/test`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
),
|
||||
tests: (id: number) => api<Job[]>(`/api/workflows/${id}/tests`),
|
||||
clearTests: (id: number) =>
|
||||
api<{ ok: boolean; removed: number }>(`/api/workflows/${id}/clear-tests`, {
|
||||
method: "POST",
|
||||
}),
|
||||
create: (payload: {
|
||||
name: string;
|
||||
workflow_json: Record<string, unknown>;
|
||||
@@ -496,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}`, {
|
||||
|
||||
@@ -1,26 +1,72 @@
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
/** Studio image slots (ref-upload-spec §3): PNG / JPG / WebP, ≤ 20MB each. */
|
||||
export const IMAGE_SLOT_ACCEPT = '.png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp';
|
||||
export const IMAGE_SLOT_ACCEPT =
|
||||
".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp";
|
||||
export const IMAGE_SLOT_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
|
||||
const IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp"];
|
||||
|
||||
/** Reference video slots (HEL-643): MP4 / WebM / MOV, ≤ 500MB (upload cap). */
|
||||
export const VIDEO_SLOT_ACCEPT =
|
||||
"video/mp4,video/webm,video/quicktime,.mp4,.webm,.mov";
|
||||
export const VIDEO_SLOT_MAX_BYTES = 500 * 1024 * 1024;
|
||||
const VIDEO_EXTS = [".mp4", ".webm", ".mov"];
|
||||
|
||||
/** Reference audio slots (HEL-643): MP3 / WAV / M4A, ≤ 100MB. */
|
||||
export const AUDIO_SLOT_ACCEPT =
|
||||
"audio/mpeg,audio/wav,audio/mp4,.mp3,.wav,.m4a";
|
||||
export const AUDIO_SLOT_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const AUDIO_EXTS = [".mp3", ".wav", ".m4a"];
|
||||
|
||||
export type MediaSlotKind = "image" | "video" | "audio";
|
||||
|
||||
export function mediaSlotAccept(kind: MediaSlotKind): string {
|
||||
if (kind === "video") return VIDEO_SLOT_ACCEPT;
|
||||
if (kind === "audio") return AUDIO_SLOT_ACCEPT;
|
||||
return IMAGE_SLOT_ACCEPT;
|
||||
}
|
||||
|
||||
/** Why this file cannot fill a media slot of this kind (null = fine). */
|
||||
export function mediaFileProblem(
|
||||
kind: MediaSlotKind,
|
||||
file: { name: string; size: number },
|
||||
): string | null {
|
||||
const dot = file.name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : "";
|
||||
const max =
|
||||
kind === "video"
|
||||
? VIDEO_SLOT_MAX_BYTES
|
||||
: kind === "audio"
|
||||
? AUDIO_SLOT_MAX_BYTES
|
||||
: IMAGE_SLOT_MAX_BYTES;
|
||||
if (kind === "video" && !VIDEO_EXTS.includes(ext))
|
||||
return t("studio.errVideoFormat", { name: file.name });
|
||||
if (kind === "audio" && !AUDIO_EXTS.includes(ext))
|
||||
return t("studio.errAudioFormat", { name: file.name });
|
||||
if (kind === "image" && !IMAGE_EXTS.includes(ext))
|
||||
return t("studio.errImageFormat", { name: file.name });
|
||||
if (file.size > max)
|
||||
return kind === "image"
|
||||
? t("studio.errImageSize", { name: file.name })
|
||||
: t("studio.errMediaSize", { name: file.name });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Why this file cannot fill an image slot (null = fine). */
|
||||
export function imageFileProblem(file: { name: string; size: number }): string | null {
|
||||
const dot = file.name.lastIndexOf('.');
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : '';
|
||||
if (!IMAGE_EXTS.includes(ext)) return t('studio.errImageFormat', { name: file.name });
|
||||
if (file.size > IMAGE_SLOT_MAX_BYTES) return t('studio.errImageSize', { name: file.name });
|
||||
return null;
|
||||
export function imageFileProblem(file: {
|
||||
name: string;
|
||||
size: number;
|
||||
}): string | null {
|
||||
return mediaFileProblem("image", file);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
/** Display name of an uploaded path (strips the uuid8- prefix the upload endpoint writes). */
|
||||
export function uploadBaseName(p: string): string {
|
||||
const raw = p.split(/[/\\]/).pop() ?? p;
|
||||
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
|
||||
const raw = p.split(/[/\\]/).pop() ?? p;
|
||||
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ export type ComfyInputKind =
|
||||
| 'audio'
|
||||
| 'video'
|
||||
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
|
||||
| 'image_list';
|
||||
| 'image_list'
|
||||
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
|
||||
| 'video_list'
|
||||
| 'audio_list';
|
||||
export type ComfyOutputKind = 'image' | 'video' | 'other';
|
||||
|
||||
/** Structural finding on an imported workflow; `error` blocks generation. */
|
||||
@@ -38,6 +41,8 @@ export interface ComfyDynamicInput {
|
||||
options?: string[];
|
||||
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
|
||||
resolution?: { megapixels: string; multiple: number };
|
||||
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
|
||||
media_hint?: string;
|
||||
}
|
||||
|
||||
export interface ComfyDynamicOutput {
|
||||
@@ -84,6 +89,40 @@ export const USAGE_BY_KIND: Record<'image' | 'video', WorkflowUsage[]> = {
|
||||
video: ['text2video', 'image2video', 'ref2video'],
|
||||
};
|
||||
|
||||
/** One L2 connection-check finding against a live ComfyUI (HEL-643). */
|
||||
export interface PreflightFinding {
|
||||
level: 'error' | 'warn' | 'info' | 'ok';
|
||||
code: string;
|
||||
node_id?: string;
|
||||
node_title?: string | null;
|
||||
message: string;
|
||||
hint?: string | null;
|
||||
}
|
||||
|
||||
/** Result of checking a workflow against a target connection's /object_info. */
|
||||
export interface WorkflowConnectionCheck {
|
||||
endpoint_id: string | null;
|
||||
endpoint_name: string | null;
|
||||
base_url?: string | null;
|
||||
/** False when any error-level finding exists (or the server was unreachable). */
|
||||
ok: boolean;
|
||||
/** False → 无法核查 (server down): honest, non-blocking. */
|
||||
reachable: boolean;
|
||||
findings: PreflightFinding[];
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
/** Slim live record of a workflow's latest test run (HEL-643). */
|
||||
export interface WorkflowLastTest {
|
||||
id: number;
|
||||
status: string;
|
||||
phase: string | null;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
output_count: number;
|
||||
}
|
||||
|
||||
/** Result of POST /api/workflows/analyze (import confirm step 1). */
|
||||
export interface WorkflowAnalyzeResult {
|
||||
inputs: ComfyDynamicInput[];
|
||||
@@ -101,6 +140,8 @@ export interface WorkflowAnalyzeResult {
|
||||
summary: string[];
|
||||
};
|
||||
usable_usage: WorkflowUsage[];
|
||||
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
|
||||
connection_check?: WorkflowConnectionCheck;
|
||||
}
|
||||
|
||||
export interface WorkflowNode {
|
||||
@@ -130,6 +171,122 @@ export interface Workflow {
|
||||
output_map_source?: 'auto' | 'user' | null;
|
||||
/** True when the graph has unknown output nodes needing designation. */
|
||||
needs_mapping?: boolean;
|
||||
/** Latest test-run record, queried live (HEL-643); null = never tested. */
|
||||
last_test?: WorkflowLastTest | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HEL-682: metadata-only row (GET /api/workflows?view=meta) — the whole
|
||||
* point is browsing 150+ workflows without downloading workflow_json.
|
||||
*/
|
||||
export interface WorkflowMediaIO {
|
||||
in: string[];
|
||||
out: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowMeta {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: 'image' | 'video';
|
||||
usage: WorkflowUsage | null;
|
||||
usage_source: 'auto' | 'user' | null;
|
||||
is_enabled: boolean;
|
||||
category: string | null;
|
||||
model_family: string | null;
|
||||
media_io: WorkflowMediaIO;
|
||||
tags: string[];
|
||||
favorite: boolean;
|
||||
last_used_at: string | null;
|
||||
description: string | null;
|
||||
source: string | null;
|
||||
source_path: string | null;
|
||||
prompt_profile: string;
|
||||
created_at: string;
|
||||
endpoint_id: string | null;
|
||||
output_map_source: 'auto' | 'user' | null;
|
||||
/** Cheap pending check: usage or output mapping not settled yet. */
|
||||
needs_mapping: boolean;
|
||||
}
|
||||
|
||||
/** One UI-format file submitted to the batch convert/import pipeline. */
|
||||
export interface WorkflowUIFileInput {
|
||||
name?: string;
|
||||
source_path?: string;
|
||||
workflow_json: Record<string, unknown>;
|
||||
usage?: WorkflowUsage;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface WorkflowConvertIssue {
|
||||
node_id: string;
|
||||
code: string;
|
||||
level: 'error' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WorkflowConvertSuggested {
|
||||
kind: 'image' | 'video';
|
||||
usage: WorkflowUsage | null;
|
||||
usage_ambiguous: boolean;
|
||||
category: string | null;
|
||||
category_label: string | null;
|
||||
model_family: string | null;
|
||||
media_in: string[];
|
||||
media_out: string[];
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowPreflightFinding {
|
||||
level: string;
|
||||
code: string;
|
||||
node_id?: string;
|
||||
node_title?: string;
|
||||
message: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowConvertFileReport {
|
||||
name: string;
|
||||
source_path: string | null;
|
||||
status: 'ok' | 'warn' | 'fail';
|
||||
needs_confirmation: boolean;
|
||||
issues: WorkflowConvertIssue[];
|
||||
importable: boolean;
|
||||
skip_reason?: string | null;
|
||||
suggested?: WorkflowConvertSuggested;
|
||||
preflight?: {
|
||||
endpoint_id: string | null;
|
||||
ok: boolean;
|
||||
findings: WorkflowPreflightFinding[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Dry-run report (POST /convert, POST /import-scan with dry_run). */
|
||||
export interface WorkflowConvertReport {
|
||||
total: number;
|
||||
ok: number;
|
||||
warn: number;
|
||||
fail: number;
|
||||
needs_confirmation: number;
|
||||
importable: number;
|
||||
files: WorkflowConvertFileReport[];
|
||||
}
|
||||
|
||||
export interface WorkflowImportFileReport extends WorkflowConvertFileReport {
|
||||
result?: 'imported' | 'skipped' | 'failed';
|
||||
id?: number;
|
||||
existing_id?: number;
|
||||
}
|
||||
|
||||
/** Commit result (POST /import-batch, POST /import-scan without dry_run). */
|
||||
export interface WorkflowImportResult {
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
imported_ids: number[];
|
||||
skipped_files: { name: string; source_path: string | null; reason: string | null }[];
|
||||
files: WorkflowImportFileReport[];
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
@@ -152,6 +309,8 @@ export interface Job {
|
||||
comfy_prompt_id?: string | null;
|
||||
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
|
||||
phase?: string | null;
|
||||
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface SceneVideoSettings {
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { jobsApi, type Job } from '$lib/api';
|
||||
import type { CalliopeEvent, EventConnectionState } from '$lib/events';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import JobRow from './JobRow.svelte';
|
||||
import Button from './ui/Button.svelte';
|
||||
import Icon from './ui/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
logs: CalliopeEvent[];
|
||||
projectId: number;
|
||||
/** Real SSE connection state from events.ts — drives the live pip. */
|
||||
connState: EventConnectionState;
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { logs, projectId, connState, collapsed = false, onToggle }: Props = $props();
|
||||
|
||||
type Tone = 'info' | 'ok' | 'warn' | 'err' | 'work';
|
||||
|
||||
type LogEntry = {
|
||||
key: string;
|
||||
ts: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
tone: Tone;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function str(v: unknown): string {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatEvent(ev: CalliopeEvent, index: number): LogEntry | null {
|
||||
// Poll spam — progress lives in the jobProgress store, not the log
|
||||
if (ev.type === 'job.progress') return null;
|
||||
|
||||
const d = ev.data ?? {};
|
||||
const msg = str(d.message).trim();
|
||||
const kind = str(d.kind);
|
||||
const jobId = d.job_id != null ? `#${d.job_id}` : '';
|
||||
const err = str(d.error).trim();
|
||||
const paths = Array.isArray(d.paths) ? d.paths.length : 0;
|
||||
const outputs = Array.isArray(d.outputs) ? d.outputs.length : 0;
|
||||
const time = ev.ts?.includes('T') ? ev.ts.slice(11, 19) : ev.ts?.slice(0, 8) || '';
|
||||
|
||||
let title = ev.type;
|
||||
let detail = msg;
|
||||
let tone: Tone = 'info';
|
||||
|
||||
switch (ev.type) {
|
||||
case 'agent.thinking':
|
||||
title = t('activity.agentWorking');
|
||||
detail = msg || t('activity.working');
|
||||
tone = 'work';
|
||||
break;
|
||||
case 'story.ready':
|
||||
title = t('activity.storyReady');
|
||||
detail = msg || t('activity.storylineDrafted');
|
||||
tone = 'ok';
|
||||
break;
|
||||
case 'job.created':
|
||||
title = t('activity.queued');
|
||||
detail = msg || [kind, jobId].filter(Boolean).join(' ');
|
||||
tone = 'info';
|
||||
break;
|
||||
case 'job.started':
|
||||
title = t('activity.running');
|
||||
detail = msg || [kind, jobId].filter(Boolean).join(' ');
|
||||
tone = 'work';
|
||||
break;
|
||||
case 'job.completed':
|
||||
title = t('activity.finished');
|
||||
detail =
|
||||
msg ||
|
||||
[kind, jobId, outputs ? t('activity.files', { count: outputs }) : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
tone = 'ok';
|
||||
break;
|
||||
case 'job.failed':
|
||||
title = t('activity.failed');
|
||||
detail = err || msg || [kind, jobId].filter(Boolean).join(' ');
|
||||
tone = 'err';
|
||||
break;
|
||||
case 'asset.ready':
|
||||
title = t('activity.assetReady');
|
||||
detail = msg || (paths ? t('activity.filesSaved', { count: paths }) : t('activity.outputSaved'));
|
||||
tone = 'ok';
|
||||
break;
|
||||
case 'job.deleted':
|
||||
title = t('activity.removed');
|
||||
detail = msg || jobId || t('activity.jobDeleted');
|
||||
tone = 'warn';
|
||||
break;
|
||||
default:
|
||||
title = ev.type.replace(/\./g, ' · ');
|
||||
detail = msg || err || kind;
|
||||
tone = 'info';
|
||||
}
|
||||
|
||||
return {
|
||||
key: `${ev.ts}-${ev.type}-${index}`,
|
||||
ts: time,
|
||||
title,
|
||||
detail,
|
||||
tone,
|
||||
type: ev.type,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = $derived.by(() => {
|
||||
const raw = logs
|
||||
.slice(-120)
|
||||
.map((ev, i) => formatEvent(ev, i))
|
||||
.filter((e): e is LogEntry => e != null);
|
||||
|
||||
// Collapse consecutive identical agent lines
|
||||
const collapsed: LogEntry[] = [];
|
||||
for (const e of raw) {
|
||||
const prev = collapsed[collapsed.length - 1];
|
||||
if (prev && prev.type === e.type && prev.detail === e.detail) {
|
||||
continue;
|
||||
}
|
||||
collapsed.push(e);
|
||||
}
|
||||
return collapsed.slice(-60).reverse();
|
||||
});
|
||||
|
||||
// Same query key as QueueStage — refreshed by SSE invalidation from the
|
||||
// project page (job.* events), not by polling.
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs', projectId],
|
||||
queryFn: () => jobsApi.list(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
const allJobs = $derived(($jobsQuery.data ?? []) as Job[]);
|
||||
|
||||
function activeWeight(job: Job): number {
|
||||
if (job.status === 'running') return 0;
|
||||
if (job.status === 'pending') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Active jobs first, then most recent — capped so the log keeps room.
|
||||
const queueRows = $derived(
|
||||
[...allJobs].sort((a, b) => activeWeight(a) - activeWeight(b) || b.id - a.id).slice(0, 6),
|
||||
);
|
||||
|
||||
const stats = $derived({
|
||||
running: allJobs.filter((j) => j.status === 'running').length,
|
||||
queued: allJobs.filter((j) => j.status === 'pending').length,
|
||||
done: allJobs.filter((j) => j.status === 'done').length,
|
||||
failed: allJobs.filter((j) => j.status === 'failed').length,
|
||||
});
|
||||
const busyCount = $derived(stats.running + stats.queued);
|
||||
|
||||
const connected = $derived(connState === 'open');
|
||||
</script>
|
||||
|
||||
<aside class="activity" class:collapsed aria-label={t('activity.title')}>
|
||||
{#if collapsed}
|
||||
<button
|
||||
type="button"
|
||||
class="rail-toggle"
|
||||
onclick={onToggle}
|
||||
aria-expanded="false"
|
||||
title={t('activity.show')}
|
||||
>
|
||||
<span class="pip rail-pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
|
||||
{#if busyCount > 0}
|
||||
<span class="rail-count" title={t('activity.busyCount', { count: busyCount })}>{busyCount}</span>
|
||||
{/if}
|
||||
<span class="rail-label">{t('activity.title')}</span>
|
||||
<span class="rail-chevron" aria-hidden="true">‹</span>
|
||||
</button>
|
||||
{:else}
|
||||
<header class="head">
|
||||
<div class="head-title">
|
||||
<span class="pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
|
||||
<strong>{t('activity.title')}</strong>
|
||||
{#if connState === 'reconnecting'}
|
||||
<span class="conn-state warn">{t('activity.reconnecting')}</span>
|
||||
{:else if connState === 'connecting'}
|
||||
<span class="conn-state">{t('activity.connecting')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onclick={onToggle} title={t('activity.hide')}>
|
||||
{t('activity.hide')} <Icon name="chevron-right" size={13} />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<section class="queue" aria-label={t('activity.queue')}>
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">{t('activity.queue')}</p>
|
||||
</div>
|
||||
{#if queueRows.length === 0}
|
||||
<p class="queue-empty">{t('activity.queueIdle')}</p>
|
||||
{:else}
|
||||
<div class="job-list">
|
||||
{#each queueRows as job (job.id)}
|
||||
<JobRow {job} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="stats-strip" aria-label={t('activity.queueStats')}>
|
||||
<span><span class="sd sd-running" aria-hidden="true"></span>{stats.running} {t('activity.running').toLowerCase()}</span>
|
||||
<span><span class="sd sd-queued" aria-hidden="true"></span>{stats.queued} {t('activity.queued').toLowerCase()}</span>
|
||||
<span><span class="sd sd-done" aria-hidden="true"></span>{stats.done} {t('activity.done')}</span>
|
||||
<span><span class="sd sd-failed" aria-hidden="true"></span>{stats.failed} {t('activity.failed').toLowerCase()}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="log-section" aria-label={t('activity.agentLog')}>
|
||||
<p class="eyebrow">{t('activity.agentLog')}</p>
|
||||
{#if entries.length === 0}
|
||||
<div class="empty">
|
||||
<p>{t('activity.quiet')}</p>
|
||||
<p class="hint">{t('activity.quietHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="log">
|
||||
{#each entries as ev (ev.key)}
|
||||
<li class="entry tone-{ev.tone}">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<div class="meta">
|
||||
<div class="row">
|
||||
<span class="title">{ev.title}</span>
|
||||
<time class="ts" datetime={ev.ts}>{ev.ts}</time>
|
||||
</div>
|
||||
{#if ev.detail}
|
||||
<p class="detail">{ev.detail}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.activity {
|
||||
--panel-w: 320px;
|
||||
--rail-w: 44px;
|
||||
width: var(--panel-w);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--border);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(139, 92, 246, 0.06) 0%, transparent 120px),
|
||||
var(--bg-surface);
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.activity.collapsed {
|
||||
width: var(--rail-w);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.rail-toggle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 16px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rail-toggle:hover {
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rail-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.rail-label {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rail-chevron {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rail-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pip {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pip.live {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
animation: live-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pip.reconnecting {
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.18);
|
||||
}
|
||||
|
||||
@keyframes live-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.65;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.activity {
|
||||
transition: none;
|
||||
}
|
||||
.pip.live {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.head-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.head-title strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.conn-state {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conn-state.warn {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 10px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.queue {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.job-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 216px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.queue-empty {
|
||||
margin: 0 6px 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stats-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
margin: 10px 6px 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stats-strip > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sd {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sd-running {
|
||||
background: var(--info);
|
||||
}
|
||||
.sd-queued {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.sd-done {
|
||||
background: var(--success);
|
||||
}
|
||||
.sd-failed {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.log-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 8px 6px;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty .hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.log {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 4px 8px 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 8px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.entry:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.tone-ok .dot {
|
||||
background: var(--success);
|
||||
}
|
||||
.tone-err .dot {
|
||||
background: var(--error);
|
||||
}
|
||||
.tone-warn .dot {
|
||||
background: var(--warning);
|
||||
}
|
||||
.tone-work .dot {
|
||||
background: var(--accent);
|
||||
}
|
||||
.tone-info .dot {
|
||||
background: var(--info);
|
||||
}
|
||||
|
||||
.meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ts {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tone-err .detail {
|
||||
color: #fca5a5;
|
||||
}
|
||||
</style>
|
||||
@@ -15,8 +15,8 @@ interface Props {
|
||||
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<a class="logo" href="/canvas" title="Calliope Lab">
|
||||
Calliope <span>Lab</span>
|
||||
<a class="logo" href="/" title={t('shell.brand')}>
|
||||
{t('shell.brand')}
|
||||
</a>
|
||||
{#if crumb}
|
||||
<span class="crumb">{crumb}</span>
|
||||
@@ -28,7 +28,7 @@ interface Props {
|
||||
{/if}
|
||||
<nav class="top-nav" aria-label={t('nav.primary')}>
|
||||
<a class="nav-link" class:active={active === 'canvas'} href="/canvas">{t('nav.canvas')}</a>
|
||||
<a class="nav-link" class:active={active === 'projects'} href="/projects">{t('nav.projects')}</a>
|
||||
<a class="nav-link" class:active={active === 'projects'} href="/">{t('nav.projects')}</a>
|
||||
<a class="nav-link" class:active={active === 'build-scene'} href="/build-scene">{t('nav.buildScene')}</a>
|
||||
<a class="nav-link" class:active={active === 'library'} href="/library">{t('nav.library')}</a>
|
||||
<a class="nav-link" class:active={active === 'agents'} href="/agents">{t('nav.agents')}</a>
|
||||
@@ -69,18 +69,14 @@ interface Props {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-family: var(--font);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logo span {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.crumb {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
@@ -122,7 +118,7 @@ interface Props {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 760px) {
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
|
||||
interface Props {
|
||||
src: string | null;
|
||||
alt: string;
|
||||
placeholder: string;
|
||||
wide?: boolean;
|
||||
/** Taller 4:3 media-first crop (asset card grids). */
|
||||
tall?: boolean;
|
||||
/** Latest generation-job state for this asset, if the parent tracks jobs. */
|
||||
jobState?: 'generating' | 'failed' | null;
|
||||
onPreview?: (src: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
src,
|
||||
alt,
|
||||
placeholder,
|
||||
wide = false,
|
||||
tall = false,
|
||||
jobState = null,
|
||||
onPreview,
|
||||
}: Props = $props();
|
||||
|
||||
let available = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
void src;
|
||||
available = false;
|
||||
});
|
||||
|
||||
// Job state (live from the queue) wins over the media-derived state.
|
||||
const chip = $derived.by((): { status: string; label: string } => {
|
||||
if (jobState === 'generating') return { status: 'generating', label: t('common.generating') };
|
||||
if (jobState === 'failed') return { status: 'failed', label: t('common.failed') };
|
||||
if (available) return { status: 'ready', label: t('common.ready') };
|
||||
if (src) return { status: 'missing', label: t('common.missing') };
|
||||
return { status: 'pending', label: t('common.pending') };
|
||||
});
|
||||
|
||||
function open() {
|
||||
if (available && src && onPreview) onPreview(src);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="thumb"
|
||||
class:wide
|
||||
class:tall
|
||||
class:clickable={available && !!onPreview}
|
||||
role={available && onPreview ? 'button' : undefined}
|
||||
tabindex={available && onPreview ? 0 : undefined}
|
||||
onclick={open}
|
||||
onkeydown={available && onPreview ? onKey : undefined}
|
||||
>
|
||||
<SafeMedia
|
||||
class="fill"
|
||||
{src}
|
||||
{alt}
|
||||
label={placeholder}
|
||||
onAvailable={() => (available = true)}
|
||||
onUnavailable={() => (available = false)}
|
||||
/>
|
||||
<span class="thumb-chip">
|
||||
<StatusChip status={chip.status} label={chip.label} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.thumb {
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.thumb.wide {
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
.thumb.tall {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
.thumb.clickable {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.thumb.clickable:hover {
|
||||
border-color: var(--accent, #a78bfa);
|
||||
}
|
||||
.thumb.clickable:focus-visible {
|
||||
outline: 2px solid var(--accent, #a78bfa);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.thumb :global(.fill) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.thumb :global(.safe-media-ph.fill) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.thumb-chip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -136,9 +136,9 @@
|
||||
|
||||
const projectList = $derived(($projectsQuery.data ?? []) as Project[]);
|
||||
|
||||
// Stage-URL contract: images land on Assets, clips on Video.
|
||||
// Five-stage workbench URL contract: images land on 素材资产, clips on 镜头脚本.
|
||||
const stageLink = $derived(
|
||||
attached ? `/project/${attached.projectId}?stage=${isVideo ? 'video' : 'assets'}` : '',
|
||||
attached ? `/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}` : '',
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -267,13 +267,13 @@
|
||||
.attached {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: var(--ok-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent);
|
||||
border-radius: var(--r-md);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.attached-check {
|
||||
display: flex;
|
||||
@@ -282,8 +282,8 @@
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
color: #052e12;
|
||||
background: var(--ok);
|
||||
color: var(--bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.attached-text {
|
||||
@@ -293,23 +293,26 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.attached-text strong {
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 普通链接走 ghost(§1.2):默认 --text-2,悬停 --text,不用金色文字链接。 */
|
||||
.attached-link {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.attached-link:hover {
|
||||
color: var(--text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.attached-link:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.attached-dismiss {
|
||||
display: flex;
|
||||
@@ -319,15 +322,15 @@
|
||||
height: 22px;
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.attached-dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.attached-dismiss:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -336,20 +339,20 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-5);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-1);
|
||||
}
|
||||
.err {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
margin: var(--sp-1) 0 0;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--err);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
allowUpload?: boolean;
|
||||
/** Force required-field error rings visible (e.g. after a blocked Generate). */
|
||||
showErrors?: boolean;
|
||||
/** Show each field's workflow value (原值) and mark fields that differ from it. */
|
||||
showOrig?: boolean;
|
||||
/** Extra per-field messages keyed by nodeId (e.g. range checks); shown with showErrors. */
|
||||
errors?: Record<string, string>;
|
||||
/** Emits labels of required-but-empty inputs whenever the set changes. */
|
||||
onValidityChange?: (missing: string[]) => void;
|
||||
onChange?: (values: Record<string, string | number>) => void;
|
||||
@@ -30,10 +34,23 @@
|
||||
assetOptions = [],
|
||||
allowUpload = false,
|
||||
showErrors = false,
|
||||
showOrig = false,
|
||||
errors = {},
|
||||
onValidityChange,
|
||||
onChange,
|
||||
}: Props = $props();
|
||||
|
||||
function hasOrig(inp: ComfyDynamicInput): boolean {
|
||||
return inp.defaultValue !== undefined && inp.defaultValue !== '';
|
||||
}
|
||||
|
||||
function isChanged(inp: ComfyDynamicInput): boolean {
|
||||
if (!showOrig) return false;
|
||||
const v = values[inp.nodeId];
|
||||
if (isBlank(v)) return hasOrig(inp);
|
||||
return !hasOrig(inp) || String(v) !== String(inp.defaultValue);
|
||||
}
|
||||
|
||||
// Unique per mounted form so label/id pairs never collide across instances.
|
||||
const uid = Math.random().toString(36).slice(2, 8);
|
||||
let touched = $state<Record<string, boolean>>({});
|
||||
@@ -80,6 +97,11 @@
|
||||
return isMissing(inp) && (showErrors || !!touched[inp.nodeId]);
|
||||
}
|
||||
|
||||
function extraError(inp: ComfyDynamicInput): string | null {
|
||||
const msg = errors[inp.nodeId]?.trim();
|
||||
return msg && !isMissing(inp) && (showErrors || !!touched[inp.nodeId]) ? msg : null;
|
||||
}
|
||||
|
||||
function thumbFor(current: string): string | null {
|
||||
if (!current) return null;
|
||||
const opt = assetOptions.find((o) => o.path === current);
|
||||
@@ -224,7 +246,9 @@
|
||||
{#each inputs as inp (inp.nodeId)}
|
||||
{@const current = String(values[inp.nodeId] ?? '')}
|
||||
{@const id = inputId(inp.nodeId)}
|
||||
{@const errored = showError(inp)}
|
||||
{@const extra = extraError(inp)}
|
||||
{@const errored = showError(inp) || !!extra}
|
||||
{@const changed = isChanged(inp)}
|
||||
<div class="field">
|
||||
<label class="label" for={id}>
|
||||
{inp.label}
|
||||
@@ -235,6 +259,9 @@
|
||||
{#if inp.role}<span class="role">{inp.role}</span>{/if}
|
||||
<span class="kind">{inp.kind}</span>
|
||||
{/if}
|
||||
{#if showOrig && hasOrig(inp)}
|
||||
<span class="orig">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{#if readonly}
|
||||
<div class="readonly">{values[inp.nodeId] ?? inp.defaultValue ?? '—'}</div>
|
||||
@@ -243,6 +270,7 @@
|
||||
{id}
|
||||
rows="3"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={current}
|
||||
@@ -254,6 +282,7 @@
|
||||
{id}
|
||||
type="number"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={values[inp.nodeId] ?? ''}
|
||||
@@ -264,6 +293,7 @@
|
||||
<select
|
||||
{id}
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
value={current || String(inp.defaultValue ?? '')}
|
||||
onchange={(e) => setValue(inp.nodeId, e.currentTarget.value)}
|
||||
onblur={() => markTouched(inp.nodeId)}
|
||||
@@ -445,6 +475,7 @@
|
||||
{id}
|
||||
type="text"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={current}
|
||||
@@ -453,7 +484,7 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if errored}
|
||||
<span class="error-msg" role="alert">{t('cdf.requiredMsg', { label: inp.label })}</span>
|
||||
<span class="error-msg" role="alert">{extra ?? t('cdf.requiredMsg', { label: inp.label })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -516,11 +547,23 @@
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
input.changed,
|
||||
textarea.changed,
|
||||
select.changed {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
input.invalid,
|
||||
textarea.invalid,
|
||||
select.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.orig {
|
||||
float: right;
|
||||
font-weight: 400;
|
||||
font-size: var(--fs-mini);
|
||||
font-family: var(--mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
input.invalid:focus-visible,
|
||||
textarea.invalid:focus-visible,
|
||||
select.invalid:focus-visible {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
|
||||
interface Props {
|
||||
src: string | null;
|
||||
@@ -61,6 +63,17 @@
|
||||
}
|
||||
return alt ? `${alt.trim().replace(/\s+/g, '-').toLowerCase()}.png` : 'media';
|
||||
});
|
||||
|
||||
let frameBusy = $state(false);
|
||||
async function exportTail() {
|
||||
if (!src || frameBusy) return;
|
||||
frameBusy = true;
|
||||
try {
|
||||
await exportLastFrame(src);
|
||||
} finally {
|
||||
frameBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={src ? onKeydown : undefined} />
|
||||
@@ -90,6 +103,26 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
<Icon name="zoom-in" size={17} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if isVideo}
|
||||
<button
|
||||
class="tool"
|
||||
type="button"
|
||||
aria-label={frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
|
||||
title={frameBusy ? t('lastFrame.exporting') : `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
|
||||
disabled={frameBusy}
|
||||
aria-busy={frameBusy}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
exportTail();
|
||||
}}
|
||||
>
|
||||
{#if frameBusy}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<Icon name="image" size={17} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<a
|
||||
class="tool"
|
||||
href={src}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { jobsApi, type Job } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { toast } from '$lib/toast';
|
||||
import Button from './ui/Button.svelte';
|
||||
import Icon from './ui/Icon.svelte';
|
||||
import type { IconName } from './ui/icons';
|
||||
import ProgressBar from './ui/ProgressBar.svelte';
|
||||
import StatusChip from './ui/StatusChip.svelte';
|
||||
|
||||
interface Props {
|
||||
job: Job;
|
||||
/** Optional override for the leading label (defaults to "Kind #id"). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { job, label }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
let busy = $state(false);
|
||||
|
||||
const kindIcon: IconName = $derived(
|
||||
job.kind === 'video'
|
||||
? 'video'
|
||||
: job.kind === 'image'
|
||||
? 'image'
|
||||
: job.kind === 'export'
|
||||
? 'film'
|
||||
: 'queue',
|
||||
);
|
||||
const kindLabel = $derived(
|
||||
job.kind === 'export'
|
||||
? t('job.exportFilm')
|
||||
: job.kind.charAt(0).toUpperCase() + job.kind.slice(1),
|
||||
);
|
||||
const title = $derived(label ?? t('job.title', { kind: kindLabel, id: String(job.id) }));
|
||||
const active = $derived(job.status === 'pending' || job.status === 'running');
|
||||
const entry = $derived(progressFor(job.id));
|
||||
|
||||
async function refresh() {
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
busy = true;
|
||||
try {
|
||||
await jobsApi.cancel(job.id);
|
||||
await refresh();
|
||||
toast.info(t('job.cancelled', { id: String(job.id) }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('job.cancelFailed'));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
busy = true;
|
||||
try {
|
||||
await jobsApi.retry(job.id);
|
||||
await refresh();
|
||||
toast.success(t('job.requeued', { id: String(job.id) }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('job.retryFailed'));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="job-row">
|
||||
<span class="kind" aria-hidden="true"><Icon name={kindIcon} size={14} /></span>
|
||||
<div class="main">
|
||||
<div class="top">
|
||||
<span class="label" {title}>{title}</span>
|
||||
<StatusChip status={job.status} />
|
||||
</div>
|
||||
{#if active}
|
||||
<div class="bar">
|
||||
<ProgressBar
|
||||
size="sm"
|
||||
value={entry?.progress ?? 0}
|
||||
indeterminate={job.status === 'running' && entry == null}
|
||||
label={entry?.message}
|
||||
/>
|
||||
</div>
|
||||
{:else if job.status === 'failed' && job.error}
|
||||
<p class="err" title={job.error}>{job.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if active}
|
||||
<Button size="sm" variant="ghost" loading={busy} title={t('job.cancelJob')} onclick={cancel}>
|
||||
<Icon name="close" size={13} />
|
||||
</Button>
|
||||
{:else if job.status === 'failed'}
|
||||
<Button size="sm" variant="ghost" loading={busy} title={t('job.retryJob')} onclick={retry}>
|
||||
<Icon name="retry" size={13} />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.job-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.kind {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 20px;
|
||||
}
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bar {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.err {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--error);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,137 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
|
||||
const items = $derived(
|
||||
[
|
||||
{ id: 'story', key: 'nav.story', icon: 'story' },
|
||||
{ id: 'assets', key: 'nav.assets', icon: 'assets' },
|
||||
{ id: 'script', key: 'nav.script', icon: 'script' },
|
||||
{ id: 'video', key: 'nav.video', icon: 'video' },
|
||||
] as { id: string; key: string; icon: IconName }[],
|
||||
);
|
||||
|
||||
interface Props {
|
||||
active?: string;
|
||||
onSelect?: (id: string) => void;
|
||||
/** Optional per-stage counts — a nonzero value shows a small pill on the icon. */
|
||||
badges?: Record<string, number>;
|
||||
}
|
||||
|
||||
let { active = 'story', onSelect, badges = {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<nav class="nav" aria-label={t('nav.projectStages')}>
|
||||
<button
|
||||
class="nav-item home"
|
||||
title={t('nav.projects')}
|
||||
aria-label={t('nav.backProjects')}
|
||||
onclick={() => goto('/projects')}
|
||||
>
|
||||
<span class="icon"><Icon name="home" size={20} /></span>
|
||||
<span class="label">{t('nav.home')}</span>
|
||||
</button>
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
{#each items as item (item.id)}
|
||||
{@const count = badges[item.id] ?? 0}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={active === item.id}
|
||||
title={t(item.key)}
|
||||
aria-current={active === item.id ? 'page' : undefined}
|
||||
onclick={() => onSelect?.(item.id)}
|
||||
>
|
||||
<span class="icon">
|
||||
<Icon name={item.icon} size={20} />
|
||||
{#if count > 0}
|
||||
<span class="badge" aria-label={t('nav.countActive', { count })}>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="label">{t(item.key)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.nav {
|
||||
width: 64px;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-md) 0;
|
||||
gap: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.divider {
|
||||
width: 32px;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 2px 0 4px;
|
||||
}
|
||||
.nav-item {
|
||||
width: 56px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
box-shadow: 0 0 16px var(--accent-glow);
|
||||
}
|
||||
.nav-item.home:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.nav-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
line-height: 1;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -12px;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 0 2px var(--bg-surface);
|
||||
}
|
||||
.nav-item.active .badge {
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,478 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { assetUrl, projects, type Project } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
const { project }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
const stats = $derived(
|
||||
project.stats ?? {
|
||||
scene_count: 0,
|
||||
character_count: 0,
|
||||
asset_ready_count: 0,
|
||||
asset_total_count: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// Explicit project cover wins; otherwise fall back to the first asset image.
|
||||
// The project prop is stable for the lifetime of a card, so the eager
|
||||
// queryKey capture below is intentional.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const assetsQuery = createQuery({
|
||||
queryKey: ['assets', project.id],
|
||||
queryFn: () => projects.getAssets(project.id),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const thumbUrl = $derived.by(() => {
|
||||
if (project.cover_path) return assetUrl(project.cover_path);
|
||||
const data = $assetsQuery.data;
|
||||
if (!data) return null;
|
||||
const char = data.characters.find((c) => c.portrait_path || c.sheet_path);
|
||||
const path =
|
||||
char?.portrait_path ??
|
||||
char?.sheet_path ??
|
||||
data.locations.find((l) => l.reference_image_path)?.reference_image_path ??
|
||||
null;
|
||||
return assetUrl(path);
|
||||
});
|
||||
|
||||
// A broken cover (deleted file, 404) falls back to the gradient tile.
|
||||
let thumbFailedFor = $state<string | null>(null);
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
|
||||
if (mins < 1) return t('projectCard.timeJustNow');
|
||||
if (mins < 60) return t('projectCard.timeM', { count: mins });
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return t('projectCard.timeH', { count: hours });
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 30) return t('projectCard.timeD', { count: days });
|
||||
const months = Math.round(days / 30);
|
||||
if (months < 12) return t('projectCard.timeMo', { count: months });
|
||||
return t('projectCard.timeY', { count: Math.round(months / 12) });
|
||||
}
|
||||
|
||||
const updatedLabel = $derived(relativeTime(project.updated_at));
|
||||
const updatedTitle = $derived(new Date(project.updated_at).toLocaleString());
|
||||
|
||||
// Overflow menu (⋯) — closes on outside click, focus leaving, or Esc.
|
||||
let menuOpen = $state(false);
|
||||
let menuEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') menuOpen = false;
|
||||
};
|
||||
const onFocusIn = (e: FocusEvent) => {
|
||||
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
|
||||
};
|
||||
window.addEventListener('click', onDocClick);
|
||||
window.addEventListener('keydown', onKey);
|
||||
document.addEventListener('focusin', onFocusIn);
|
||||
return () => {
|
||||
window.removeEventListener('click', onDocClick);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('focusin', onFocusIn);
|
||||
};
|
||||
});
|
||||
|
||||
let renameOpen = $state(false);
|
||||
let renameValue = $state('');
|
||||
|
||||
const renameMutation = createMutation({
|
||||
mutationFn: (title: string) => projects.update(project.id, { title }),
|
||||
onSuccess: (updated) => {
|
||||
client.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success(t('projectCard.renamedTo', { title: updated.title }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.renameFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
function openRename() {
|
||||
menuOpen = false;
|
||||
renameValue = project.title;
|
||||
renameOpen = true;
|
||||
}
|
||||
|
||||
function submitRename() {
|
||||
const title = renameValue.trim();
|
||||
renameOpen = false;
|
||||
if (!title || title === project.title) return;
|
||||
$renameMutation.mutate(title);
|
||||
}
|
||||
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
const deleteMutation = createMutation({
|
||||
mutationFn: () => projects.delete(project.id),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success(t('projectCard.deleted', { title: project.title }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.deleteFailed'));
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<article class="project-card">
|
||||
<button
|
||||
type="button"
|
||||
class="card-main"
|
||||
aria-label={t('projectCard.open', { title: project.title })}
|
||||
onclick={() => goto(`/project/${project.id}`)}
|
||||
>
|
||||
<div class="card-thumb">
|
||||
{#if thumbUrl && thumbFailedFor !== thumbUrl}
|
||||
<img
|
||||
class="thumb-img"
|
||||
src={thumbUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror={() => (thumbFailedFor = thumbUrl)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="card-thumb-icon"><Icon name="video" size={26} /></div>
|
||||
{/if}
|
||||
<div class="card-status">
|
||||
<StatusChip status={project.status} label={t(`projectCard.status.${project.status}`)} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
{project.title}
|
||||
<span class="open-arrow">→</span>
|
||||
</div>
|
||||
<p class="card-desc">{project.idea || t('projectCard.noIdea')}</p>
|
||||
<div class="card-meta">
|
||||
<div class="card-stats">
|
||||
<span class="card-stat">
|
||||
<Icon name="script" size={13} />
|
||||
{t('projectCard.scenes', { count: stats.scene_count })}
|
||||
</span>
|
||||
<span class="card-stat">
|
||||
<Icon name="assets" size={13} />
|
||||
{t('projectCard.characters', { count: stats.character_count })}
|
||||
</span>
|
||||
<span class="card-stat">
|
||||
<Icon name="image" size={13} />
|
||||
{t('projectCard.assets', {
|
||||
ready: stats.asset_ready_count,
|
||||
total: stats.asset_total_count,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<span title={updatedTitle}>{updatedLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="menu-wrap" bind:this={menuEl}>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-btn"
|
||||
aria-label={t('projectCard.actions', { title: project.title })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div class="menu" role="menu" aria-label={t('projectCard.menuLabel')}>
|
||||
<button type="button" role="menuitem" class="menu-item" onclick={openRename}>
|
||||
<Icon name="edit" size={14} />
|
||||
{t('common.rename')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="menu-item"
|
||||
onclick={() => {
|
||||
menuOpen = false;
|
||||
goto(`/project/${project.id}?stage=assets`);
|
||||
}}
|
||||
>
|
||||
<Icon name="image" size={14} />
|
||||
{t('projectCard.changeCover')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="menu-item danger"
|
||||
onclick={() => {
|
||||
menuOpen = false;
|
||||
deleteOpen = true;
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<Modal bind:open={renameOpen} title={t('projectCard.renameTitle')}>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitRename();
|
||||
}}
|
||||
>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('common.title')}</span>
|
||||
<input class="field-input" bind:value={renameValue} required />
|
||||
</label>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (renameOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!renameValue.trim() || $renameMutation.isPending}
|
||||
onclick={submitRename}
|
||||
>
|
||||
{t('common.rename')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('projectCard.deleteTitle')}
|
||||
message={t('projectCard.deleteMessage', { title: project.title })}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onconfirm={() => $deleteMutation.mutate()}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.project-card {
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.project-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.project-card:hover .open-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.card-main {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-main:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.card-thumb {
|
||||
height: 170px;
|
||||
background: linear-gradient(135deg, var(--bg-elevated) 0%, #1a1625 100%);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-thumb::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(139, 92, 246, 0.12) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, rgba(59, 130, 246, 0.08) 0%, transparent 40%);
|
||||
}
|
||||
.thumb-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.card-thumb-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.card-status {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
.card-body {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-xs);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.open-arrow {
|
||||
color: var(--accent);
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
}
|
||||
.card-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 var(--space-md);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.menu-wrap {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 3;
|
||||
}
|
||||
.menu-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(10, 10, 12, 0.55);
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.project-card:hover .menu-btn,
|
||||
.menu-btn:focus-visible,
|
||||
.menu-btn[aria-expanded='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
.menu-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.menu-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
left: 0;
|
||||
min-width: 140px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.menu-item.danger {
|
||||
color: var(--error);
|
||||
}
|
||||
.menu-item.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.project-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,867 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { assetUrl, jobsApi, projects, type Job, type Scene, type Clip } from '$lib/api';
|
||||
import { estimateTargetSeconds } from '$lib/durationBudget';
|
||||
import { agentDeepLink } from '$lib/agentTasks';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
let { projectId }: Props = $props();
|
||||
const client = useQueryClient();
|
||||
let editing = $state<Scene | null>(null);
|
||||
let editOpen = $state(false);
|
||||
let highlightId = $state<number | null>(null);
|
||||
let adding = $state(false);
|
||||
let deletingId = $state<number | null>(null);
|
||||
let sceneToDelete = $state<Scene | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
const scenesQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['scenes', projectId],
|
||||
queryFn: () => projects.getScenes(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
// Shared cache key with QueueStage — clip job state shows up on scene cards
|
||||
// too. Refreshed via SSE invalidation from the project page, not polling.
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs', projectId],
|
||||
queryFn: () => jobsApi.list(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
// Cache-shared with the parent's story query — only used for the target-length hint.
|
||||
const storyQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['story', projectId],
|
||||
queryFn: () => projects.getStory(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
// Cache-shared with AssetsStage — resolves scene location chips.
|
||||
const assetsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['assets', projectId],
|
||||
queryFn: () => projects.getAssets(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
const scenes = $derived($scenesQuery.data?.scenes ?? []);
|
||||
const sceneCount = $derived(scenes.length);
|
||||
const totalClips = $derived(scenes.reduce((n, s) => n + (s.clips?.length ?? 0), 0));
|
||||
const totalSec = $derived($scenesQuery.data?.estimated_duration_sec ?? 0);
|
||||
const targetSec = $derived.by(() => {
|
||||
const target = $storyQuery.data?.project?.target_duration;
|
||||
return target ? estimateTargetSeconds(target) : 0;
|
||||
});
|
||||
|
||||
const busy = $derived(adding || deletingId != null);
|
||||
|
||||
// ── Break into shots (coverage expansion) ────────────────────────────
|
||||
let expandingIds = $state<number[]>([]);
|
||||
let expandingAll = $state(false);
|
||||
let expandedIds = $state<number[]>([]);
|
||||
|
||||
function isExpanded(scene: Scene): boolean {
|
||||
return (scene.clips?.length ?? 0) > 1 || expandedIds.includes(scene.id);
|
||||
}
|
||||
|
||||
async function breakIntoShots(scene: Scene) {
|
||||
if (expandingIds.length > 0 || expandingAll) return;
|
||||
expandingIds = [...expandingIds, scene.id];
|
||||
try {
|
||||
const res = await projects.expandClips(projectId, { scene_ids: [scene.id] });
|
||||
expandedIds = [...new Set([...expandedIds, scene.id])];
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(
|
||||
t('script.toast.breakIntoShots', {
|
||||
num: scene.order_index,
|
||||
clips: res.scenes?.[0]?.clip_count ?? '?',
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.breakFail'));
|
||||
} finally {
|
||||
expandingIds = expandingIds.filter((id) => id !== scene.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function breakAllIntoShots() {
|
||||
if (expandingIds.length > 0 || expandingAll) return;
|
||||
if (
|
||||
!window.confirm(t('script.toast.breakAllConfirm', { count: sceneCount }))
|
||||
)
|
||||
return;
|
||||
expandingAll = true;
|
||||
try {
|
||||
const res = await projects.expandClips(projectId, { all: true });
|
||||
expandedIds = scenes.map((s) => s.id);
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.boardExpanded', { count: res.total_clips }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.breakAllFail'));
|
||||
} finally {
|
||||
expandingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClip(clip: Clip, scene: Scene) {
|
||||
try {
|
||||
await projects.deleteClip(projectId, clip.id);
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.deletedShot', { num: `${scene.order_index}.${clip.order_index}` }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.deleteClipFail'));
|
||||
}
|
||||
}
|
||||
|
||||
const saveMutation = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateScene(projectId, editing!.id, {
|
||||
heading: editing!.heading,
|
||||
action: editing!.action,
|
||||
dialog: editing!.dialog,
|
||||
duration_sec: editing!.duration_sec,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editing = null;
|
||||
editOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.sceneSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.saveSceneFail'));
|
||||
},
|
||||
});
|
||||
|
||||
// Chain-from-previous toggle — persists on the scene; consumed at render time.
|
||||
let chainPendingId = $state<number | null>(null);
|
||||
async function toggleChain(scene: Scene) {
|
||||
if (chainPendingId != null) return;
|
||||
chainPendingId = scene.id;
|
||||
try {
|
||||
await projects.updateScene(projectId, scene.id, {
|
||||
chain_from_prev: !scene.chain_from_prev,
|
||||
});
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.updateSceneFail'));
|
||||
} finally {
|
||||
chainPendingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function jobForScene(sceneId: number): Job | undefined {
|
||||
const jobs = ($jobsQuery.data ?? []).filter(
|
||||
(j) => j.scene_id === sceneId && j.kind === 'video',
|
||||
);
|
||||
if (jobs.length === 0) return undefined;
|
||||
return [...jobs].sort((a, b) => b.id - a.id)[0];
|
||||
}
|
||||
|
||||
function jobForClip(clipId: number): Job | undefined {
|
||||
const jobs = ($jobsQuery.data ?? []).filter(
|
||||
(j) => (j as Job & { clip_id?: number | null }).clip_id === clipId && j.kind === 'video',
|
||||
);
|
||||
if (jobs.length === 0) return undefined;
|
||||
return [...jobs].sort((a, b) => b.id - a.id)[0];
|
||||
}
|
||||
|
||||
/** Per-scene production status — only uses fields the API actually exposes. */
|
||||
function sceneStatus(scene: Scene): { status: string; label: string } {
|
||||
const job = jobForScene(scene.id);
|
||||
if (job && (job.status === 'pending' || job.status === 'running')) {
|
||||
return { status: 'generating', label: t('script.status.rendering') };
|
||||
}
|
||||
if (job?.status === 'failed') return { status: 'failed', label: t('script.status.renderFailed') };
|
||||
if (scene.video_path || job?.status === 'done') {
|
||||
return { status: 'ready', label: t('script.status.clipReady') };
|
||||
}
|
||||
return { status: 'idle', label: t('script.status.noClip') };
|
||||
}
|
||||
|
||||
function locationName(scene: Scene): string | null {
|
||||
if (scene.location_id == null) return null;
|
||||
const locs = $assetsQuery.data?.locations ?? [];
|
||||
return locs.find((l) => l.id === scene.location_id)?.name ?? null;
|
||||
}
|
||||
|
||||
function avatarFor(char: { portrait_path: string | null; sheet_path: string | null }) {
|
||||
return assetUrl(char.portrait_path ?? char.sheet_path);
|
||||
}
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0].toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Hide a broken avatar image (portrait moved/deleted on disk), revealing initials. */
|
||||
function hideBrokenAvatar(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement | null;
|
||||
if (img) img.style.display = 'none';
|
||||
}
|
||||
|
||||
function formatClock(sec: number): string {
|
||||
const s = Math.max(0, Math.round(sec));
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${r.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function move(sceneId: number, dir: -1 | 1) {
|
||||
if (busy) return;
|
||||
const list = $scenesQuery.data?.scenes ?? [];
|
||||
const idx = list.findIndex((s) => s.id === sceneId);
|
||||
const swap = idx + dir;
|
||||
if (idx < 0 || swap < 0 || swap >= list.length) return;
|
||||
const ids = list.map((s) => s.id);
|
||||
[ids[idx], ids[swap]] = [ids[swap], ids[idx]];
|
||||
await projects.reorderScenes(projectId, ids);
|
||||
client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
}
|
||||
|
||||
/** Hand off script regeneration to a fresh, project-linked agent chat. */
|
||||
function requestRegenerate() {
|
||||
if (busy) return;
|
||||
goto(agentDeepLink(projectId, 'script'));
|
||||
}
|
||||
|
||||
function openEdit(scene: Scene) {
|
||||
editing = { ...scene };
|
||||
editOpen = true;
|
||||
}
|
||||
|
||||
async function addScene() {
|
||||
if (busy) return;
|
||||
adding = true;
|
||||
try {
|
||||
const existing = $scenesQuery.data?.scenes ?? [];
|
||||
const nextIndex = existing.length + 1;
|
||||
const created = await projects.createScene(projectId, {
|
||||
order_index: nextIndex,
|
||||
heading: `NEW SCENE ${nextIndex}`,
|
||||
action: '',
|
||||
dialog: '',
|
||||
duration_sec: 5,
|
||||
});
|
||||
// Keep order_index contiguous 1..n so Regenerate sees the real board size
|
||||
const ids = [...existing.map((s) => s.id), created.id];
|
||||
await projects.reorderScenes(projectId, ids);
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
await client.refetchQueries({ queryKey: ['scenes', projectId] });
|
||||
editing = { ...created, order_index: nextIndex, heading: `NEW SCENE ${nextIndex}` };
|
||||
editOpen = true;
|
||||
highlightId = created.id;
|
||||
await tick();
|
||||
document
|
||||
.getElementById(`scene-${created.id}`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
toast.success(t('script.toast.sceneAdded', { num: nextIndex }));
|
||||
window.setTimeout(() => {
|
||||
if (highlightId === created.id) highlightId = null;
|
||||
}, 2800);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.addSceneFail'));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestDelete(scene: Scene) {
|
||||
if (busy) return;
|
||||
sceneToDelete = scene;
|
||||
deleteOpen = true;
|
||||
}
|
||||
|
||||
async function confirmDeleteScene() {
|
||||
const scene = sceneToDelete;
|
||||
sceneToDelete = null;
|
||||
if (!scene || busy) return;
|
||||
const label = scene.heading?.trim() || `Scene #${scene.order_index}`;
|
||||
deletingId = scene.id;
|
||||
try {
|
||||
await projects.deleteScene(projectId, scene.id);
|
||||
if (editing?.id === scene.id) {
|
||||
editing = null;
|
||||
editOpen = false;
|
||||
}
|
||||
const remaining = ($scenesQuery.data?.scenes ?? [])
|
||||
.filter((s) => s.id !== scene.id)
|
||||
.map((s) => s.id);
|
||||
if (remaining.length > 0) {
|
||||
await projects.reorderScenes(projectId, remaining);
|
||||
}
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.deleted', { name: label }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.deleteFailed'));
|
||||
} finally {
|
||||
deletingId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="stage-header">
|
||||
<div>
|
||||
<h2>3. Script</h2>
|
||||
<p class="muted">
|
||||
Estimated duration: {formatClock(totalSec)}
|
||||
{#if targetSec > 0}
|
||||
/ target ~{formatClock(targetSec)}
|
||||
{/if}
|
||||
{#if sceneCount > 0}
|
||||
· {sceneCount} scenes
|
||||
{#if totalClips > 0}
|
||||
/ {totalClips} shot clips
|
||||
{/if}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="stage-actions">
|
||||
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
|
||||
<Icon name="plus" size={14} /> {t('script.addScene')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy || expandingAll || expandingIds.length > 0 || sceneCount === 0}
|
||||
loading={expandingAll}
|
||||
onclick={breakAllIntoShots}
|
||||
title={t('script.breakAllTitle')}
|
||||
>
|
||||
<Icon name="film" size={14} /> {t('script.breakAllIntoShots')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={busy} onclick={requestRegenerate}>
|
||||
<Icon name="sparkle" size={15} /> {t('script.regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="script-panel">
|
||||
{#if $scenesQuery.isLoading}
|
||||
<div class="card">{t('script.loadingScenes')}</div>
|
||||
{:else if sceneCount === 0}
|
||||
<EmptyState
|
||||
title={t('script.noScenesTitle')}
|
||||
body={t('script.noScenesBody')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Icon name="script" size={28} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
|
||||
<Icon name="plus" size={14} /> {t('script.addScene')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
{#each scenes as scene, i (scene.id)}
|
||||
{@const st = sceneStatus(scene)}
|
||||
{@const locName = locationName(scene)}
|
||||
<article
|
||||
id="scene-{scene.id}"
|
||||
class="card scene"
|
||||
class:fresh={highlightId === scene.id}
|
||||
class:deleting={deletingId === scene.id}
|
||||
>
|
||||
<div class="scene-head">
|
||||
<div class="scene-title">
|
||||
<span class="grip" aria-hidden="true"><Icon name="drag" size={14} /></span>
|
||||
<span class="num">#{scene.order_index}</span>
|
||||
<strong>{scene.heading || t('script.untitledScene')}</strong>
|
||||
{#if highlightId === scene.id}
|
||||
<span class="fresh-tag">{t('script.justAdded')}</span>
|
||||
{/if}
|
||||
<StatusChip status={st.status} label={st.label} />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('script.moveSceneUp')}
|
||||
title={t('script.moveSceneUp')}
|
||||
disabled={busy || i === 0}
|
||||
onclick={() => move(scene.id, -1)}
|
||||
>
|
||||
<Icon name="chevron-up" size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('script.moveSceneDown')}
|
||||
title={t('script.moveSceneDown')}
|
||||
disabled={busy || i === sceneCount - 1}
|
||||
onclick={() => move(scene.id, 1)}
|
||||
>
|
||||
<Icon name="chevron-down" size={15} />
|
||||
</button>
|
||||
<Button variant="secondary" size="sm" disabled={busy} onclick={() => openEdit(scene)}>
|
||||
{t('script.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
loading={deletingId === scene.id}
|
||||
onclick={() => requestDelete(scene)}
|
||||
>
|
||||
{t('script.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if scene.action}
|
||||
<p class="muted">{scene.action}</p>
|
||||
{:else}
|
||||
<p class="muted empty-line">{t('script.noActionYet')}</p>
|
||||
{/if}
|
||||
{#if scene.dialog}
|
||||
<pre class="dialog">{scene.dialog}</pre>
|
||||
{/if}
|
||||
<div class="clip-block">
|
||||
<div class="clip-head">
|
||||
<span class="clip-title">
|
||||
<Icon name="film" size={12} />
|
||||
{(scene.clips ?? []).length}
|
||||
{((scene.clips ?? []).length === 1 ? t('script.shotClipSingular') : t('script.shotClipPlural', { n: (scene.clips ?? []).length }))}
|
||||
{#if (scene.clips ?? []).some((c) => c.clip_path)}
|
||||
· {t('script.renderedCount', { count: (scene.clips ?? []).filter((c) => c.clip_path).length })}
|
||||
{/if}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={busy || expandingIds.length > 0 || expandingAll}
|
||||
loading={expandingIds.includes(scene.id)}
|
||||
onclick={() => breakIntoShots(scene)}
|
||||
title={isExpanded(scene) ? t('script.rebreakTitle') : t('script.breakTitle')}
|
||||
>
|
||||
{isExpanded(scene) ? t('script.rebreak') : t('script.break')}
|
||||
</Button>
|
||||
</div>
|
||||
{#if isExpanded(scene) && (scene.clips ?? []).length > 1}
|
||||
<ul class="clip-list">
|
||||
{#each scene.clips ?? [] as clip (clip.id)}
|
||||
{@const clipJob = jobForClip(clip.id)}
|
||||
<li class="clip-row">
|
||||
<span class="clip-num" class:ready={Boolean(clip.clip_path)}>
|
||||
#{scene.order_index}.{clip.order_index}
|
||||
</span>
|
||||
<span class="clip-desc">{clip.description || t('script.untitledShot')}</span>
|
||||
{#if clip.shot_size}
|
||||
<span class="chip">{clip.shot_size}</span>
|
||||
{/if}
|
||||
{#if clipJob && (clipJob.status === 'pending' || clipJob.status === 'running')}
|
||||
<span class="chip chip-render">{t('script.rendering')}</span>
|
||||
{:else if clip.clip_path}
|
||||
<span class="chip chip-ok">✓ {t('script.clipDone')}</span>
|
||||
{/if}
|
||||
{#if clip.duration_sec}
|
||||
<span class="clip-dur">{clip.duration_sec}s</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('script.deleteShotAria', { id: `${scene.order_index}.${clip.order_index}` })}
|
||||
title={t('script.deleteShotTitle')}
|
||||
onclick={() => deleteClip(clip, scene)}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="chips">
|
||||
{#each scene.characters ?? [] as c (c.id)}
|
||||
{@const avatar = avatarFor(c)}
|
||||
<span class="chip">
|
||||
<span class="avatar">
|
||||
<span class="initials" aria-hidden="true">{initialsOf(c.name)}</span>
|
||||
{#if avatar}
|
||||
<img src={avatar} alt="" loading="lazy" onerror={hideBrokenAvatar} />
|
||||
{/if}
|
||||
</span>
|
||||
{c.name}
|
||||
</span>
|
||||
{/each}
|
||||
{#if locName}
|
||||
<span class="chip"><Icon name="folder" size={12} /> {locName}</span>
|
||||
{/if}
|
||||
{#if scene.duration_sec}
|
||||
<span class="chip"><Icon name="clock" size={12} /> {formatClock(scene.duration_sec)}</span>
|
||||
{/if}
|
||||
{#if i > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="chip chip-toggle"
|
||||
class:chip-on={Boolean(scene.chain_from_prev)}
|
||||
disabled={chainPendingId === scene.id}
|
||||
title={t('script.chainTitle')}
|
||||
onclick={() => toggleChain(scene)}
|
||||
>
|
||||
<Icon name="film" size={12} />
|
||||
{Boolean(scene.chain_from_prev) ? t('script.chainOn') : t('script.chainOff')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
bind:open={editOpen}
|
||||
title={editing ? t('script.editSceneNumbered', { n: editing.order_index }) : t('script.editScene')}
|
||||
onclose={() => (editing = null)}
|
||||
>
|
||||
{#if editing}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.headingField')}</span>
|
||||
<input class="field-input" bind:value={editing.heading} placeholder={t('script.headingPlaceholder')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.actionField')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={editing.action}
|
||||
rows="4"
|
||||
placeholder={t('script.actionPlaceholder')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.dialogField')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={editing.dialog}
|
||||
rows="4"
|
||||
placeholder={t('script.dialogPlaceholder')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.durationField')}</span>
|
||||
<input class="field-input" type="number" bind:value={editing.duration_sec} min="1" />
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (editOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={$saveMutation.isPending}
|
||||
onclick={() => $saveMutation.mutate()}
|
||||
>
|
||||
{t('script.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('script.deleteSceneTitle')}
|
||||
message={sceneToDelete
|
||||
? t('script.deleteSceneMessage', {
|
||||
heading: sceneToDelete.heading?.trim() || t('script.sceneNumber', { n: sceneToDelete.order_index }),
|
||||
})
|
||||
: ''}
|
||||
confirmLabel={t('script.delete')}
|
||||
danger
|
||||
onconfirm={() => void confirmDeleteScene()}
|
||||
oncancel={() => (sceneToDelete = null)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.stage-header {
|
||||
/* Pins inside the stage scroll container so Add Scene / Regenerate
|
||||
stay reachable while scrolling long scripts. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
padding: 8px 0 12px;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.stage-header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stage-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.script-panel {
|
||||
position: relative;
|
||||
min-height: 220px;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: var(--space-md);
|
||||
scroll-margin-top: 84px;
|
||||
scroll-margin-bottom: 24px;
|
||||
}
|
||||
.scene {
|
||||
transition:
|
||||
border-color 0.25s ease,
|
||||
box-shadow 0.25s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
.scene.fresh {
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--accent),
|
||||
0 0 24px var(--accent-glow);
|
||||
animation: scene-in 0.45s ease-out;
|
||||
}
|
||||
.scene.deleting {
|
||||
opacity: 0.45;
|
||||
}
|
||||
@keyframes scene-in {
|
||||
from {
|
||||
opacity: 0.35;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scene.fresh {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.scene-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.scene-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.grip {
|
||||
display: inline-flex;
|
||||
color: var(--text-muted);
|
||||
cursor: grab;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.num {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.fresh-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
background: var(--accent-glow);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.icon-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.empty-line {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dialog {
|
||||
background: var(--bg-elevated);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.chip-toggle {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.chip-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: #52525b;
|
||||
}
|
||||
.chip-toggle:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.chip-on {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-elevated));
|
||||
}
|
||||
.clip-block {
|
||||
margin-top: 10px;
|
||||
border-top: 1px dashed var(--border);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.clip-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.clip-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.clip-list {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.clip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.clip-num {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.clip-num.ready {
|
||||
color: var(--accent);
|
||||
}
|
||||
.clip-desc {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.clip-dur {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-ok {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.chip-render {
|
||||
color: var(--warning, #eab308);
|
||||
border-color: var(--warning, #eab308);
|
||||
}
|
||||
.avatar {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.avatar img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.initials {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,947 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
projects,
|
||||
type Beat,
|
||||
type Character,
|
||||
type Item,
|
||||
type Location,
|
||||
type StoryData,
|
||||
} from '$lib/api';
|
||||
import {
|
||||
buildStoryDraftPromptPreview,
|
||||
estimateTargetSeconds,
|
||||
recommendBeatCount,
|
||||
recommendSceneCount,
|
||||
} from '$lib/durationBudget';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { agentDeepLink } from '$lib/agentTasks';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
story: StoryData;
|
||||
configured: boolean;
|
||||
onGoAssets?: () => void;
|
||||
}
|
||||
|
||||
let { projectId, story, configured, onGoAssets }: Props = $props();
|
||||
const client = useQueryClient();
|
||||
|
||||
const GENRES = [
|
||||
'Adventure / Mystery',
|
||||
'Drama',
|
||||
'Sci-Fi',
|
||||
'Fantasy',
|
||||
'Horror',
|
||||
'Romance',
|
||||
'Thriller',
|
||||
];
|
||||
const LENGTHS_HINT = $derived(t('story.lengthsHint'));
|
||||
const TONES = [
|
||||
'Cinematic, atmospheric',
|
||||
'Dark, tense',
|
||||
'Whimsical, warm',
|
||||
'Gritty, realistic',
|
||||
'Epic, sweeping',
|
||||
];
|
||||
|
||||
const GENRE_KEYS: Record<string, string> = {
|
||||
'Adventure / Mystery': 'genres.adventure',
|
||||
Drama: 'genres.drama',
|
||||
'Sci-Fi': 'genres.scifi',
|
||||
Fantasy: 'genres.fantasy',
|
||||
Horror: 'genres.horror',
|
||||
Romance: 'genres.romance',
|
||||
Thriller: 'genres.thriller',
|
||||
};
|
||||
const TONE_KEYS: Record<string, string> = {
|
||||
'Cinematic, atmospheric': 'tones.cinematic',
|
||||
'Dark, tense': 'tones.dark',
|
||||
'Whimsical, warm': 'tones.whimsical',
|
||||
'Gritty, realistic': 'tones.gritty',
|
||||
'Epic, sweeping': 'tones.epic',
|
||||
};
|
||||
function genreLabel(v: string): string {
|
||||
return t(GENRE_KEYS[v] ?? v);
|
||||
}
|
||||
function toneLabel(v: string): string {
|
||||
return t(TONE_KEYS[v] ?? v);
|
||||
}
|
||||
|
||||
const EXAMPLE = {
|
||||
idea: 'A lone cartographer discovers a map that rewrites itself every midnight, leading her into a forgotten city beneath the desert.',
|
||||
genre: 'Adventure / Mystery',
|
||||
tone: 'Cinematic, atmospheric',
|
||||
target_duration: '2 minutes',
|
||||
};
|
||||
|
||||
let editingBeat = $state<Beat | null>(null);
|
||||
let editingChar = $state<Character | null>(null);
|
||||
let editingLoc = $state<Location | null>(null);
|
||||
let editingItem = $state<Item | null>(null);
|
||||
let beatModalOpen = $state(false);
|
||||
let charModalOpen = $state(false);
|
||||
let locModalOpen = $state(false);
|
||||
let itemModalOpen = $state(false);
|
||||
let confirmExampleOpen = $state(false);
|
||||
let deletingBeat = $state<Beat | null>(null);
|
||||
let beatDeleteOpen = $state(false);
|
||||
let addingBeat = $state(false);
|
||||
// Static defaults — the $effect below syncs all drafts from the story prop.
|
||||
let ideaDraft = $state('');
|
||||
let genreDraft = $state(GENRES[0]);
|
||||
let toneDraft = $state(TONES[0]);
|
||||
let lengthDraft = $state('2 minutes');
|
||||
let showDraftPrompt = $state(false);
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
let saveState = $state<SaveState>('idle');
|
||||
let savedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const beatBudget = $derived(recommendBeatCount(lengthDraft));
|
||||
const sceneBudget = $derived(recommendSceneCount(lengthDraft));
|
||||
const secsBudget = $derived(estimateTargetSeconds(lengthDraft));
|
||||
const draftPromptPreview = $derived(
|
||||
buildStoryDraftPromptPreview({
|
||||
title: story.project.title,
|
||||
idea: ideaDraft,
|
||||
genre: genreDraft,
|
||||
tone: toneDraft,
|
||||
targetDuration: lengthDraft,
|
||||
}),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
ideaDraft = story.project.idea ?? '';
|
||||
genreDraft = story.project.genre || GENRES[0];
|
||||
toneDraft = story.project.tone || TONES[0];
|
||||
lengthDraft = story.project.target_duration || '2 minutes';
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (savedTimer) clearTimeout(savedTimer);
|
||||
};
|
||||
});
|
||||
|
||||
function armSavedTimer() {
|
||||
if (savedTimer) clearTimeout(savedTimer);
|
||||
savedTimer = setTimeout(() => {
|
||||
if (saveState === 'saved') saveState = 'idle';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const saveProject = createMutation({
|
||||
mutationFn: (payload: {
|
||||
idea?: string;
|
||||
genre?: string;
|
||||
tone?: string;
|
||||
target_duration?: string;
|
||||
}) => projects.update(projectId, payload),
|
||||
onMutate: () => {
|
||||
saveState = 'saving';
|
||||
},
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
saveState = 'saved';
|
||||
armSavedTimer();
|
||||
},
|
||||
onError: (err) => {
|
||||
saveState = 'error';
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveSettingsFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
/** Autosave path — returns false on failure (mutation onError already toasted). */
|
||||
async function persistSettings(): Promise<boolean> {
|
||||
try {
|
||||
await $saveProject.mutateAsync({
|
||||
idea: ideaDraft,
|
||||
genre: genreDraft || undefined,
|
||||
tone: toneDraft || undefined,
|
||||
target_duration: lengthDraft || undefined,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExample() {
|
||||
ideaDraft = EXAMPLE.idea;
|
||||
genreDraft = EXAMPLE.genre;
|
||||
toneDraft = EXAMPLE.tone;
|
||||
lengthDraft = EXAMPLE.target_duration;
|
||||
const saved = await persistSettings();
|
||||
if (saved) toast.success(t('story.exampleLoaded'));
|
||||
}
|
||||
|
||||
function requestLoadExample() {
|
||||
if (ideaDraft.trim()) confirmExampleOpen = true;
|
||||
else void loadExample();
|
||||
}
|
||||
|
||||
/** Hand off to a fresh, project-linked agent chat with the composer pre-filled. */
|
||||
async function draftStoryline() {
|
||||
if (!configured) return;
|
||||
const saved = await persistSettings();
|
||||
if (!saved) return; // persistSettings already toasted the failure
|
||||
goto(agentDeepLink(projectId, 'story'));
|
||||
}
|
||||
|
||||
function openBeat(beat: Beat) {
|
||||
editingBeat = { ...beat };
|
||||
beatModalOpen = true;
|
||||
}
|
||||
|
||||
async function addBeat() {
|
||||
if (addingBeat) return;
|
||||
addingBeat = true;
|
||||
try {
|
||||
const nextIndex =
|
||||
story.beats.reduce((max, b) => Math.max(max, b.order_index), 0) + 1;
|
||||
// No api.ts binding exists yet for POST /beats — call the endpoint directly.
|
||||
const res = await fetch(`/api/projects/${projectId}/beats`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
order_index: nextIndex,
|
||||
title: t('story.newBeat', { n: nextIndex }),
|
||||
description: '',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => 'unknown error');
|
||||
throw new Error(`${res.status}: ${body}`);
|
||||
}
|
||||
const created = (await res.json()) as Beat;
|
||||
await client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.beatAdded', { index: nextIndex }));
|
||||
openBeat(created);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('story.addBeatFailed'));
|
||||
} finally {
|
||||
addingBeat = false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestDeleteBeat(beat: Beat) {
|
||||
deletingBeat = beat;
|
||||
beatDeleteOpen = true;
|
||||
}
|
||||
|
||||
const deleteBeatMutation = createMutation({
|
||||
mutationFn: (beatId: number) => projects.deleteBeat(projectId, beatId),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.beatDeleted'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.deleteBeatFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveBeat = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateBeat(projectId, editingBeat!.id, {
|
||||
title: editingBeat!.title,
|
||||
description: editingBeat!.description,
|
||||
order_index: editingBeat!.order_index,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingBeat = null;
|
||||
beatModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.beatSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveBeatFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveChar = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateCharacter(projectId, editingChar!.id, {
|
||||
name: editingChar!.name,
|
||||
role: editingChar!.role,
|
||||
age: editingChar!.age,
|
||||
appearance: editingChar!.appearance,
|
||||
personality: editingChar!.personality,
|
||||
consistency_prompt: editingChar!.consistency_prompt,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingChar = null;
|
||||
charModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.characterSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveCharacterFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveLoc = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateLocation(projectId, editingLoc!.id, {
|
||||
name: editingLoc!.name,
|
||||
description: editingLoc!.description,
|
||||
consistency_prompt: editingLoc!.consistency_prompt,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingLoc = null;
|
||||
locModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.locationSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveLocationFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveItem = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateItem(projectId, editingItem!.id, {
|
||||
name: editingItem!.name,
|
||||
description: editingItem!.description,
|
||||
consistency_prompt: editingItem!.consistency_prompt,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingItem = null;
|
||||
itemModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.itemSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveItemFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
function goToAssets() {
|
||||
goto('?stage=assets', { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
function beatCountLabel(n: number): string {
|
||||
return t('story.beats', { n });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet saveIndicator()}
|
||||
{#if saveState === 'saving'}
|
||||
<span class="save-ind"><Spinner size="sm" /> {t('common.saving')}</span>
|
||||
{:else if saveState === 'saved'}
|
||||
<span class="save-ind saved"><Icon name="check" size={13} /> {t('common.saved')}</span>
|
||||
{:else if saveState === 'error'}
|
||||
<span class="save-ind err">
|
||||
<Icon name="alert" size={13} /> {t('common.saveFailed')}
|
||||
<Button variant="ghost" size="sm" onclick={() => void persistSettings()}>{t('common.retry')}</Button>
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<header class="stage-header">
|
||||
<h2>{t('story.stageTitle')}</h2>
|
||||
<div class="stage-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={$saveProject.isPending}
|
||||
onclick={requestLoadExample}
|
||||
>
|
||||
{t('story.loadExample')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!configured} onclick={() => void draftStoryline()}>
|
||||
<Icon name="sparkle" size={15} /> {t('story.draftStoryline')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if !configured}
|
||||
<div class="banner">{t('story.llmBanner')}</div>
|
||||
{/if}
|
||||
|
||||
<div class="stack-col">
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.ideaTitle')}</h3>
|
||||
{@render saveIndicator()}
|
||||
{/snippet}
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={ideaDraft}
|
||||
rows="5"
|
||||
placeholder={t('story.ideaPlaceholder')}
|
||||
onblur={() => void persistSettings()}
|
||||
></textarea>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.settingsTitle')}</h3>
|
||||
{@render saveIndicator()}
|
||||
{/snippet}
|
||||
<div class="grid-3">
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.genre')}</span>
|
||||
<select
|
||||
class="field-select"
|
||||
bind:value={genreDraft}
|
||||
onchange={() => void persistSettings()}
|
||||
>
|
||||
{#each GENRES as g}
|
||||
<option value={g}>{genreLabel(g)}</option>
|
||||
{/each}
|
||||
{#if genreDraft && !GENRES.includes(genreDraft)}
|
||||
<option value={genreDraft}>{genreLabel(genreDraft)}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.targetLength')}</span>
|
||||
<input
|
||||
class="field-input"
|
||||
type="text"
|
||||
bind:value={lengthDraft}
|
||||
placeholder={LENGTHS_HINT}
|
||||
onblur={() => void persistSettings()}
|
||||
/>
|
||||
<span class="field-hint beat-hint">
|
||||
{t('story.lengthHint', { secs: secsBudget, beats: beatBudget, scenes: sceneBudget })}
|
||||
</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.tone')}</span>
|
||||
<select
|
||||
class="field-select"
|
||||
bind:value={toneDraft}
|
||||
onchange={() => void persistSettings()}
|
||||
>
|
||||
{#each TONES as tone}
|
||||
<option value={tone}>{toneLabel(tone)}</option>
|
||||
{/each}
|
||||
{#if toneDraft && !TONES.includes(toneDraft)}
|
||||
<option value={toneDraft}>{toneLabel(toneDraft)}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.draftPromptTitle')}</h3>
|
||||
<Button variant="ghost" size="sm" onclick={() => (showDraftPrompt = !showDraftPrompt)}>
|
||||
{showDraftPrompt ? t('story.hidePrompt') : t('story.showPrompt')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#if showDraftPrompt}
|
||||
<pre class="prompt-preview">{draftPromptPreview}</pre>
|
||||
<p class="field-hint">
|
||||
{t('story.promptExplain')}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="field-hint prompt-collapsed">
|
||||
{t('story.promptCollapsedHint')}
|
||||
</p>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.beatsTitle')}</h3>
|
||||
<div class="head-actions">
|
||||
{#if story.beats.length}
|
||||
<StatusChip status="ready" label={beatCountLabel(story.beats.length)} />
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
|
||||
<Icon name="plus" size={14} /> {t('story.addBeat')}
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#if story.beats.length === 0}
|
||||
<EmptyState
|
||||
title={t('story.noBeats')}
|
||||
body={t('story.noBeatsBody')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Icon name="story" size={28} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
|
||||
<Icon name="plus" size={14} /> {t('story.addBeatManual')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="beat-list">
|
||||
{#each story.beats as beat (beat.id)}
|
||||
<div class="beat-row">
|
||||
<button type="button" class="beat" onclick={() => openBeat(beat)}>
|
||||
<div class="beat-number">{beat.order_index}</div>
|
||||
<div class="beat-body">
|
||||
<h4>{beat.title}</h4>
|
||||
<p>{beat.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
<div class="beat-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={t('story.deleteBeat', { index: beat.order_index })}
|
||||
onclick={() => requestDeleteBeat(beat)}
|
||||
>
|
||||
<Icon name="trash" size={14} /><span class="sr-only">{t('story.deleteBeat', { index: beat.order_index })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="beats-footer">
|
||||
<Button variant="primary" onclick={goToAssets}>
|
||||
{t('story.continueAssets')} <Icon name="chevron-right" size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
{#if story.characters.length || story.locations.length || story.items.length}
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.extracted')}</h3>
|
||||
<Button variant="ghost" size="sm" onclick={goToAssets}>
|
||||
{t('story.openAssets')} <Icon name="chevron-right" size={14} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="chips">
|
||||
{#each story.characters as char (char.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => {
|
||||
editingChar = { ...char };
|
||||
charModalOpen = true;
|
||||
}}
|
||||
title={char.appearance ?? ''}
|
||||
>
|
||||
<span class="chip-tag">{t('story.charTag')}</span>
|
||||
{char.name}
|
||||
</button>
|
||||
{/each}
|
||||
{#each story.locations as loc (loc.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => {
|
||||
editingLoc = { ...loc };
|
||||
locModalOpen = true;
|
||||
}}
|
||||
title={loc.description ?? ''}
|
||||
>
|
||||
<span class="chip-tag">{t('story.envTag')}</span>
|
||||
{loc.name}
|
||||
</button>
|
||||
{/each}
|
||||
{#each story.items as item (item.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => {
|
||||
editingItem = { ...item };
|
||||
itemModalOpen = true;
|
||||
}}
|
||||
title={item.description ?? ''}
|
||||
>
|
||||
<span class="chip-tag">{t('story.itemTag')}</span>
|
||||
{item.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:open={beatModalOpen} title={t('story.editBeatTitle')} onclose={() => (editingBeat = null)}>
|
||||
{#if editingBeat}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldTitle')}</span>
|
||||
<input class="field-input" bind:value={editingBeat.title} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldDescription')}</span>
|
||||
<textarea class="field-textarea" rows="4" bind:value={editingBeat.description}></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (beatModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveBeat.isPending} onclick={() => $saveBeat.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={charModalOpen} title={t('story.editCharTitle')} onclose={() => (editingChar = null)}>
|
||||
{#if editingChar}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldName')}</span>
|
||||
<input class="field-input" bind:value={editingChar.name} placeholder={t('story.fieldName')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldRole')}</span>
|
||||
<input class="field-input" bind:value={editingChar.role} placeholder={t('story.fieldRole')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldAge')}</span>
|
||||
<input class="field-input" bind:value={editingChar.age} placeholder={t('story.fieldAge')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldAppearance')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
bind:value={editingChar.appearance}
|
||||
placeholder={t('story.fieldAppearance')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldPersonality')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingChar.personality}
|
||||
placeholder={t('story.fieldPersonality')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldConsistency')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingChar.consistency_prompt}
|
||||
placeholder={t('story.fieldConsistency')}
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (charModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveChar.isPending} onclick={() => $saveChar.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={locModalOpen} title={t('story.editLocTitle')} onclose={() => (editingLoc = null)}>
|
||||
{#if editingLoc}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldName')}</span>
|
||||
<input class="field-input" bind:value={editingLoc.name} placeholder={t('story.fieldName')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldDescription')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
bind:value={editingLoc.description}
|
||||
placeholder={t('story.fieldDescription')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldConsistency')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingLoc.consistency_prompt}
|
||||
placeholder={t('story.fieldConsistency')}
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (locModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveLoc.isPending} onclick={() => $saveLoc.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={itemModalOpen} title={t('story.editItemTitle')} onclose={() => (editingItem = null)}>
|
||||
{#if editingItem}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldName')}</span>
|
||||
<input class="field-input" bind:value={editingItem.name} placeholder={t('story.fieldName')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldDescription')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
bind:value={editingItem.description}
|
||||
placeholder={t('story.fieldDescription')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldConsistency')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingItem.consistency_prompt}
|
||||
placeholder={t('story.fieldConsistency')}
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (itemModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveItem.isPending} onclick={() => $saveItem.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmExampleOpen}
|
||||
title={t('story.confirmExampleTitle')}
|
||||
message={t('story.confirmExampleMsg')}
|
||||
confirmLabel={t('story.loadExample')}
|
||||
onconfirm={() => void loadExample()}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={beatDeleteOpen}
|
||||
title={t('story.confirmDeleteBeatTitle')}
|
||||
message={deletingBeat
|
||||
? t('story.confirmDeleteBeatMsg', { index: deletingBeat.order_index, title: deletingBeat.title })
|
||||
: ''}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onconfirm={() => {
|
||||
const id = deletingBeat?.id;
|
||||
deletingBeat = null;
|
||||
if (id != null) $deleteBeatMutation.mutate(id);
|
||||
}}
|
||||
oncancel={() => (deletingBeat = null)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.stage-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.stage-header h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stage-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.banner {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
color: var(--warning);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stack-col {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.card-h {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.save-ind {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.save-ind.saved {
|
||||
color: var(--success);
|
||||
animation: saved-fade 2s ease forwards;
|
||||
}
|
||||
.save-ind.err {
|
||||
color: var(--error);
|
||||
}
|
||||
@keyframes saved-fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
12% {
|
||||
opacity: 1;
|
||||
}
|
||||
78% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.grid-3 .field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.beat-hint {
|
||||
display: block;
|
||||
}
|
||||
.beat-hint strong {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.prompt-preview {
|
||||
margin: 0 0 var(--space-sm);
|
||||
padding: 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
color: var(--text-secondary);
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
.prompt-collapsed {
|
||||
margin: 0;
|
||||
}
|
||||
.beat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.beat-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.beat {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: var(--space-md);
|
||||
align-items: flex-start;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.beat:hover {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.beat:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.beat-actions {
|
||||
flex-shrink: 0;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.beat-number {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.beat-body h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.beat-body p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.beats-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-md);
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.chip-tag {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -37,8 +37,13 @@
|
||||
<span class="dot"></span>
|
||||
<Icon name={kindIcon[toast.kind]} size={15} />
|
||||
</span>
|
||||
<span class="msg">{toast.message}</span>
|
||||
<button
|
||||
<span class="msg">{toast.message}</span>
|
||||
{#if toast.action}
|
||||
<button type="button" class="action" onclick={() => (toast.action?.run(), dismissToast(toast.id))}>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="dismiss"
|
||||
aria-label={t('toast.dismiss')}
|
||||
@@ -52,15 +57,17 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* §四.5 Toast:右下角堆叠,220ms 上移入场;成功绿点 / 失败红点。 */
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
/* Above every overlay, incl. the 10000 lightbox, so feedback stays visible. */
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
max-width: min(400px, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
@@ -69,23 +76,10 @@
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
animation: toast-in 0.22s ease-out;
|
||||
}
|
||||
.toast.success {
|
||||
border-color: rgba(34, 197, 94, 0.45);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.45),
|
||||
0 0 0 1px rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.toast.error {
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
.toast.info {
|
||||
border-color: rgba(59, 130, 246, 0.45);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--float-bg);
|
||||
box-shadow: var(--shadow-float);
|
||||
animation: toast-in var(--d-enter) var(--ease-out);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
@@ -101,21 +95,44 @@
|
||||
background: currentColor;
|
||||
}
|
||||
.toast.success .badge {
|
||||
color: var(--success);
|
||||
color: var(--ok);
|
||||
}
|
||||
.toast.error .badge {
|
||||
color: var(--error);
|
||||
color: var(--err);
|
||||
}
|
||||
.toast.info .badge {
|
||||
color: var(--info);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.msg {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* §四.5: Toast 动作(如撤销)为金色文字 */
|
||||
.action {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.action:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
}
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.dismiss {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -127,10 +144,12 @@
|
||||
padding: 0 2px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
.dismiss:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -139,7 +158,7 @@
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -151,10 +170,12 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
:global(:root[data-reduce='1']) .toast {
|
||||
animation: none;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.toast-host {
|
||||
top: auto;
|
||||
bottom: 20px;
|
||||
bottom: calc(var(--tabbar-h) + var(--sp-5) + env(safe-area-inset-bottom));
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
max-width: none;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,827 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import {
|
||||
apiErrorMessage,
|
||||
assetUrl,
|
||||
playgroundApi,
|
||||
jobsApi,
|
||||
workflows,
|
||||
type ComfyEndpoint,
|
||||
type Job,
|
||||
type Workflow,
|
||||
} from '$lib/api';
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ProgressBar from '$lib/components/ui/ProgressBar.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import { mediaFileProblem, type MediaSlotKind } from '$lib/comfy/imageFile';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 测试工作流 (HEL-643 L3, E 批抽屉化): fill the workflow's REAL input
|
||||
* slots, submit one REAL run to the chosen ComfyUI through the ordinary
|
||||
* job queue, and call it a pass only when at least one downloadable
|
||||
* output of the right media kind came back. Test artifacts are isolated
|
||||
* from projects. Presentation follows HEL-649 §3.8: right-side drawer,
|
||||
* cyan progress + mono stage log while running, result thumbnails in a
|
||||
* horizontal row.
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
workflow: Workflow | null;
|
||||
endpoints: ComfyEndpoint[];
|
||||
onchanged?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
workflow,
|
||||
endpoints,
|
||||
onchanged,
|
||||
}: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
let endpointId = $state('');
|
||||
let values = $state<Record<string, string | number>>({});
|
||||
let listValues = $state<Record<string, string[]>>({});
|
||||
let sizes = $state<Record<string, number>>({});
|
||||
let uploadingName = $state<Record<string, string>>({});
|
||||
let rejects = $state<Record<string, { name: string; message: string }[]>>({});
|
||||
let submitError = $state('');
|
||||
let confirmOpen = $state(false);
|
||||
let submitting = $state(false);
|
||||
let testJobId = $state<number | null>(null);
|
||||
let errorExpanded = $state(false);
|
||||
let logBox = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const wf = $derived(workflow);
|
||||
const schema = $derived(wf?.input_schema ?? []);
|
||||
const listEntries = $derived(
|
||||
schema.filter(
|
||||
(i) =>
|
||||
i.kind === 'image_list' ||
|
||||
i.kind === 'video_list' ||
|
||||
i.kind === 'audio_list',
|
||||
),
|
||||
);
|
||||
const formEntries = $derived(
|
||||
schema.filter(
|
||||
(i) =>
|
||||
i.kind !== 'image_list' &&
|
||||
i.kind !== 'video_list' &&
|
||||
i.kind !== 'audio_list',
|
||||
),
|
||||
);
|
||||
const hasH3Hint = $derived(
|
||||
listEntries.some((e) => e.media_hint === 'minimax_h3_ref2va'),
|
||||
);
|
||||
const outputKind = $derived<'image' | 'video'>(
|
||||
wf?.kind === 'video' ? 'video' : 'image',
|
||||
);
|
||||
|
||||
const testsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['workflow-tests', wf?.id ?? 0],
|
||||
queryFn: () => workflows.tests(wf!.id),
|
||||
enabled: !!wf && open,
|
||||
refetchInterval: 2000,
|
||||
})),
|
||||
);
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs'],
|
||||
queryFn: () => jobsApi.list(),
|
||||
enabled: !!wf && open,
|
||||
refetchInterval: 5000,
|
||||
})),
|
||||
);
|
||||
const queuedCount = $derived(
|
||||
($jobsQuery.data ?? []).filter(
|
||||
(j) => j.status === 'pending' || j.status === 'running',
|
||||
).length,
|
||||
);
|
||||
|
||||
const history = $derived(($testsQuery.data ?? []) as Job[]);
|
||||
const activeJob = $derived.by(() => {
|
||||
if (testJobId != null) {
|
||||
const mine = history.find((j) => j.id === testJobId);
|
||||
if (mine && (mine.status === 'pending' || mine.status === 'running'))
|
||||
return mine;
|
||||
}
|
||||
return (
|
||||
history.find((j) => j.status === 'pending' || j.status === 'running') ??
|
||||
null
|
||||
);
|
||||
});
|
||||
const finishedJob = $derived.by(() => {
|
||||
if (testJobId != null)
|
||||
return history.find((j) => j.id === testJobId) ?? null;
|
||||
return null;
|
||||
});
|
||||
|
||||
const queueBusy = $derived(!!activeJob || submitting);
|
||||
const liveProgress = $derived(
|
||||
activeJob ? progressFor(activeJob.id) : undefined,
|
||||
);
|
||||
const activeInfo = $derived(activeJob ? jobStatusInfo(activeJob) : null);
|
||||
|
||||
// Rolling mono stage log (§3.8): one line per distinct worker message.
|
||||
let stageLog = $state<string[]>([]);
|
||||
$effect(() => {
|
||||
if (!activeJob || !activeInfo) {
|
||||
stageLog = [];
|
||||
return;
|
||||
}
|
||||
const line = liveProgress?.message || activeInfo.detail;
|
||||
if (!line) return;
|
||||
if (stageLog[stageLog.length - 1] !== line) stageLog = [...stageLog, line];
|
||||
});
|
||||
$effect(() => {
|
||||
stageLog.length;
|
||||
if (logBox) logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open || !wf) return;
|
||||
endpointId = wf.endpoint_id ?? '';
|
||||
values = Object.fromEntries(
|
||||
schema
|
||||
.filter((i) => i.defaultValue !== undefined && i.defaultValue !== null)
|
||||
.map((i) => [i.nodeId, i.defaultValue as string | number]),
|
||||
);
|
||||
listValues = {};
|
||||
sizes = {};
|
||||
rejects = {};
|
||||
uploadingName = {};
|
||||
submitError = '';
|
||||
errorExpanded = false;
|
||||
stageLog = [];
|
||||
testJobId =
|
||||
wf.last_test &&
|
||||
(wf.last_test.status === 'pending' || wf.last_test.status === 'running')
|
||||
? wf.last_test.id
|
||||
: null;
|
||||
});
|
||||
|
||||
function kindOf(entry: { kind: string }): MediaSlotKind {
|
||||
if (entry.kind === 'video_list') return 'video';
|
||||
if (entry.kind === 'audio_list') return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function unitOf(kind: MediaSlotKind): string {
|
||||
return kind === 'image' ? '张' : '段';
|
||||
}
|
||||
|
||||
function setRejects(key: string, next: { name: string; message: string }[]) {
|
||||
rejects = { ...rejects, [key]: next };
|
||||
}
|
||||
|
||||
async function uploadOne(file: File): Promise<string | null> {
|
||||
const res = await playgroundApi.upload(file);
|
||||
sizes = { ...sizes, [res.path]: file.size };
|
||||
return res.path;
|
||||
}
|
||||
|
||||
async function mediaAdd(entry: (typeof listEntries)[number], files: File[]) {
|
||||
const kind = kindOf(entry);
|
||||
const key = entry.nodeId;
|
||||
const max = entry.max ?? 9;
|
||||
const current = listValues[key] ?? [];
|
||||
const ok: string[] = [];
|
||||
const bad: { name: string; message: string }[] = [];
|
||||
for (const f of files) {
|
||||
const problem = mediaFileProblem(kind, f);
|
||||
if (problem) bad.push({ name: f.name, message: problem });
|
||||
else ok.push(f.name);
|
||||
}
|
||||
if (current.length + ok.length > max) {
|
||||
toast.error(
|
||||
t('studio.errRefMax', { max, n: current.length, k: ok.length }),
|
||||
);
|
||||
if (bad.length) setRejects(key, [...(rejects[key] ?? []), ...bad]);
|
||||
return;
|
||||
}
|
||||
setRejects(key, [...(rejects[key] ?? []), ...bad]);
|
||||
for (const f of files) {
|
||||
if (!ok.includes(f.name)) continue;
|
||||
uploadingName = { ...uploadingName, [key]: f.name };
|
||||
try {
|
||||
const path = await uploadOne(f);
|
||||
if (path)
|
||||
listValues = {
|
||||
...listValues,
|
||||
[key]: [...(listValues[key] ?? []), path],
|
||||
};
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
uploadingName = { ...uploadingName, [key]: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mediaReplace(
|
||||
entry: (typeof listEntries)[number],
|
||||
index: number,
|
||||
file: File,
|
||||
) {
|
||||
const kind = kindOf(entry);
|
||||
const key = entry.nodeId;
|
||||
const problem = mediaFileProblem(kind, file);
|
||||
if (problem) {
|
||||
setRejects(key, [
|
||||
...(rejects[key] ?? []),
|
||||
{ name: file.name, message: problem },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
uploadingName = { ...uploadingName, [key]: file.name };
|
||||
try {
|
||||
const path = await uploadOne(file);
|
||||
if (path) {
|
||||
const next = [...(listValues[key] ?? [])];
|
||||
next[index] = path;
|
||||
listValues = { ...listValues, [key]: next };
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
uploadingName = { ...uploadingName, [key]: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function mediaRemove(entry: (typeof listEntries)[number], index: number) {
|
||||
const key = entry.nodeId;
|
||||
listValues = {
|
||||
...listValues,
|
||||
[key]: (listValues[key] ?? []).filter((_, i) => i !== index),
|
||||
};
|
||||
}
|
||||
|
||||
/** Mirrors the backend's validate_input_values for immediate feedback. */
|
||||
function validate(): string[] {
|
||||
const errs: string[] = [];
|
||||
for (const e of schema) {
|
||||
const label = e.label;
|
||||
if (
|
||||
e.kind === 'image_list' ||
|
||||
e.kind === 'video_list' ||
|
||||
e.kind === 'audio_list'
|
||||
) {
|
||||
const n = (listValues[e.nodeId] ?? []).length;
|
||||
const unit = unitOf(kindOf(e));
|
||||
if (e.required && n < (e.min ?? 1))
|
||||
errs.push(t('wft.errListMin', { n: e.min ?? 1, unit, label }));
|
||||
} else if (e.auto && e.required && e.role === 'prompt') {
|
||||
const v = values[e.nodeId];
|
||||
if (
|
||||
!(typeof v === 'string' && v.trim()) &&
|
||||
!String(e.defaultValue ?? '').trim()
|
||||
) {
|
||||
errs.push(t('wft.errRequired', { label }));
|
||||
}
|
||||
} else if (
|
||||
e.required &&
|
||||
(e.kind === 'image' || e.kind === 'video' || e.kind === 'audio') &&
|
||||
e.role !== 'mask'
|
||||
) {
|
||||
if (!values[e.nodeId]) errs.push(t('wft.errRequired', { label }));
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
async function submitTest() {
|
||||
if (!wf) return;
|
||||
const errs = validate();
|
||||
if (errs.length) {
|
||||
submitError = errs.join(';');
|
||||
return;
|
||||
}
|
||||
submitError = '';
|
||||
confirmOpen = true;
|
||||
}
|
||||
|
||||
async function reallyRunTest() {
|
||||
if (!wf) return;
|
||||
submitting = true;
|
||||
try {
|
||||
const inputValues: Record<string, unknown> = { ...values };
|
||||
for (const [k, v] of Object.entries(listValues)) {
|
||||
if (v.length) inputValues[k] = v;
|
||||
}
|
||||
const res = await workflows.test(wf.id, {
|
||||
...(endpointId ? { endpoint_id: endpointId } : {}),
|
||||
input_values: inputValues as Record<string, string | number | string[]>,
|
||||
});
|
||||
testJobId = res.job.id;
|
||||
if (res.reused) toast.info(t('wft.reusedToast'));
|
||||
void $testsQuery.refetch();
|
||||
} catch (err) {
|
||||
submitError = apiErrorMessage(err);
|
||||
} finally {
|
||||
submitting = false;
|
||||
confirmOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelTest() {
|
||||
if (!activeJob) return;
|
||||
try {
|
||||
await jobsApi.cancel(activeJob.id);
|
||||
void $testsQuery.refetch();
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function clearRecords() {
|
||||
if (!wf) return;
|
||||
try {
|
||||
await workflows.clearTests(wf.id);
|
||||
testJobId = null;
|
||||
await $testsQuery.refetch();
|
||||
onchanged?.();
|
||||
toast.success(t('wft.cleared'));
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function resultLine(job: Job): string {
|
||||
if (job.status === 'done' && (job.output_paths?.length ?? 0) > 0) {
|
||||
return t('wft.passedShort', { n: job.output_paths.length });
|
||||
}
|
||||
if (job.error === 'cancelled') return t('wft.cancelledShort');
|
||||
return job.error ? job.error.slice(0, 120) : t('wft.failedShort');
|
||||
}
|
||||
|
||||
function timeOf(at: string | null): string {
|
||||
if (!at) return '—';
|
||||
const ts = Date.parse(at.replace(' ', 'T') + 'Z');
|
||||
const d = new Date(Number.isFinite(ts) ? ts : at);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const endpointLabel = $derived(
|
||||
endpoints.find((e) => e.id === endpointId)?.name ??
|
||||
t('conn.wfConnectionActive'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<Drawer bind:open title={t('wft.title')} width={420}>
|
||||
{#if wf}
|
||||
<div class="wft">
|
||||
<p class="wft-sub">
|
||||
{wf.name}
|
||||
<span class="mono wft-id">#{wf.id}</span>
|
||||
</p>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">{t('wft.connection')}</span>
|
||||
<select
|
||||
class="a-select full"
|
||||
bind:value={endpointId}
|
||||
disabled={queueBusy}
|
||||
>
|
||||
<option value="">{t('conn.wfConnectionActive')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="field-hint">{t('wft.costNote')}</span>
|
||||
</label>
|
||||
|
||||
{#if hasH3Hint}
|
||||
<div class="wft-note" role="note">
|
||||
<Icon name="info" size={14} />
|
||||
<p>{t('wfMedia.h3Limits')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeJob && activeInfo}
|
||||
<div class="wft-active">
|
||||
<div class="wft-active-head">
|
||||
<span class="chip run">
|
||||
<span class="chip-dot" aria-hidden="true"></span>
|
||||
{t('wft.running')}
|
||||
</span>
|
||||
<span class="wft-active-detail">{activeInfo.detail}</span>
|
||||
<Button variant="secondary" size="sm" onclick={cancelTest}
|
||||
>{t('wft.cancel')}</Button
|
||||
>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={liveProgress?.progress ?? 0}
|
||||
indeterminate={activeJob.status === 'pending' && !liveProgress}
|
||||
label={activeInfo.label}
|
||||
/>
|
||||
{#if stageLog.length}
|
||||
<div class="wft-log mono" bind:this={logBox} aria-live="polite">
|
||||
{#each stageLog as line, i (i)}
|
||||
<p>{line}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if finishedJob && finishedJob.status === 'done' && (finishedJob.output_paths?.length ?? 0) > 0}
|
||||
<div class="wft-result ok">
|
||||
<p class="wft-verdict">
|
||||
{t('wft.passed', { n: finishedJob.output_paths.length })}
|
||||
</p>
|
||||
<div class="wft-outputs">
|
||||
{#each finishedJob.output_paths as path (path)}
|
||||
<a
|
||||
class="wft-out"
|
||||
href={assetUrl(path)}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t('wft.download')}
|
||||
>
|
||||
<SafeMedia
|
||||
kind={outputKind}
|
||||
src={assetUrl(path)}
|
||||
controls={outputKind === 'video'}
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if finishedJob && finishedJob.status !== 'pending' && finishedJob.status !== 'running'}
|
||||
<div class="wft-result bad">
|
||||
<p class="wft-verdict">{t('wft.failed')}</p>
|
||||
<p class="wft-error">
|
||||
{finishedJob.error === 'cancelled'
|
||||
? t('wft.cancelledShort')
|
||||
: errorExpanded || (finishedJob.error ?? '').length <= 160
|
||||
? finishedJob.error
|
||||
: (finishedJob.error ?? '').slice(0, 160) + '…'}
|
||||
</p>
|
||||
{#if finishedJob.error && finishedJob.error !== 'cancelled' && finishedJob.error.length > 160}
|
||||
<button
|
||||
type="button"
|
||||
class="wft-expand"
|
||||
onclick={() => (errorExpanded = !errorExpanded)}
|
||||
>
|
||||
{errorExpanded ? t('wfd.hideDetails') : t('wfd.showDetails')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !queueBusy}
|
||||
<div class="wft-form">
|
||||
{#each listEntries as entry (entry.nodeId)}
|
||||
{@const kind = kindOf(entry)}
|
||||
<RefImageGrid
|
||||
label={entry.label}
|
||||
mediaKind={kind}
|
||||
paths={listValues[entry.nodeId] ?? []}
|
||||
max={entry.max ?? 9}
|
||||
{sizes}
|
||||
uploadingName={uploadingName[entry.nodeId] || undefined}
|
||||
rejects={rejects[entry.nodeId] ?? []}
|
||||
onAdd={(files) => mediaAdd(entry, files)}
|
||||
onReplace={(i, file) => mediaReplace(entry, i, file)}
|
||||
onRemove={(i) => mediaRemove(entry, i)}
|
||||
onDismissReject={(i) =>
|
||||
setRejects(
|
||||
entry.nodeId,
|
||||
(rejects[entry.nodeId] ?? []).filter((_, j) => j !== i),
|
||||
)}
|
||||
/>
|
||||
{/each}
|
||||
{#if formEntries.length}
|
||||
<ComfyDynamicForm
|
||||
inputs={formEntries}
|
||||
bind:values
|
||||
allowUpload
|
||||
showErrors={!!submitError}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if submitError}<span class="field-error" role="alert"
|
||||
>{submitError}</span
|
||||
>{/if}
|
||||
<div class="wft-actions">
|
||||
<Button variant="primary" loading={submitting} onclick={submitTest}>
|
||||
{t('wft.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="wft-history">
|
||||
<div class="wft-history-head">
|
||||
<span class="field-label"
|
||||
>{t('wft.history', { n: history.length })}</span
|
||||
>
|
||||
{#if history.length}
|
||||
<button type="button" class="link-danger" onclick={clearRecords}>
|
||||
{t('wft.clear')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if history.length === 0}
|
||||
<p class="wft-empty">{t('wft.never')}</p>
|
||||
{:else}
|
||||
<ul class="wft-rows">
|
||||
{#each history as job (job.id)}
|
||||
<li class:active={job.id === activeJob?.id}>
|
||||
<span class="mono wft-row-id">#{job.id}</span>
|
||||
<span class="wft-row-time">{timeOf(job.created_at)}</span>
|
||||
<span
|
||||
class="wft-row-result"
|
||||
class:ok={job.status === 'done' &&
|
||||
(job.output_paths?.length ?? 0) > 0}
|
||||
>
|
||||
{resultLine(job)}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Drawer>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmOpen}
|
||||
title={t('wft.confirmTitle')}
|
||||
message={t('wft.confirmBody', { endpoint: endpointLabel, n: queuedCount })}
|
||||
confirmLabel={t('wft.submit')}
|
||||
onconfirm={reallyRunTest}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.wft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wft-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-id {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.a-select.full {
|
||||
width: 100%;
|
||||
}
|
||||
.wft-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--run-soft);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--run-soft);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wft-note :global(.icon) {
|
||||
color: var(--run);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.wft-note p {
|
||||
margin: 0;
|
||||
}
|
||||
.wft-active,
|
||||
.wft-result {
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-active {
|
||||
border-color: var(--run-soft);
|
||||
}
|
||||
.wft-active-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip.run {
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.chip-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--run);
|
||||
animation: wft-breathe 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes wft-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
:global([data-reduce='1']) .chip-dot {
|
||||
animation: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chip-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.wft-active-detail {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-log {
|
||||
max-height: 110px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-log p {
|
||||
margin: 0;
|
||||
}
|
||||
.wft-result.ok {
|
||||
border-color: var(--ok);
|
||||
}
|
||||
.wft-result.bad {
|
||||
border-color: var(--err);
|
||||
}
|
||||
.wft-verdict {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.wft-result.ok .wft-verdict {
|
||||
color: var(--ok);
|
||||
}
|
||||
.wft-result.bad .wft-verdict {
|
||||
color: var(--err);
|
||||
}
|
||||
.wft-error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--err);
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
.wft-expand {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.wft-expand:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
.wft-outputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.wft-out {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: 148px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.wft-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.wft-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-history {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.wft-history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-history-head .field-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.link-danger {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--err);
|
||||
cursor: pointer;
|
||||
border-radius: var(--r-sm);
|
||||
opacity: 0.85;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.link-danger:hover {
|
||||
opacity: 1;
|
||||
background: var(--err-soft);
|
||||
}
|
||||
}
|
||||
.wft-empty {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wft-rows {
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wft-rows li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.wft-rows li.active {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.wft-row-id {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.wft-row-time {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wft-row-result {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wft-row-result.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
</style>
|
||||
@@ -357,16 +357,13 @@
|
||||
.empty-mark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--r-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
background:
|
||||
radial-gradient(circle at 30% 25%, rgba(139, 92, 246, 0.35) 0%, transparent 60%),
|
||||
var(--bg-elevated);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
box-shadow: 0 0 32px var(--accent-glow);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.empty-title {
|
||||
@@ -391,21 +388,24 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
.suggestion {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-2);
|
||||
border-radius: var(--r-pill);
|
||||
padding: var(--sp-3) var(--sp-6);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
border-color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.suggestion:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface));
|
||||
color: var(--text);
|
||||
border-color: var(--line-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.suggestion:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -430,11 +430,11 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-size: var(--fs-mini);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--agent, var(--accent));
|
||||
color: var(--agent, var(--text-2));
|
||||
margin-bottom: 4px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
@@ -443,7 +443,7 @@
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--accent)) 25%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--text-2)) 25%, transparent);
|
||||
}
|
||||
.reasoning-wrap {
|
||||
margin-bottom: 4px;
|
||||
@@ -451,28 +451,29 @@
|
||||
}
|
||||
.bubble {
|
||||
max-width: 86%;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 10px 14px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* 对话气泡按规范 §3.2/§3.6:用户 --bg-3,Agent --bg-2 + --line 描边。 */
|
||||
.user-bubble {
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--bg-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
.user-bubble.steering {
|
||||
border-style: dashed;
|
||||
}
|
||||
.steer-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-size: var(--fs-mini);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
color: var(--text-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.user-thumbs {
|
||||
@@ -486,36 +487,37 @@
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.user-thumb.file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.user-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/* 工作流提及为次级内容:中性底 + --text,不占金色。 */
|
||||
.wf-chip {
|
||||
display: inline;
|
||||
padding: 1px 7px;
|
||||
margin: 0 1px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg-elevated));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
color: var(--accent);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-4);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.assistant-bubble {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--agent, var(--accent));
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--agent, var(--text-2));
|
||||
color: var(--text);
|
||||
}
|
||||
.question-card {
|
||||
display: flex;
|
||||
@@ -525,15 +527,15 @@
|
||||
max-width: 420px;
|
||||
margin: 2px 0 6px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--accent) 7%, var(--bg-surface));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.question-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.question-options {
|
||||
@@ -542,40 +544,39 @@
|
||||
gap: 6px;
|
||||
}
|
||||
.question-option {
|
||||
border: 1px solid color-mix(in srgb, var(--agent, var(--accent)) 45%, transparent);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease;
|
||||
background-color var(--d-press) ease,
|
||||
border-color var(--d-press) ease;
|
||||
}
|
||||
.question-option:hover:enabled {
|
||||
background: color-mix(in srgb, var(--agent, var(--accent)) 18%, var(--bg-elevated));
|
||||
border-color: var(--agent, var(--accent));
|
||||
background: var(--bg-4);
|
||||
border-color: var(--agent, var(--text-2));
|
||||
}
|
||||
.question-option:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.question-option.affirmative {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.question-hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.assistant-bubble.err {
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
border-left-color: rgba(239, 68, 68, 0.6);
|
||||
color: var(--error);
|
||||
border-color: var(--err-line);
|
||||
border-left-color: var(--err);
|
||||
color: var(--err);
|
||||
}
|
||||
.bubble.live {
|
||||
display: flex;
|
||||
@@ -604,7 +605,7 @@
|
||||
width: 7px;
|
||||
height: 14px;
|
||||
margin-left: 2px;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: blink 1s step-end infinite;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
@@ -617,14 +618,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
/* 进行中指示 = 信号青(§1.3),不占金色。 */
|
||||
.pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -711,12 +711,12 @@ let {
|
||||
display: inline;
|
||||
padding: 1px 7px;
|
||||
margin: 0 1px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg-elevated));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
color: var(--accent);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-4);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -724,12 +724,12 @@ let {
|
||||
display: inline;
|
||||
padding: 1px 7px;
|
||||
margin: 0 1px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, #38bdf8 20%, var(--bg-elevated));
|
||||
border: 1px solid color-mix(in srgb, #38bdf8 45%, transparent);
|
||||
color: #7dd3fc;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-4);
|
||||
border: 1px dashed var(--line-2);
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
}
|
||||
.head:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
.head:hover {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.icon-wrap {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -135,9 +135,7 @@
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-float);
|
||||
padding: 4px;
|
||||
}
|
||||
.empty {
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
/**
|
||||
* Agent identity palette: a stable display name + accent color per agent.
|
||||
*
|
||||
* The main loop runs with `agent_name = null`; we label it "Calliope". Each
|
||||
* swarm sub-agent ("planner", "story-agent", …) gets its own fixed color so
|
||||
* differently-authored bubbles are visually distinct, plus a deterministic
|
||||
* fallback for any future agent name.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const DEFAULT_AGENT_NAME = 'Calliope';
|
||||
|
||||
// Fixed identity map (keyed by the raw agent_name the backend emits).
|
||||
const IDENTITIES: Record<string, { label: string; color: string }> = {
|
||||
planner: { label: 'Planner', color: '#22d3ee' }, // cyan
|
||||
'story-agent': { label: 'Story Agent', color: '#22c55e' }, // green
|
||||
'script-agent': { label: 'Script Agent', color: '#f59e0b' }, // amber
|
||||
'assets-agent': { label: 'Assets Agent', color: '#ec4899' }, // pink
|
||||
'video-agent': { label: 'Video Agent', color: '#fb923c' }, // orange
|
||||
'research-agent': { label: 'Research Agent', color: '#a78bfa' }, // violet
|
||||
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
|
||||
// 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' },
|
||||
@@ -26,19 +27,9 @@ const IDENTITIES: Record<string, { label: string; color: string }> = {
|
||||
镜头脚本: { label: '镜头脚本', color: '#d98e6a' },
|
||||
};
|
||||
|
||||
// Fallback palette for unknown names (stable hash → deterministic color).
|
||||
const FALLBACK_COLORS = [
|
||||
'#22d3ee',
|
||||
'#22c55e',
|
||||
'#f59e0b',
|
||||
'#ec4899',
|
||||
'#fb923c',
|
||||
'#a78bfa',
|
||||
'#38bdf8',
|
||||
'#4ade80',
|
||||
'#f472b6',
|
||||
'#facc15',
|
||||
];
|
||||
// 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'];
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 0;
|
||||
@@ -48,8 +39,8 @@ function hashString(s: string): number {
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The default "Calliope" accent (matches --accent). */
|
||||
export const DEFAULT_AGENT_COLOR = '#8b5cf6';
|
||||
/** Neutral slate (--text-2) — the default agent borrows no signal color. */
|
||||
export const DEFAULT_AGENT_COLOR = '#aab2c5';
|
||||
|
||||
export function agentDisplayName(name: string | null | undefined): string {
|
||||
const key = (name || '').trim().toLowerCase();
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
<script lang="ts">
|
||||
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 SlotTray from './SlotTray.svelte';
|
||||
import WorkflowPicker from './WorkflowPicker.svelte';
|
||||
import { chipValue, 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.
|
||||
*/
|
||||
interface Props {
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
workflows: Workflow[];
|
||||
current: Workflow | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
endpointId: string;
|
||||
conn: { state: 'ok' | 'fail' | 'untested' | 'dry'; ms?: number | null };
|
||||
errors: Record<string, string>;
|
||||
showErrors: boolean;
|
||||
block: BlockReason | null;
|
||||
changedCount: number;
|
||||
submitting: boolean;
|
||||
/** Shows 已提交 on the button for --d-flash. */
|
||||
sent: boolean;
|
||||
slotBusy?: boolean;
|
||||
promptEl?: HTMLTextAreaElement | null;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onPickWorkflow: (wf: Workflow) => void;
|
||||
onPickEndpoint: (id: string) => void;
|
||||
onSubmit: () => void;
|
||||
onReveal: () => void;
|
||||
onOpenParams: () => void;
|
||||
onFromResults: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
ds,
|
||||
values,
|
||||
workflows,
|
||||
current,
|
||||
endpoints,
|
||||
endpointId,
|
||||
conn,
|
||||
errors,
|
||||
showErrors,
|
||||
block,
|
||||
changedCount,
|
||||
submitting,
|
||||
sent,
|
||||
slotBusy = $bindable(false),
|
||||
promptEl = $bindable(null),
|
||||
onSet,
|
||||
onPickWorkflow,
|
||||
onPickEndpoint,
|
||||
onSubmit,
|
||||
onReveal,
|
||||
onOpenParams,
|
||||
onFromResults,
|
||||
}: Props = $props();
|
||||
|
||||
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'));
|
||||
|
||||
/** 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)}` });
|
||||
}
|
||||
}
|
||||
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) {
|
||||
e.preventDefault();
|
||||
dragDepth = 0;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) 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>
|
||||
<!-- 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;
|
||||
}}
|
||||
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>
|
||||
{/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}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.composer-wrap {
|
||||
padding: var(--sp-2) var(--sp-7) var(--sp-6);
|
||||
}
|
||||
.a-sprocket {
|
||||
margin: 0 var(--sp-2) var(--sp-3);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.composer:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: var(--composer-focus);
|
||||
}
|
||||
.composer.drag {
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.slots {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.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);
|
||||
font: inherit;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
field-sizing: content;
|
||||
}
|
||||
.prompt:focus-visible {
|
||||
outline: 0;
|
||||
}
|
||||
.prompt::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.prompt-err,
|
||||
.no-prompt {
|
||||
margin: 0;
|
||||
padding: 0 var(--sp-6);
|
||||
}
|
||||
.no-prompt {
|
||||
padding-top: var(--sp-5);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3) var(--sp-3);
|
||||
}
|
||||
.conn {
|
||||
position: relative;
|
||||
}
|
||||
.submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
margin-left: auto;
|
||||
}
|
||||
.why {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--warn);
|
||||
white-space: nowrap;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.why.err {
|
||||
color: var(--err);
|
||||
}
|
||||
/* 生成:按下即缩,提交后文字换成「已提交」约 --d-flash,期间仍可再次提交 */
|
||||
.gen {
|
||||
display: inline-grid;
|
||||
min-width: 84px;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 1179px) {
|
||||
.composer-wrap {
|
||||
padding: var(--sp-2) var(--sp-6) var(--sp-5);
|
||||
}
|
||||
}
|
||||
@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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { backHref, type DeskContext } from './context';
|
||||
|
||||
/**
|
||||
* 上下文条 (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.
|
||||
*/
|
||||
interface Props {
|
||||
ctx: DeskContext;
|
||||
projectTitle: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
let { ctx, projectTitle, onDismiss }: Props = $props();
|
||||
|
||||
const stageName = $derived(t(`workbench.stage.${ctx.stage}`));
|
||||
</script>
|
||||
|
||||
<div class="context-bar" role="status">
|
||||
<Icon name="link" size={14} />
|
||||
<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>
|
||||
<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} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.context-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2) var(--sp-4);
|
||||
margin: var(--sp-4) var(--sp-7) 0;
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--run-soft);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.context-bar > :global(svg) {
|
||||
color: var(--run);
|
||||
}
|
||||
.from b {
|
||||
color: var(--run);
|
||||
font-weight: 600;
|
||||
}
|
||||
.note {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.back {
|
||||
margin-left: auto;
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.context-bar {
|
||||
margin: var(--sp-4) var(--sp-6) 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.context-bar {
|
||||
margin: var(--sp-3) var(--sp-5) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,581 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from '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 './schema';
|
||||
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf, specOf } from './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 即时无动画), a finished/just-picked video auto-plays muted,
|
||||
* failed takes get 重试, and a done take whose files no longer load
|
||||
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
|
||||
*/
|
||||
interface Props {
|
||||
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;
|
||||
/** Bump to auto-play the stage video (pointer pick / completion). */
|
||||
playSignal: number;
|
||||
/** The stage <video> element, for the desk's space-bar toggle. */
|
||||
videoEl?: HTMLVideoElement | null;
|
||||
params: Snippet;
|
||||
onClose: () => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onSendToSlot: (path: string) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tab = $bindable(),
|
||||
job,
|
||||
wf,
|
||||
nowMs,
|
||||
open,
|
||||
inactive,
|
||||
canSendToSlot,
|
||||
crossfade,
|
||||
playSignal,
|
||||
videoEl = $bindable(null),
|
||||
params,
|
||||
onClose,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onSendToSlot,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
let outIndex = $state(0);
|
||||
$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 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}`;
|
||||
}
|
||||
|
||||
// 完成或点选后自动播放(静音);键盘切换不触发。
|
||||
$effect(() => {
|
||||
void playSignal;
|
||||
if (inactive || tab !== 'result' || !videoEl) return;
|
||||
videoEl.muted = true;
|
||||
videoEl.play().catch(() => {});
|
||||
});
|
||||
|
||||
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}` };
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<aside class="inspector" 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>
|
||||
</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}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
{#if tab === 'params'}
|
||||
{@render params()}
|
||||
{:else if !job || !st}
|
||||
<div class="a-stage stage"><div class="stage-empty">{t('desk.pickTake')}</div></div>
|
||||
{:else}
|
||||
<div class="a-stage stage">
|
||||
{#key mediaKey}
|
||||
<div class="stage-media" in:xfade out:xfade>
|
||||
{#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
|
||||
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)}
|
||||
/>
|
||||
</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}
|
||||
</div>
|
||||
|
||||
{#if allOutputs.length > 1}
|
||||
<div class="strip" role="group" aria-label={t('desk.outputs')}>
|
||||
{#each allOutputs as p, i (p)}
|
||||
<button
|
||||
type="button"
|
||||
class="strip-item"
|
||||
class:gone={gonePaths.includes(p)}
|
||||
aria-pressed={p === output}
|
||||
aria-label={t('desk.outputN', { n: i + 1 })}
|
||||
onclick={() => (outIndex = i)}
|
||||
>
|
||||
{#if isVideoOutput(job, p)}
|
||||
<Icon name="film" size={14} />
|
||||
{:else}
|
||||
<img src={assetUrl(p)} alt="" loading="lazy" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="meta">
|
||||
{#if dims}<span class="mono"><b>{dims}</b></span>{/if}
|
||||
{#each spec as s (s.label)}
|
||||
{#if !(dims && s.label === t('desk.specSize'))}
|
||||
<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>
|
||||
{#if endpoint}<span>{endpoint}</span>{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#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>
|
||||
{:else}
|
||||
<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')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if output}
|
||||
<a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a>
|
||||
{/if}
|
||||
{#if output && video}
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn secondary sm"
|
||||
title={t('lastFrame.hint')}
|
||||
disabled={frameBusy}
|
||||
aria-busy={frameBusy}
|
||||
onclick={exportTail}
|
||||
>
|
||||
{frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if st.tone === 'fail'}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button>
|
||||
{/if}
|
||||
{#if job.status !== 'pending' && job.status !== 'running'}
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if st.tone === 'fail'}
|
||||
<div class="note err" role="alert">{st.detail}</div>
|
||||
{:else if fileGone}
|
||||
<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>
|
||||
{/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}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-bottom: 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);
|
||||
}
|
||||
.close {
|
||||
padding: 0 var(--sp-3);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
.stage {
|
||||
aspect-ratio: 16 / 10;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.stage-media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-5);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* 相纸白边 (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;
|
||||
place-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-6);
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.stage-empty.gone {
|
||||
color: var(--warn);
|
||||
}
|
||||
.stage-empty .sub {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.stage-bar {
|
||||
width: 160px;
|
||||
}
|
||||
.strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.strip-item {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.strip-item[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.strip-item.gone {
|
||||
border-style: dashed;
|
||||
border-color: var(--warn);
|
||||
color: var(--warn);
|
||||
}
|
||||
.strip-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2) var(--sp-5);
|
||||
margin: var(--sp-5) 0;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.meta b {
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.note {
|
||||
margin-top: var(--sp-5);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--r-md);
|
||||
font-size: var(--fs-aux);
|
||||
word-break: break-word;
|
||||
}
|
||||
.note.err {
|
||||
background: var(--err-soft);
|
||||
color: var(--err);
|
||||
}
|
||||
.note.warn {
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
}
|
||||
.section-title {
|
||||
margin: var(--sp-7) 0 var(--sp-3);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.inputs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
.mini {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
object-fit: cover;
|
||||
}
|
||||
.mini.glyph {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.prompt {
|
||||
margin: 0;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 92px 1fr;
|
||||
gap: var(--sp-2) var(--sp-4);
|
||||
margin: 0;
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.kv dt {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
word-break: break-all;
|
||||
}
|
||||
.kv .wf-gone {
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ≥1180: a column of the desk grid; the close button hides it (]) */
|
||||
@media (max-width: 1179px) {
|
||||
.inspector {
|
||||
position: fixed;
|
||||
top: var(--topbar-h);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
width: min(420px, 100vw);
|
||||
box-shadow: var(--shadow-float);
|
||||
transform: translateX(100%);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform var(--d-drawer) var(--ease-drawer),
|
||||
visibility 0s linear var(--d-drawer);
|
||||
}
|
||||
.inspector.open {
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
transition:
|
||||
transform var(--d-drawer) var(--ease-drawer),
|
||||
visibility 0s;
|
||||
}
|
||||
:global(:root[data-reduce='1']) .inspector {
|
||||
transform: none;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--d-drawer) ease,
|
||||
visibility 0s linear var(--d-drawer);
|
||||
}
|
||||
:global(:root[data-reduce='1']) .inspector.open {
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity var(--d-drawer) ease,
|
||||
visibility 0s;
|
||||
}
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
.close {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.inspector {
|
||||
top: 0;
|
||||
z-index: 70;
|
||||
width: 100vw;
|
||||
border-left: 0;
|
||||
}
|
||||
.body {
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import type { ComfyDynamicInput } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { inputRole, isChanged, type FormValue, type FormValues } from './schema';
|
||||
|
||||
/**
|
||||
* One common parameter as a composer chip (spec-v2 §3.3 参数芯片): the
|
||||
* pop edits it in place — options list for selects, a number field for
|
||||
* the rest (seed adds 随机) — and 恢复原值 returns to the workflow value.
|
||||
* `inputs` holds two entries for the width × height pair.
|
||||
*/
|
||||
interface Props {
|
||||
inputs: ComfyDynamicInput[];
|
||||
values: FormValues;
|
||||
text: string;
|
||||
title?: string;
|
||||
invalid?: boolean;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
}
|
||||
|
||||
let { inputs, values, text, title, invalid = false, onSet }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
const changed = $derived(inputs.some((i) => isChanged(i, values)));
|
||||
const single = $derived(inputs.length === 1 ? inputs[0] : null);
|
||||
const uid = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
function current(inp: ComfyDynamicInput): string {
|
||||
const v = values[inp.nodeId] ?? inp.defaultValue ?? '';
|
||||
return Array.isArray(v) ? v.join(',') : String(v);
|
||||
}
|
||||
|
||||
function setNumber(inp: ComfyDynamicInput, raw: string) {
|
||||
if (raw === '') return onSet(inp.nodeId, '');
|
||||
const n = Number(raw);
|
||||
onSet(inp.nodeId, Number.isNaN(n) ? raw : n);
|
||||
}
|
||||
|
||||
function restore() {
|
||||
for (const inp of inputs) onSet(inp.nodeId, inp.defaultValue ?? '');
|
||||
}
|
||||
|
||||
function randomSeed(inp: ComfyDynamicInput) {
|
||||
const max = Math.min(inp.max ?? 2 ** 32 - 1, Number.MAX_SAFE_INTEGER);
|
||||
const min = Math.max(inp.min ?? 0, 0);
|
||||
onSet(inp.nodeId, min + Math.floor(Math.random() * (max - min + 1)));
|
||||
}
|
||||
|
||||
function pickOption(inp: ComfyDynamicInput, opt: string) {
|
||||
onSet(inp.nodeId, opt);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="param-chip">
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip"
|
||||
class:changed={changed && !invalid}
|
||||
class:err={invalid}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={title ?? inputs.map((i) => i.label).join(' × ')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span class="lbl">{text}</span>
|
||||
</button>
|
||||
<ShellPop bind:open label={inputs.map((i) => i.label).join(' × ')} placement="top" width="260px">
|
||||
{#if single?.kind === 'select' && single.options?.length}
|
||||
<div class="a-menu-group">{single.label}</div>
|
||||
{#each single.options as opt (opt)}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item"
|
||||
aria-current={current(single) === opt ? 'true' : undefined}
|
||||
onclick={() => pickOption(single, opt)}
|
||||
>
|
||||
<span class="a-menu-label">{opt}</span>
|
||||
{#if String(single.defaultValue ?? '') === opt}
|
||||
<span class="a-menu-meta">{t('desk.origValue')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="fields">
|
||||
{#each inputs as inp (inp.nodeId)}
|
||||
{@const id = `pc-${uid}-${inp.nodeId.replace(/[^a-zA-Z0-9_-]/g, '')}`}
|
||||
<div class="field-row">
|
||||
<label class="field-label" for={id}>
|
||||
{inp.label}
|
||||
{#if inp.defaultValue !== undefined && inp.defaultValue !== ''}
|
||||
<span class="orig mono">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<div class="inline">
|
||||
{#if inp.kind === 'number'}
|
||||
<input
|
||||
{id}
|
||||
class="a-input mono"
|
||||
class:invalid
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
value={current(inp)}
|
||||
oninput={(e) => setNumber(inp, e.currentTarget.value)}
|
||||
onkeydown={(e) => e.key === 'Enter' && (open = false)}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
{id}
|
||||
class="a-input"
|
||||
class:invalid
|
||||
type="text"
|
||||
value={current(inp)}
|
||||
oninput={(e) => onSet(inp.nodeId, e.currentTarget.value)}
|
||||
onkeydown={(e) => e.key === 'Enter' && (open = false)}
|
||||
/>
|
||||
{/if}
|
||||
{#if inputRole(inp) === 'seed'}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => randomSeed(inp)}>
|
||||
{t('desk.seedRandom')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if inp.min != null || inp.max != null}
|
||||
<span class="field-hint mono">{inp.min ?? '−∞'} – {inp.max ?? '∞'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if changed}
|
||||
<div class="foot">
|
||||
<button type="button" class="a-btn ghost sm" onclick={restore}>{t('desk.restoreOrig')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.param-chip {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: var(--sp-5);
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
.field-row {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.field-row .field-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin: 0;
|
||||
}
|
||||
.orig {
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
.inline {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.field-row .field-hint {
|
||||
margin: 0;
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--sp-2) var(--sp-1) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import type { DeskSchema, FormValue, FormValues } from './schema';
|
||||
|
||||
/**
|
||||
* 参数 (spec-v2 §3.3 检视区 · 参数): every non-media input the workflow
|
||||
* declares, each with its 原值 and a gold edge once changed. Common ones
|
||||
* first; the rest fold under 高级参数 — untouched, they keep the
|
||||
* workflow's own values.
|
||||
*/
|
||||
interface Props {
|
||||
workflow: Workflow | null;
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
errors: Record<string, string>;
|
||||
showErrors: boolean;
|
||||
changedCount: number;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onRestoreAll: () => void;
|
||||
}
|
||||
|
||||
let { workflow, ds, values, errors, showErrors, changedCount, onSet, onRestoreAll }: Props = $props();
|
||||
|
||||
let advancedOpen = $state(false);
|
||||
const brokenReasons = $derived(
|
||||
(workflow?.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message),
|
||||
);
|
||||
const advancedErr = $derived(showErrors && ds.advanced.some((i) => !!errors[i.nodeId]?.trim()));
|
||||
|
||||
$effect(() => {
|
||||
if (advancedErr) advancedOpen = true;
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="params">
|
||||
{#if !workflow}
|
||||
<p class="note">{t('desk.pickWorkflow')}</p>
|
||||
{:else}
|
||||
{#if brokenReasons.length}
|
||||
<div class="note err" role="alert">
|
||||
<strong>{t('studio.wfBrokenTitle')}</strong>
|
||||
<ul>
|
||||
{#each brokenReasons as r (r)}<li>{r}</li>{/each}
|
||||
</ul>
|
||||
<span>{t('studio.wfBrokenHint')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !(workflow.input_schema ?? []).length}
|
||||
<p class="note">{t('studio.wfNoParamsNote')}</p>
|
||||
{:else if (workflow.input_schema ?? []).some((i) => i.auto)}
|
||||
<p class="note">{t('studio.wfAutoParamsNote', { n: workflow.input_schema.length })}</p>
|
||||
{/if}
|
||||
|
||||
{#if ds.common.length}
|
||||
<div class="section-title">{t('desk.paramsCommon')}</div>
|
||||
<ComfyDynamicForm
|
||||
inputs={ds.common}
|
||||
values={values as Record<string, string | number>}
|
||||
quiet
|
||||
showOrig
|
||||
{errors}
|
||||
{showErrors}
|
||||
onChange={(v) => patch(ds.common, v)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if ds.advanced.length}
|
||||
<div class="fold">
|
||||
<button
|
||||
type="button"
|
||||
class="fold-head"
|
||||
class:err={advancedErr}
|
||||
aria-expanded={advancedOpen}
|
||||
aria-controls="desk-advanced"
|
||||
onclick={() => (advancedOpen = !advancedOpen)}
|
||||
>
|
||||
<span class="chev" aria-hidden="true"><Icon name="play" size={10} /></span>
|
||||
{t('desk.advanced', { n: ds.advanced.length })}
|
||||
</button>
|
||||
<div id="desk-advanced" class="collapse" class:open={advancedOpen} inert={!advancedOpen}>
|
||||
<div class="collapse-in">
|
||||
<ComfyDynamicForm
|
||||
inputs={ds.advanced}
|
||||
values={values as Record<string, string | number>}
|
||||
quiet
|
||||
showOrig
|
||||
{errors}
|
||||
{showErrors}
|
||||
onChange={(v) => patch(ds.advanced, v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if changedCount > 0}
|
||||
<div class="restore">
|
||||
<button type="button" class="a-btn ghost sm" onclick={onRestoreAll}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('desk.restoreAll')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.params {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.note {
|
||||
margin: 0;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.note.err {
|
||||
background: var(--err-soft);
|
||||
color: var(--err);
|
||||
}
|
||||
.note ul {
|
||||
margin: var(--sp-2) 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.fold {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.fold-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
padding: var(--sp-4) 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color var(--d-state) ease;
|
||||
}
|
||||
.fold-head.err {
|
||||
color: var(--err);
|
||||
}
|
||||
.chev {
|
||||
display: inline-grid;
|
||||
transition: transform var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.fold-head[aria-expanded='true'] .chev {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.fold-head:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
.collapse {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
transition:
|
||||
grid-template-rows var(--d-enter) var(--ease-out),
|
||||
opacity var(--d-state) ease;
|
||||
}
|
||||
.collapse.open {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
}
|
||||
.collapse-in {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.collapse.open .collapse-in {
|
||||
padding-bottom: var(--sp-2);
|
||||
}
|
||||
.restore {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import MediaSlot from '$lib/components/studio/MediaSlot.svelte';
|
||||
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
|
||||
import { mediaFileProblem } from '$lib/comfy/imageFile';
|
||||
import { createUploadManager } from '$lib/comfy/useUpload.svelte';
|
||||
import type { ComfyDynamicInput } from '$lib/comfy/types';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { h3Warnings, listKind, listPaths, type DeskSchema, type FormValue, type FormValues, type SlotDef } from './schema';
|
||||
|
||||
/**
|
||||
* 输入槽 (spec-v2 §3.3): exactly the media inputs the workflow JSON wires —
|
||||
* text-to-image shows none, image-to-image one input, image-to-video a
|
||||
* first frame, ref-to-video the 1–N list. Owns uploads and rejects;
|
||||
* values flow back through `onSet`.
|
||||
*/
|
||||
interface Props {
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
showErrors: boolean;
|
||||
errors: Record<string, string>;
|
||||
/** True while any upload runs (generate waits for it). */
|
||||
busy?: boolean;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onFromResults: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
let { ds, values, showErrors, errors, busy = $bindable(false), onSet, onFromResults }: Props = $props();
|
||||
|
||||
const uploads = createUploadManager();
|
||||
type Reject = { name: string; message: string };
|
||||
let rejects = $state<Record<string, Reject[]>>({});
|
||||
let fileSizes = $state<Record<string, number>>({});
|
||||
let replacingKey = $state('');
|
||||
let replacingIndex = $state(-1);
|
||||
|
||||
$effect(() => {
|
||||
busy = Object.keys(uploads.uploading).length > 0;
|
||||
});
|
||||
|
||||
// A different workflow starts with no stale rejects.
|
||||
$effect(() => {
|
||||
void ds;
|
||||
rejects = {};
|
||||
});
|
||||
|
||||
const warns = $derived(h3Warnings(ds, values));
|
||||
|
||||
function setRejects(nodeId: string, list: Reject[]) {
|
||||
rejects = { ...rejects, [nodeId]: list };
|
||||
}
|
||||
|
||||
async function upload(nodeId: string, file: File): Promise<string | null> {
|
||||
const path = await uploads.uploadSafe(nodeId, file);
|
||||
if (path) fileSizes = { ...fileSizes, [path]: file.size };
|
||||
return path;
|
||||
}
|
||||
|
||||
async function slotFile(s: SlotDef, file: File) {
|
||||
const problem = mediaFileProblem(s.mediaKind, file);
|
||||
if (problem) {
|
||||
setRejects(s.inp.nodeId, [{ name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
setRejects(s.inp.nodeId, []);
|
||||
const path = await upload(s.inp.nodeId, file);
|
||||
if (path) onSet(s.inp.nodeId, path);
|
||||
}
|
||||
|
||||
/** Add picked/dropped files in order; a batch that would pass `max` is refused whole. */
|
||||
async function groupAdd(entry: ComfyDynamicInput, files: File[]) {
|
||||
const key = entry.nodeId;
|
||||
const mk = listKind(entry);
|
||||
const max = entry.max ?? 9;
|
||||
const ok = files.filter((f) => !mediaFileProblem(mk, f));
|
||||
setRejects(
|
||||
key,
|
||||
files.filter((f) => mediaFileProblem(mk, f)).map((f) => ({ name: f.name, message: mediaFileProblem(mk, f)! })),
|
||||
);
|
||||
const start = listPaths(values, entry);
|
||||
if (start.length + ok.length > max) {
|
||||
toast.error(t('studio.errRefMax', { max, n: start.length, k: ok.length }));
|
||||
return;
|
||||
}
|
||||
for (const f of ok) {
|
||||
const path = await upload(key, f);
|
||||
if (!path) break;
|
||||
onSet(key, [...listPaths(values, entry), path]);
|
||||
}
|
||||
}
|
||||
|
||||
async function groupReplace(entry: ComfyDynamicInput, index: number, file: File) {
|
||||
const key = entry.nodeId;
|
||||
const problem = mediaFileProblem(listKind(entry), file);
|
||||
if (problem) {
|
||||
setRejects(key, [{ name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
setRejects(key, []);
|
||||
replacingKey = key;
|
||||
replacingIndex = index;
|
||||
try {
|
||||
const path = await upload(key, file);
|
||||
if (path) onSet(key, listPaths(values, entry).map((p, i) => (i === index ? path : p)));
|
||||
} finally {
|
||||
replacingKey = '';
|
||||
replacingIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function fileKind(f: File): 'image' | 'video' | 'audio' {
|
||||
if (f.type.startsWith('video/')) return 'video';
|
||||
if (f.type.startsWith('audio/')) return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
/** Composer-wide drop: each file goes to the first wired slot of its kind with room. */
|
||||
export function dropFiles(files: File[]) {
|
||||
const pending = new Map<string, File[]>();
|
||||
const taken = new Set<string>();
|
||||
let unplaced = 0;
|
||||
for (const f of files) {
|
||||
const mk = fileKind(f);
|
||||
const slot = ds.slots.find(
|
||||
(s) =>
|
||||
s.mediaKind === mk &&
|
||||
!s.mask &&
|
||||
!taken.has(s.inp.nodeId) &&
|
||||
!String(values[s.inp.nodeId] ?? ''),
|
||||
);
|
||||
if (slot) {
|
||||
taken.add(slot.inp.nodeId);
|
||||
void slotFile(slot, f);
|
||||
continue;
|
||||
}
|
||||
const list = ds.lists.find((g) => listKind(g) === mk);
|
||||
if (list) {
|
||||
pending.set(list.nodeId, [...(pending.get(list.nodeId) ?? []), f]);
|
||||
continue;
|
||||
}
|
||||
unplaced += 1;
|
||||
}
|
||||
for (const [nodeId, batch] of pending) {
|
||||
const entry = ds.lists.find((g) => g.nodeId === nodeId);
|
||||
if (entry) void groupAdd(entry, batch);
|
||||
}
|
||||
if (unplaced) toast.error(t('desk.dropNoSlot', { n: unplaced }));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if ds.slots.length || ds.lists.length}
|
||||
<div class="tray">
|
||||
{#each ds.slots as s (s.inp.nodeId)}
|
||||
{@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}
|
||||
size={fileSizes[String(values[s.inp.nodeId] ?? '')]}
|
||||
uploading={uploads.isUploading(s.inp.nodeId)}
|
||||
uploadName={uploads.uploading[s.inp.nodeId]}
|
||||
need={showErrors && !!errors[s.inp.nodeId]}
|
||||
error={reject?.message ?? (showErrors ? errors[s.inp.nodeId] : undefined)}
|
||||
allowFromResults={s.fromResults}
|
||||
anchorId={`slot-${s.inp.nodeId}`}
|
||||
onFile={(f) => slotFile(s, f)}
|
||||
onFromResults={() => onFromResults(s.inp.nodeId)}
|
||||
onClear={() => {
|
||||
setRejects(s.inp.nodeId, []);
|
||||
onSet(s.inp.nodeId, '');
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{#each ds.lists as entry (entry.nodeId)}
|
||||
<RefImageGrid
|
||||
label={entry.label}
|
||||
mediaKind={listKind(entry)}
|
||||
paths={listPaths(values, entry)}
|
||||
max={entry.max ?? 9}
|
||||
sizes={fileSizes}
|
||||
uploadingName={uploads.uploading[entry.nodeId]}
|
||||
uploadingIndex={replacingKey === entry.nodeId ? replacingIndex : -1}
|
||||
rejects={rejects[entry.nodeId] ?? []}
|
||||
need={showErrors && !!errors[entry.nodeId]}
|
||||
error={showErrors ? errors[entry.nodeId] : undefined}
|
||||
allowFromResults={entry.kind === 'image_list'}
|
||||
anchorId={`slot-${entry.nodeId}`}
|
||||
onAdd={(files) => groupAdd(entry, files)}
|
||||
onReplace={(i, file) => groupReplace(entry, i, file)}
|
||||
onRemove={(i) => onSet(entry.nodeId, listPaths(values, entry).filter((_, j) => j !== i))}
|
||||
onDismissReject={(i) => setRejects(entry.nodeId, (rejects[entry.nodeId] ?? []).filter((_, j) => j !== i))}
|
||||
onFromResults={entry.kind === 'image_list' ? () => onFromResults(entry.nodeId) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if showErrors && errors.firstLast}
|
||||
<p class="a-slot-hint err note">{errors.firstLast}</p>
|
||||
{/if}
|
||||
{#if ds.hasH3Hint}
|
||||
<div class="note" role="note">
|
||||
<p class="a-slot-hint">{t('wfMedia.h3Limits')}</p>
|
||||
{#each warns as w (w)}
|
||||
<p class="a-slot-hint warn">{w}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tray {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3) var(--sp-6);
|
||||
padding: var(--sp-4) var(--sp-5) 0;
|
||||
}
|
||||
.note {
|
||||
margin: 0;
|
||||
padding: var(--sp-2) var(--sp-5) 0;
|
||||
}
|
||||
.note p {
|
||||
margin: 0;
|
||||
}
|
||||
.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,602 @@
|
||||
<script lang="ts">
|
||||
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 { usageLabel } from './schema';
|
||||
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf } from './takes';
|
||||
|
||||
/**
|
||||
* 一条记录 (spec-v2 §3.3 记录卡): workflow · usage · time, the prompt, the
|
||||
* inputs it used, its real state (phase / progress / time / error) and
|
||||
* the first output. The whole card selects; actions sit above it.
|
||||
* D 批 (HEL-654): the gold sel-mark frame slides between cards on
|
||||
* pointer selection (driven by the feed), new takes enter with a
|
||||
* 220ms rise, completion flashes once, failure shakes once, a done
|
||||
* take whose files no longer load turns amber (文件缺失) with
|
||||
* 用相同参数重做 / 移除记录, and takes on a deleted workflow are
|
||||
* marked as such instead of pretending nothing changed.
|
||||
*/
|
||||
interface Props {
|
||||
job: Job;
|
||||
wf: Workflow | undefined;
|
||||
selected: boolean;
|
||||
nowMs: number;
|
||||
busy?: boolean;
|
||||
/** Play the 220ms entrance rise (new take / undo restore). */
|
||||
enter?: boolean;
|
||||
/** One-shot 900ms gold flash on completion. */
|
||||
flash?: boolean;
|
||||
/** One-shot failure shake. */
|
||||
shake?: boolean;
|
||||
onSelect: () => void;
|
||||
onReuse: () => void;
|
||||
onRetry: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
job,
|
||||
wf,
|
||||
selected,
|
||||
nowMs,
|
||||
busy = false,
|
||||
enter = false,
|
||||
flash = false,
|
||||
shake = false,
|
||||
onSelect,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
const st = $derived(jobStatusInfo(job));
|
||||
// §3.4 红/琥珀界线: red follows the backend task status only. Amber is
|
||||
// reserved for a SUCCEEDED take whose product files no longer load.
|
||||
const gonePaths = $derived(missingOutputsOf(job));
|
||||
const fileGone = $derived(gonePaths.length > 0);
|
||||
const tone = $derived(fileGone ? 'warn' : st.tone === 'queue' ? '' : st.tone === 'fail' ? 'err' : st.tone);
|
||||
const label = $derived(fileGone ? t('jobst.missing') : st.label);
|
||||
const wfDeleted = $derived(job.workflow_id == null);
|
||||
const prompt = $derived(promptOf(job, wf));
|
||||
const inputs = $derived(inputsOf(job, wf).filter((i) => i.kind === 'image'));
|
||||
const output = $derived(
|
||||
fileGone
|
||||
? (job.output_paths.find((p) => !gonePaths.includes(p)) ?? null)
|
||||
: (job.output_paths[0] ?? null),
|
||||
);
|
||||
const progress = $derived.by(() => {
|
||||
if (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;
|
||||
});
|
||||
const active = $derived(job.status === 'pending' || job.status === 'running');
|
||||
const name = $derived(wf?.name ?? t('studio.jobName', { id: job.id }));
|
||||
|
||||
let portrait = $state(false);
|
||||
let clipSec = $state<number | null>(null);
|
||||
let errOpen = $state(false);
|
||||
|
||||
function noteImage(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
portrait = img.naturalHeight > img.naturalWidth;
|
||||
}
|
||||
|
||||
function noteVideo(e: Event) {
|
||||
const v = e.currentTarget as HTMLVideoElement;
|
||||
portrait = v.videoHeight > v.videoWidth;
|
||||
if (Number.isFinite(v.duration)) clipSec = v.duration;
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="take"
|
||||
class:selected
|
||||
class:enter
|
||||
class:done-flash={flash}
|
||||
class:shake
|
||||
data-job={job.id}
|
||||
>
|
||||
<span class="sel-mark" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="hit"
|
||||
aria-pressed={selected}
|
||||
aria-label={`${name} · ${label}`}
|
||||
onclick={onSelect}
|
||||
></button>
|
||||
|
||||
<div class="head">
|
||||
<span class="name">{name}</span>
|
||||
{#if wfDeleted}<span class="a-tag">{t('studio.wfDeletedTag')}</span>{/if}
|
||||
{#if wf?.usage}<span class="a-tag">{usageLabel(wf.usage)}</span>{/if}
|
||||
<span class="meta mono">{fmtClock(parseDbTs(job.created_at))}</span>
|
||||
</div>
|
||||
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
|
||||
{#if inputs.length}
|
||||
<div class="inputs" aria-label={t('desk.inputs')}>
|
||||
{#each inputs.slice(0, 5) as inp (inp.path + inp.label)}
|
||||
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
|
||||
{/each}
|
||||
{#if inputs.length > 5}<span class="more">+{inputs.length - 5}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="state">
|
||||
<span class="a-tag {tone}">{label}</span>
|
||||
{#if st.tone === 'run'}
|
||||
{#if progress > 0}
|
||||
<div class="a-progress bar" style:--p={progress / 100}><i></i></div>
|
||||
{/if}
|
||||
<span class="txt">{fmtDur(elapsedSec(job, nowMs))}</span>
|
||||
{:else if st.tone === 'ok' && !fileGone}
|
||||
<span class="txt">{t('desk.took', { d: fmtDur(elapsedSec(job, nowMs)) })}</span>
|
||||
{:else if st.tone === 'fail'}
|
||||
<button
|
||||
type="button"
|
||||
class="err-txt"
|
||||
class:open={errOpen}
|
||||
title={st.detail}
|
||||
aria-expanded={errOpen}
|
||||
onclick={() => (errOpen = !errOpen)}
|
||||
>
|
||||
{st.detail}
|
||||
</button>
|
||||
{:else if fileGone}
|
||||
<span class="gone-txt">{t('jobst.missingDetail')}</span>
|
||||
{:else}
|
||||
<span class="txt">{st.detail}</span>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
{#if fileGone}
|
||||
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>
|
||||
{t('desk.redo')}
|
||||
</button>
|
||||
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('desk.removeRecord')}</button>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
{#if active}
|
||||
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onCancel}>{t('common.cancel')}</button>
|
||||
{/if}
|
||||
{#if output}
|
||||
<a class="a-btn ghost xs" href={assetUrl(output)} download>{t('desk.download')}</a>
|
||||
{/if}
|
||||
{#if !active}
|
||||
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('studio.deleteBtn')}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="out">
|
||||
{#if output}
|
||||
<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)}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
></video>
|
||||
<span class="play" aria-hidden="true"><Icon name="play" size={10} /></span>
|
||||
{#if clipSec != null}<span class="badge mono">{fmtDur(clipSec)}</span>{/if}
|
||||
{:else}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImage(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
{/if}
|
||||
{#if job.output_paths.length > 1}<span class="count mono">×{job.output_paths.length}</span>{/if}
|
||||
</div>
|
||||
{:else if active}
|
||||
<div class="thumb pending">{st.label}</div>
|
||||
{:else if fileGone}
|
||||
<div class="thumb gone">{t('jobst.missing')}</div>
|
||||
{:else}
|
||||
<div class="thumb missing">{t('desk.notGenerated')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.take {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--sp-4) var(--sp-6);
|
||||
margin-bottom: var(--sp-3);
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--bg-2);
|
||||
transition:
|
||||
border-color var(--d-state) ease,
|
||||
background-color var(--d-state) ease,
|
||||
box-shadow var(--d-state) ease;
|
||||
}
|
||||
/* 选中框:取景框角线从上一条记录滑到新选中的记录(feed 驱动 WAAPI,
|
||||
HEL-680 四角短线取代整框描边) */
|
||||
.sel-mark {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
z-index: 3;
|
||||
display: none;
|
||||
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;
|
||||
background: var(--accent-wash), var(--bg-3);
|
||||
box-shadow: var(--sel-shadow);
|
||||
}
|
||||
.take.selected > .sel-mark {
|
||||
display: block;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.take:hover:not(.selected) {
|
||||
border-color: var(--line-2);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
}
|
||||
/* 新记录 / 撤销恢复:220ms 上移 8px 入场(减动效只余淡变) */
|
||||
.take.enter {
|
||||
animation: take-in var(--d-enter) var(--ease-out);
|
||||
}
|
||||
@keyframes take-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
/* 完成一次性金色光晕 900ms;失败原地轻抖一次 */
|
||||
.take.done-flash {
|
||||
animation: done-flash var(--d-flash) var(--ease-out);
|
||||
}
|
||||
@keyframes done-flash {
|
||||
from {
|
||||
box-shadow: var(--accent-glow), 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
}
|
||||
.take.shake {
|
||||
animation: take-shake 320ms var(--ease-out);
|
||||
}
|
||||
@keyframes take-shake {
|
||||
20% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
45% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
70% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .take.enter {
|
||||
animation: take-fade var(--d-state) ease;
|
||||
}
|
||||
:global(:root[data-reduce='1']) .take.done-flash,
|
||||
:global(:root[data-reduce='1']) .take.shake {
|
||||
animation: none;
|
||||
}
|
||||
@keyframes take-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.hit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hit:focus-visible {
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
.head,
|
||||
.prompt,
|
||||
.inputs,
|
||||
.state {
|
||||
grid-column: 1;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.meta {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.prompt {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-body-sm);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.mini {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 5px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line-2);
|
||||
}
|
||||
.more {
|
||||
padding-left: 2px;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-height: 22px;
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.bar {
|
||||
flex: 0 0 140px;
|
||||
}
|
||||
.txt {
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gone-txt {
|
||||
min-width: 0;
|
||||
color: var(--warn);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* 失败:一行中文结论自动展开,点开看完整详情 */
|
||||
.err-txt {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--err);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.err-txt.open {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
.err-txt:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transition: opacity var(--d-state) ease;
|
||||
}
|
||||
.take:focus-within .actions,
|
||||
.take.selected .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.take:hover .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (hover: none) {
|
||||
.actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.out {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 4;
|
||||
align-self: start;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
.thumb {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: var(--r-md);
|
||||
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;
|
||||
}
|
||||
.thumb img,
|
||||
.thumb video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.badge,
|
||||
.count {
|
||||
position: absolute;
|
||||
bottom: var(--sp-2);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.badge {
|
||||
right: var(--sp-2);
|
||||
}
|
||||
.count {
|
||||
right: var(--sp-2);
|
||||
top: var(--sp-2);
|
||||
bottom: auto;
|
||||
}
|
||||
.play {
|
||||
position: absolute;
|
||||
left: var(--sp-2);
|
||||
bottom: var(--sp-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
}
|
||||
.thumb.pending,
|
||||
.thumb.missing,
|
||||
.thumb.gone {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-2);
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
border: 1px dashed var(--line-2);
|
||||
background: var(--bg-2);
|
||||
box-shadow: none;
|
||||
}
|
||||
.thumb.pending {
|
||||
border-style: solid;
|
||||
color: var(--run);
|
||||
}
|
||||
.thumb.gone {
|
||||
color: var(--warn);
|
||||
border-color: var(--warn);
|
||||
}
|
||||
.thumb.pending::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(100deg, transparent 30%, var(--run-soft) 50%, transparent 70%);
|
||||
animation: take-sheen 1.4s linear infinite;
|
||||
}
|
||||
@keyframes take-sheen {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .thumb.pending::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.take {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
.head {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 2px;
|
||||
}
|
||||
.out {
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
.state {
|
||||
grid-column: 1 / -1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.thumb {
|
||||
width: 108px;
|
||||
}
|
||||
.thumb.portrait {
|
||||
width: 72px;
|
||||
}
|
||||
.bar {
|
||||
flex-basis: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,344 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import TakeCard from './TakeCard.svelte';
|
||||
import { dayLabel, matchesFilter, type FeedFilter } from './takes';
|
||||
|
||||
/**
|
||||
* 记录流 (spec-v2 §3.3): every desk take, newest last above the composer,
|
||||
* filtered 全部 / 图片 / 视频 / 失败 and grouped by day.
|
||||
* D 批 (HEL-654): rows pending their 5-second undo collapse out
|
||||
* (200ms) instead of vanishing, new/restored takes rise in (220ms),
|
||||
* the gold selection frame slides between cards on pointer picks
|
||||
* (键盘 ↑↓/J/K 即时切换无动画 — driven by the desk through the
|
||||
* exported helpers), and long histories page in from the top
|
||||
* (spec §五: 记录流超 200 条分页).
|
||||
*/
|
||||
interface Props {
|
||||
jobs: Job[];
|
||||
workflowOf: (job: Job) => Workflow | undefined;
|
||||
filter: FeedFilter;
|
||||
selectedId: number | null;
|
||||
nowMs: number;
|
||||
busyId: number | null;
|
||||
loading: boolean;
|
||||
/** Takes whose delete is inside the 5s undo window — hidden. */
|
||||
pendingIds: SvelteSet<number>;
|
||||
/** One-shot completion flashes / failure shakes (driven by SSE). */
|
||||
flashIds: SvelteSet<number>;
|
||||
shakeIds: SvelteSet<number>;
|
||||
/** Ordered visible ids (oldest first) for ↑↓/J/K. */
|
||||
orderedIds?: number[];
|
||||
/** Shown under the filters (e.g. the workbench context bar). */
|
||||
banner?: Snippet;
|
||||
/** Inspector toggle when it is hidden (desktop) or an overlay (mid). */
|
||||
inspectorToggle?: Snippet;
|
||||
onSelect: (job: Job) => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onCancel: (job: Job) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
jobs,
|
||||
workflowOf,
|
||||
filter = $bindable(),
|
||||
selectedId,
|
||||
nowMs,
|
||||
busyId,
|
||||
loading,
|
||||
pendingIds,
|
||||
flashIds,
|
||||
shakeIds,
|
||||
orderedIds = $bindable(),
|
||||
banner,
|
||||
inspectorToggle,
|
||||
onSelect,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
const FILTERS: Array<{ id: FeedFilter; key: string }> = [
|
||||
{ id: 'all', key: 'desk.filterAll' },
|
||||
{ id: 'image', key: 'desk.filterImage' },
|
||||
{ id: 'video', key: 'desk.filterVideo' },
|
||||
{ id: 'failed', key: 'desk.filterFailed' },
|
||||
];
|
||||
|
||||
/** §五 分页: render the newest PAGE_SIZE takes; older ones load on demand. */
|
||||
const PAGE_SIZE = 80;
|
||||
let limit = $state(PAGE_SIZE);
|
||||
|
||||
let scroller = $state<HTMLElement | null>(null);
|
||||
|
||||
/** Oldest first so the newest take sits right above the composer. */
|
||||
const shown = $derived(
|
||||
jobs
|
||||
.filter((j) => matchesFilter(j, filter) && !pendingIds.has(j.id))
|
||||
.sort((a, b) => parseDbTs(a.created_at) - parseDbTs(b.created_at)),
|
||||
);
|
||||
const hiddenEarlier = $derived(Math.max(0, shown.length - limit));
|
||||
const visible = $derived(hiddenEarlier > 0 ? shown.slice(-limit) : shown);
|
||||
$effect(() => {
|
||||
orderedIds = visible.map((j) => j.id);
|
||||
});
|
||||
const activeCount = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running').length);
|
||||
const days = $derived.by(() => {
|
||||
const out: Array<{ label: string; items: Job[] }> = [];
|
||||
for (const job of visible) {
|
||||
const label = dayLabel(parseDbTs(job.created_at), nowMs);
|
||||
const last = out[out.length - 1];
|
||||
if (last?.label === label) last.items.push(job);
|
||||
else out.push({ label, items: [job] });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Stick to the bottom as takes arrive, unless the user scrolled up to read.
|
||||
let pinned = true;
|
||||
function onScroll() {
|
||||
if (!scroller) return;
|
||||
pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80;
|
||||
}
|
||||
$effect(() => {
|
||||
void visible.length;
|
||||
void filter;
|
||||
if (!scroller || !pinned) return;
|
||||
const el = scroller;
|
||||
requestAnimationFrame(() => (el.scrollTop = el.scrollHeight));
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Entrance bookkeeping: only takes that appear AFTER the first fill
|
||||
// (or come back from an undo) play the 220ms rise.
|
||||
// ------------------------------------------------------------------
|
||||
const seenIds = new Set<number>();
|
||||
const animateIds = new SvelteSet<number>();
|
||||
let hydrated = false;
|
||||
$effect(() => {
|
||||
if (!hydrated) {
|
||||
if (jobs.length) {
|
||||
for (const j of jobs) seenIds.add(j.id);
|
||||
hydrated = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const j of jobs) {
|
||||
if (seenIds.has(j.id)) continue;
|
||||
seenIds.add(j.id);
|
||||
animateIds.add(j.id);
|
||||
}
|
||||
});
|
||||
|
||||
export function markEnter(jobId: number) {
|
||||
seenIds.add(jobId);
|
||||
animateIds.add(jobId);
|
||||
}
|
||||
|
||||
function reduced(): boolean {
|
||||
return (
|
||||
document.documentElement.dataset.reduce === '1' ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Gold selection frame (HEL-648 slideMark): pointer picks only.
|
||||
// ------------------------------------------------------------------
|
||||
export function markRect(): DOMRect | null {
|
||||
const el = scroller?.querySelector(`.take[data-job="${selectedId}"]`);
|
||||
return el instanceof HTMLElement ? el.getBoundingClientRect() : null;
|
||||
}
|
||||
|
||||
export function slideFrom(fromRect: DOMRect | null) {
|
||||
if (!fromRect || reduced()) return;
|
||||
const el = scroller?.querySelector(`.take[data-job="${selectedId}"]`);
|
||||
const mark = el?.querySelector('.sel-mark');
|
||||
if (!(el instanceof HTMLElement) || !(mark instanceof HTMLElement)) return;
|
||||
const to = el.getBoundingClientRect();
|
||||
if (Math.abs(fromRect.top - to.top) < 1 && Math.abs(fromRect.height - to.height) < 1) return;
|
||||
mark.animate(
|
||||
[
|
||||
{ top: `${fromRect.top - to.top - 1}px`, bottom: `${to.bottom - fromRect.bottom - 1}px` },
|
||||
{ top: '-1px', bottom: '-1px' },
|
||||
],
|
||||
{ duration: 220, easing: 'cubic-bezier(0.23, 1, 0.32, 1)' },
|
||||
);
|
||||
}
|
||||
|
||||
/** Bring a keyboard-selected take into view without animation. */
|
||||
export function scrollToJob(jobId: number) {
|
||||
const el = scroller?.querySelector(`.take[data-job="${jobId}"]`);
|
||||
el?.scrollIntoView({ block: 'nearest', behavior: 'auto' });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Row transitions: undo-pending rows collapse out (200ms 收起 + 补位),
|
||||
// new/restored takes rise in (220ms). Everything else is instant.
|
||||
// ------------------------------------------------------------------
|
||||
function takeEnter(_node: HTMLElement, should: () => boolean) {
|
||||
if (!should()) return { duration: 0 };
|
||||
if (reduced()) return { duration: 120, css: (t: number) => `opacity: ${t}` };
|
||||
return {
|
||||
duration: 220,
|
||||
easing: cubicOut,
|
||||
css: (t: number) => `opacity: ${t}; transform: translateY(${8 * (1 - t)}px)`,
|
||||
};
|
||||
}
|
||||
|
||||
function takeLeave(node: HTMLElement, isPending: () => boolean) {
|
||||
if (!isPending()) return { duration: 0 };
|
||||
if (reduced()) return { duration: 120, css: (t: number) => `opacity: ${t}` };
|
||||
const h = node.offsetHeight;
|
||||
const cs = getComputedStyle(node);
|
||||
const num = (v: string) => parseFloat(v) || 0;
|
||||
const padT = num(cs.paddingTop);
|
||||
const padB = num(cs.paddingBottom);
|
||||
const mb = num(cs.marginBottom);
|
||||
return {
|
||||
duration: 200,
|
||||
easing: cubicOut,
|
||||
css: (t: number, u: number) =>
|
||||
`height:${h * t}px;padding-top:${padT * t}px;padding-bottom:${padB * t}px;` +
|
||||
`margin-bottom:${mb * t}px;opacity:${t};transform:translateX(${-10 * u}px);overflow:hidden;`,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="filters">
|
||||
<div class="a-seg" role="group" aria-label={t('desk.filterAria')}>
|
||||
{#each FILTERS as f (f.id)}
|
||||
<button type="button" aria-pressed={filter === f.id} onclick={() => (filter = f.id)}>{t(f.key)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="count">
|
||||
{t('desk.countTakes', { n: shown.length })}{#if activeCount} · {t('desk.countActive', { n: activeCount })}{/if}
|
||||
</span>
|
||||
{@render inspectorToggle?.()}
|
||||
</div>
|
||||
{@render banner?.()}
|
||||
|
||||
<div class="feed" bind:this={scroller} onscroll={onScroll}>
|
||||
{#if loading && !jobs.length}
|
||||
<p class="empty">{t('common.loading')}</p>
|
||||
{:else if !shown.length}
|
||||
<div class="empty">
|
||||
<Icon name={filter === 'video' ? 'film' : filter === 'failed' ? 'check' : 'image'} size={20} />
|
||||
<p>{filter === 'failed' ? t('desk.emptyFailed') : t('desk.emptyFeed')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if hiddenEarlier > 0}
|
||||
<button type="button" class="a-btn ghost sm earlier" onclick={() => (limit += PAGE_SIZE)}>
|
||||
<Icon name="chevron-up" size={13} />
|
||||
{t('desk.loadEarlier', { n: hiddenEarlier })}
|
||||
</button>
|
||||
{/if}
|
||||
{#each days as day (day.label)}
|
||||
<div class="day">{day.label}</div>
|
||||
{#each day.items as job (job.id)}
|
||||
<div
|
||||
class="take-slot"
|
||||
in:takeEnter={() => animateIds.has(job.id)}
|
||||
out:takeLeave={() => pendingIds.has(job.id)}
|
||||
>
|
||||
<TakeCard
|
||||
{job}
|
||||
wf={workflowOf(job)}
|
||||
selected={job.id === selectedId}
|
||||
{nowMs}
|
||||
busy={busyId === job.id}
|
||||
enter={animateIds.has(job.id)}
|
||||
flash={flashIds.has(job.id)}
|
||||
shake={shakeIds.has(job.id)}
|
||||
onSelect={() => onSelect(job)}
|
||||
onReuse={() => onReuse(job)}
|
||||
onRetry={() => onRetry(job)}
|
||||
onCancel={() => onCancel(job)}
|
||||
onDelete={() => onDelete(job)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4) var(--sp-7);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.count {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.feed {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-6) var(--sp-7) var(--sp-3);
|
||||
}
|
||||
.earlier {
|
||||
display: flex;
|
||||
margin: 0 auto var(--sp-4);
|
||||
}
|
||||
.day {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin: var(--sp-4) 0 var(--sp-3);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.day::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
.empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin: 0;
|
||||
padding: var(--sp-8) var(--sp-6);
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.empty p {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.filters {
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
}
|
||||
.feed {
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-3);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.filters {
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.count {
|
||||
display: none;
|
||||
}
|
||||
.feed {
|
||||
padding: var(--sp-5) var(--sp-5) var(--sp-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<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>
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { WorkbenchStageKey } from '$lib/api';
|
||||
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).
|
||||
*/
|
||||
export interface DeskContext {
|
||||
kind: DeskKind;
|
||||
projectId: number;
|
||||
stage: WorkbenchStageKey;
|
||||
/** Human label of the source row, e.g. 镜头 S1 / 角色「林夏」. */
|
||||
label: string;
|
||||
prompt?: string;
|
||||
negative?: string;
|
||||
durationSec?: number;
|
||||
}
|
||||
|
||||
const STAGES: WorkbenchStageKey[] = ['script', 'style', 'storyboard', 'assets', 'shot'];
|
||||
|
||||
export function deskHref(ctx: DeskContext): string {
|
||||
const q = new URLSearchParams({
|
||||
from: 'workbench',
|
||||
project: String(ctx.projectId),
|
||||
stage: ctx.stage,
|
||||
label: ctx.label,
|
||||
});
|
||||
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));
|
||||
return `/${ctx.kind}?${q.toString()}`;
|
||||
}
|
||||
|
||||
export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
|
||||
const q = url.searchParams;
|
||||
if (q.get('from') !== 'workbench') return null;
|
||||
const projectId = Number(q.get('project'));
|
||||
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'));
|
||||
return {
|
||||
kind,
|
||||
projectId,
|
||||
stage,
|
||||
label: q.get('label') ?? '',
|
||||
prompt: q.get('prompt') ?? undefined,
|
||||
negative: q.get('negative') ?? undefined,
|
||||
durationSec: Number.isFinite(duration) && duration > 0 ? duration : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function backHref(ctx: DeskContext): string {
|
||||
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import type { ComfyDynamicInput, Workflow, WorkflowUsage } from '$lib/comfy/types';
|
||||
import { normalizeInputRole } from '$lib/comfy/parser';
|
||||
import { ratioShort, resolutionSelectorSize } from '$lib/comfy/resolution';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 生成台表单识别 (HEL-653, moved verbatim from StudioGenerate): slots and
|
||||
* structured params come from what the workflow JSON actually declares —
|
||||
* never from model names. No wiring → no slot.
|
||||
*/
|
||||
|
||||
export type DeskKind = 'image' | 'video';
|
||||
export type FormValue = string | number | string[];
|
||||
export type FormValues = Record<string, FormValue>;
|
||||
|
||||
export interface SlotDef {
|
||||
inp: ComfyDynamicInput;
|
||||
label: string;
|
||||
mediaKind: 'image' | 'video' | 'audio';
|
||||
fromResults: boolean;
|
||||
/** Required-slot message when left empty (ref-upload-spec §3). */
|
||||
missing: string;
|
||||
firstFrame: boolean;
|
||||
lastFrame: boolean;
|
||||
mask: boolean;
|
||||
}
|
||||
|
||||
/** Why 生成 is disabled — the line next to the button (spec-v2 §1.2). */
|
||||
export interface BlockReason {
|
||||
text: string;
|
||||
tone: 'warn' | 'err';
|
||||
/** Element id to focus when the reason is clicked. */
|
||||
target?: string;
|
||||
/** Open the params tab instead of focusing. */
|
||||
params?: boolean;
|
||||
}
|
||||
|
||||
export interface DeskSchema {
|
||||
prompt: ComfyDynamicInput | null;
|
||||
negative: ComfyDynamicInput | null;
|
||||
seed: ComfyDynamicInput | null;
|
||||
width: ComfyDynamicInput | null;
|
||||
height: ComfyDynamicInput | null;
|
||||
resolution: ComfyDynamicInput | null;
|
||||
duration: ComfyDynamicInput | null;
|
||||
batch: ComfyDynamicInput | null;
|
||||
/** ComfyUI ResolutionSelector: the ratio + megapixels pair decides width/height. */
|
||||
ratio: ComfyDynamicInput | null;
|
||||
megapixels: ComfyDynamicInput | null;
|
||||
/** Single media inputs (input image, first/last frame, mask, ref video/audio). */
|
||||
slots: SlotDef[];
|
||||
/** Autogrow media lists actually wired (image / video / audio). */
|
||||
lists: ComfyDynamicInput[];
|
||||
/** Parameters shown as composer chips (比例 / 尺寸 / 分辨率 / 时长 / 种子). */
|
||||
common: ComfyDynamicInput[];
|
||||
/** Everything else that is not media and not the prompt — folded. */
|
||||
advanced: ComfyDynamicInput[];
|
||||
hasH3Hint: boolean;
|
||||
}
|
||||
|
||||
export function inputRole(inp: ComfyDynamicInput): string | null {
|
||||
return normalizeInputRole(inp.role ?? null);
|
||||
}
|
||||
|
||||
function hay(inp: ComfyDynamicInput): string {
|
||||
return `${inp.role ?? ''} ${inp.label}`.toLowerCase();
|
||||
}
|
||||
|
||||
export function isMedia(inp: ComfyDynamicInput): boolean {
|
||||
return (
|
||||
inp.kind === 'image' ||
|
||||
inp.kind === 'image_url' ||
|
||||
inp.kind === 'video' ||
|
||||
inp.kind === 'audio' ||
|
||||
inp.kind === 'image_list' ||
|
||||
inp.kind === 'video_list' ||
|
||||
inp.kind === 'audio_list'
|
||||
);
|
||||
}
|
||||
|
||||
function isList(inp: ComfyDynamicInput): boolean {
|
||||
return inp.kind === 'image_list' || inp.kind === 'video_list' || inp.kind === 'audio_list';
|
||||
}
|
||||
|
||||
function isMask(inp: ComfyDynamicInput): boolean {
|
||||
return inputRole(inp) === 'mask' || /mask|遮罩/.test(hay(inp));
|
||||
}
|
||||
|
||||
// Auto-detected slots carry their role from the wiring; tagged (Input:…)
|
||||
// workflows still name frames only in the title.
|
||||
export function isFirstFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
|
||||
return inputRole(inp) === 'first_frame' || (!inp.auto && kind === 'video' && /first|start|首帧/.test(hay(inp)));
|
||||
}
|
||||
export function isLastFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
|
||||
return inputRole(inp) === 'last_frame' || (!inp.auto && kind === 'video' && /last|end|尾帧/.test(hay(inp)));
|
||||
}
|
||||
|
||||
export function listKind(entry: ComfyDynamicInput): 'image' | 'video' | 'audio' {
|
||||
if (entry.kind === 'video_list') return 'video';
|
||||
if (entry.kind === 'audio_list') return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
export function listPaths(values: FormValues, entry: ComfyDynamicInput): string[] {
|
||||
const v = values[entry.nodeId];
|
||||
if (Array.isArray(v)) return v;
|
||||
return typeof v === 'string' && v ? [v] : [];
|
||||
}
|
||||
|
||||
export function classifySchema(schema: ComfyDynamicInput[], kind: DeskKind): DeskSchema {
|
||||
const plain = (pred: (i: ComfyDynamicInput) => boolean) =>
|
||||
schema.find((i) => !isMedia(i) && pred(i)) ?? null;
|
||||
const byRole = (r: string) => plain((i) => inputRole(i) === r);
|
||||
|
||||
const prompt = byRole('prompt');
|
||||
const negative = byRole('negative');
|
||||
const seed = byRole('seed');
|
||||
const width = byRole('width');
|
||||
const height = byRole('height');
|
||||
const resolution = byRole('resolution');
|
||||
const duration = byRole('duration');
|
||||
const batch = plain((i) => /批次|batch/.test(hay(i)));
|
||||
const ratio =
|
||||
schema.find((i) => i.kind === 'select' && inputRole(i) === 'aspect_ratio' && i.resolution) ?? null;
|
||||
const megapixels = ratio ? (schema.find((i) => i.nodeId === ratio.resolution!.megapixels) ?? null) : null;
|
||||
|
||||
const media = schema.filter((i) => isMedia(i) && !isList(i));
|
||||
const plainImages = media.filter(
|
||||
(i) => i.kind === 'image' && !isMask(i) && !isFirstFrame(i, kind) && !isLastFrame(i, kind),
|
||||
);
|
||||
const editInput = kind === 'image' && plainImages.length === 1 ? plainImages[0] : null;
|
||||
const slots: SlotDef[] = [];
|
||||
let refIdx = 0;
|
||||
for (const inp of media) {
|
||||
const base = { inp, missing: '', firstFrame: false, lastFrame: false, mask: false };
|
||||
if (inp.kind === 'video') {
|
||||
slots.push({ ...base, label: t('studio.slotRefVideo'), mediaKind: 'video', fromResults: false });
|
||||
} else if (inp.kind === 'audio') {
|
||||
slots.push({ ...base, label: t('studio.slotAudio'), mediaKind: 'audio', fromResults: false });
|
||||
} else if (isMask(inp)) {
|
||||
slots.push({ ...base, mask: true, label: t('studio.slotMask'), mediaKind: 'image', fromResults: false });
|
||||
} else if (isFirstFrame(inp, kind)) {
|
||||
slots.push({
|
||||
...base,
|
||||
firstFrame: true,
|
||||
label: t('studio.slotFirst'),
|
||||
mediaKind: 'image',
|
||||
fromResults: true,
|
||||
missing: t('studio.errNeedFirstFrame'),
|
||||
});
|
||||
} else if (isLastFrame(inp, kind)) {
|
||||
slots.push({ ...base, lastFrame: true, label: t('studio.slotLast'), mediaKind: 'image', fromResults: true });
|
||||
} else if (inp === editInput) {
|
||||
slots.push({
|
||||
...base,
|
||||
label: t('studio.slotInput'),
|
||||
mediaKind: 'image',
|
||||
fromResults: true,
|
||||
missing: t('studio.errNeedInputImage'),
|
||||
});
|
||||
} else {
|
||||
refIdx += 1;
|
||||
const label = inp.auto
|
||||
? inp.label
|
||||
: refIdx === 1 && media.length === 1
|
||||
? t('studio.slotRef')
|
||||
: t('studio.slotRefN', { n: refIdx });
|
||||
slots.push({
|
||||
...base,
|
||||
label,
|
||||
mediaKind: 'image',
|
||||
fromResults: true,
|
||||
missing: t('studio.errUpload', { label }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const lists = schema.filter(isList);
|
||||
const common = [ratio, width, height, resolution, duration, seed].filter(
|
||||
(i): i is ComfyDynamicInput => !!i,
|
||||
);
|
||||
const commonIds = new Set(common.map((i) => i.nodeId));
|
||||
const advanced = schema.filter((i) => !isMedia(i) && i !== prompt && !commonIds.has(i.nodeId));
|
||||
|
||||
return {
|
||||
prompt,
|
||||
negative,
|
||||
seed,
|
||||
width,
|
||||
height,
|
||||
resolution,
|
||||
duration,
|
||||
batch,
|
||||
ratio,
|
||||
megapixels,
|
||||
slots,
|
||||
lists,
|
||||
common,
|
||||
advanced,
|
||||
hasH3Hint: lists.some((e) => e.media_hint === 'minimax_h3_ref2va'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Workflow JSON defaults — the 原值 every parameter falls back to. */
|
||||
export function schemaDefaults(schema: ComfyDynamicInput[]): FormValues {
|
||||
const out: FormValues = {};
|
||||
for (const inp of schema) {
|
||||
if (inp.defaultValue !== undefined && inp.defaultValue !== '') out[inp.nodeId] = inp.defaultValue;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isChanged(inp: ComfyDynamicInput, values: FormValues): boolean {
|
||||
const v = values[inp.nodeId];
|
||||
const orig = inp.defaultValue;
|
||||
if (v === undefined || v === '') return orig !== undefined && orig !== '';
|
||||
if (orig === undefined || orig === '') return true;
|
||||
return String(v) !== String(orig);
|
||||
}
|
||||
|
||||
export function changedParams(ds: DeskSchema, values: FormValues): ComfyDynamicInput[] {
|
||||
return [...ds.common, ...ds.advanced].filter((i) => isChanged(i, values));
|
||||
}
|
||||
|
||||
export function outputSize(ds: DeskSchema, values: FormValues): { width: number; height: number } | null {
|
||||
if (!ds.ratio?.resolution) return null;
|
||||
const mp = Number(values[ds.megapixels?.nodeId ?? ''] ?? ds.megapixels?.defaultValue);
|
||||
return resolutionSelectorSize(
|
||||
String(values[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
|
||||
mp,
|
||||
ds.ratio.resolution.multiple,
|
||||
);
|
||||
}
|
||||
|
||||
export function batchCount(ds: DeskSchema, values: FormValues): number {
|
||||
if (!ds.batch) return 1;
|
||||
const n = Number(values[ds.batch.nodeId]);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
|
||||
}
|
||||
|
||||
/** H3 Ref2VA official limits (soft hint, keyed off the node class only). */
|
||||
export function h3Warnings(ds: DeskSchema, values: FormValues): string[] {
|
||||
if (!ds.hasH3Hint) return [];
|
||||
const warns: string[] = [];
|
||||
const counts = { image: 0, video: 0, audio: 0 };
|
||||
for (const g of ds.lists) counts[listKind(g)] += listPaths(values, g).length;
|
||||
const total = counts.image + counts.video + counts.audio;
|
||||
if (total > 12) warns.push(t('wfMedia.h3TotalOver', { n: total }));
|
||||
if (counts.audio > 0 && counts.image === 0 && counts.video === 0) warns.push(t('wfMedia.h3AudioAlone'));
|
||||
return warns;
|
||||
}
|
||||
|
||||
/** Same rules the backend enforces before queueing (comfyui/validate.py). */
|
||||
export function valueError(inp: ComfyDynamicInput, raw: string | number): string | null {
|
||||
const label = inp.label;
|
||||
if (inp.kind === 'select') {
|
||||
return inp.options?.length && !inp.options.includes(String(raw)) ? t('studio.errOption', { label }) : null;
|
||||
}
|
||||
if (inp.kind !== 'number') return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return t('studio.errNumber', { label });
|
||||
if (inp.integer && !Number.isInteger(n)) return t('studio.errInteger', { label });
|
||||
if (inp.min != null && n < inp.min) return t('studio.errMin', { label, v: inp.min });
|
||||
if (inp.max != null && n > inp.max) return t('studio.errMax', { label, v: inp.max });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Field errors keyed by nodeId (spec §6.2 wording); '' keys never block. */
|
||||
export function validateForm(
|
||||
schema: ComfyDynamicInput[],
|
||||
ds: DeskSchema,
|
||||
values: FormValues,
|
||||
): Record<string, string> {
|
||||
const errs: Record<string, string> = {};
|
||||
if (ds.prompt?.required) {
|
||||
const v = String(values[ds.prompt.nodeId] ?? '');
|
||||
if (!v.trim()) errs[ds.prompt.nodeId] = t('studio.errPrompt');
|
||||
else if (v.length > 2000) errs[ds.prompt.nodeId] = t('studio.errPromptLong');
|
||||
}
|
||||
const first = ds.slots.find((s) => s.firstFrame);
|
||||
const last = ds.slots.find((s) => s.lastFrame);
|
||||
if (first && last) {
|
||||
const a = String(values[first.inp.nodeId] ?? '');
|
||||
const b = String(values[last.inp.nodeId] ?? '');
|
||||
// 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] = ' ';
|
||||
}
|
||||
}
|
||||
for (const s of ds.slots) {
|
||||
if (!s.inp.required) continue;
|
||||
if (!String(values[s.inp.nodeId] ?? '').trim()) {
|
||||
errs[s.inp.nodeId] = s.missing || t('studio.errUpload', { label: s.label });
|
||||
}
|
||||
}
|
||||
for (const g of ds.lists) {
|
||||
const n = listPaths(values, g).length;
|
||||
const max = g.max ?? 9;
|
||||
if (n < (g.min ?? 1)) {
|
||||
errs[g.nodeId] =
|
||||
g.kind === 'image_list'
|
||||
? t('studio.errNeedRef')
|
||||
: t('studio.errNeedRefMedia', { n: g.min ?? 1, label: g.label });
|
||||
} else if (n > max) {
|
||||
errs[g.nodeId] = t('studio.errRefMax', { max, n, k: n - max });
|
||||
}
|
||||
}
|
||||
for (const inp of [...ds.common, ...ds.advanced]) {
|
||||
if (inp.required && !String(values[inp.nodeId] ?? '').trim()) {
|
||||
errs[inp.nodeId] = t('studio.errFill', { label: inp.label });
|
||||
}
|
||||
}
|
||||
for (const inp of schema) {
|
||||
const raw = values[inp.nodeId];
|
||||
if (raw === undefined || Array.isArray(raw) || String(raw).trim() === '' || errs[inp.nodeId]) continue;
|
||||
const msg = valueError(inp, raw);
|
||||
if (msg) errs[inp.nodeId] = msg;
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
/** Media a slot still waits for — the 「还缺:…」 hint next to 生成. */
|
||||
export function missingMedia(ds: DeskSchema, values: FormValues): Array<{ nodeId: string; label: string }> {
|
||||
const out: Array<{ nodeId: string; label: string }> = [];
|
||||
for (const s of ds.slots) {
|
||||
if (s.inp.required && !String(values[s.inp.nodeId] ?? '').trim()) out.push({ nodeId: s.inp.nodeId, label: s.label });
|
||||
}
|
||||
for (const g of ds.lists) {
|
||||
if (listPaths(values, g).length < (g.min ?? 1)) out.push({ nodeId: g.nodeId, label: g.label });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function usageLabel(u: WorkflowUsage): string {
|
||||
return t(`usage.${u}`);
|
||||
}
|
||||
|
||||
/** Mode badge: the stored usage when we have one, else the wiring shape. */
|
||||
export function modeLabel(wf: Workflow | null, ds: DeskSchema, kind: DeskKind): string {
|
||||
if (wf?.usage) return usageLabel(wf.usage);
|
||||
if (ds.lists.some((g) => g.kind === 'image_list')) return t('studio.modeRefToVideo');
|
||||
if (ds.slots.some((s) => s.firstFrame)) return t('studio.modeImageToVideo');
|
||||
if (kind === 'image' && ds.slots.some((s) => s.mediaKind === 'image' && !s.mask))
|
||||
return t('studio.modeImageEdit');
|
||||
if (ds.slots.length) return ds.slots.map((s) => s.label).join(' · ');
|
||||
return kind === 'image' ? t('studio.modeTextToImage') : t('studio.modeTextToVideo');
|
||||
}
|
||||
|
||||
/** Picker row summary of a workflow's real media inputs, e.g. 参考图 1–9 张. */
|
||||
export function slotSummary(wf: Workflow): string {
|
||||
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
|
||||
const parts = [
|
||||
...ds.slots.map((s) => t('desk.slotOne', { label: s.label })),
|
||||
...ds.lists.map((g) => t('desk.slotRange', { label: g.label, min: g.min ?? 1, max: g.max ?? 9 })),
|
||||
];
|
||||
return parts.length ? parts.join(' · ') : t('desk.promptOnly');
|
||||
}
|
||||
|
||||
/** Short chip text for one common parameter value. */
|
||||
export function chipValue(inp: ComfyDynamicInput, values: FormValues, ds: DeskSchema): string {
|
||||
const raw = values[inp.nodeId] ?? inp.defaultValue ?? '';
|
||||
const v = Array.isArray(raw) ? raw.join(',') : String(raw);
|
||||
if (inp === ds.ratio) {
|
||||
const size = outputSize(ds, values);
|
||||
return size ? `${ratioShort(v)} · ${size.width}×${size.height}` : ratioShort(v);
|
||||
}
|
||||
if (inp === ds.duration) return v ? t('desk.chipSeconds', { v }) : t('studio.durationLabel');
|
||||
if (inp === ds.seed) return t('desk.chipSeed', { v: v.length > 8 ? `${v.slice(0, 8)}…` : v || '—' });
|
||||
if (inp === ds.resolution) return t('desk.chipResolution', { v: v || '—' });
|
||||
return v || inp.label;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { QueryClient } from '@tanstack/svelte-query';
|
||||
import { apiErrorMessage, playgroundApi } from '$lib/api';
|
||||
import type { Job } from '$lib/comfy/types';
|
||||
import { showToast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 撤销删除 (spec-v2 §四.4 删除两级制 · HEL-654 D 批): deleting a single
|
||||
* take/task never opens a dialog. The row leaves the view right away, a
|
||||
* 5-second Toast offers 撤销, and the backend DELETE is only sent when
|
||||
* that window closes without an undo — the backend removes result files,
|
||||
* so an undo after the call would be impossible. Leaving the page (SPA
|
||||
* route change or hard unload) flushes pending deletes instead of
|
||||
* dropping them.
|
||||
*/
|
||||
|
||||
export interface TakeDeleteHandlers {
|
||||
/** Localized toast message for this take. */
|
||||
message: (job: Job) => string;
|
||||
/** Undo: the row comes back — reselect / replay its entrance here. */
|
||||
onUndo?: (job: Job) => void;
|
||||
/** Called after the delete really hit the backend. */
|
||||
onCommitted?: (job: Job) => void;
|
||||
}
|
||||
|
||||
export interface TakeDeleteQueue {
|
||||
/** Ids whose rows should be hidden (commit pending). */
|
||||
readonly pending: SvelteSet<number>;
|
||||
request: (job: Job, handlers: TakeDeleteHandlers) => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
const UNDO_MS = 5000;
|
||||
|
||||
export function createTakeDeleteQueue(client: QueryClient): TakeDeleteQueue {
|
||||
const pending = new SvelteSet<number>();
|
||||
const entries = new Map<
|
||||
number,
|
||||
{
|
||||
job: Job;
|
||||
handlers: TakeDeleteHandlers;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
>();
|
||||
let destroyed = false;
|
||||
|
||||
async function invalidate() {
|
||||
await Promise.allSettled([
|
||||
client.invalidateQueries({ queryKey: ['playground-jobs'] }),
|
||||
client.invalidateQueries({ queryKey: ['jobs'] }),
|
||||
]);
|
||||
}
|
||||
|
||||
async function commit(id: number) {
|
||||
const entry = entries.get(id);
|
||||
if (!entry) return;
|
||||
entries.delete(id);
|
||||
try {
|
||||
await playgroundApi.deleteJob(id);
|
||||
pending.delete(id);
|
||||
entry.handlers.onCommitted?.(entry.job);
|
||||
} catch (err) {
|
||||
// Delete failed: the take stays — bring the row back honestly.
|
||||
pending.delete(id);
|
||||
showToast(
|
||||
t('studio.deleteFailedToast', { detail: apiErrorMessage(err) }),
|
||||
'error',
|
||||
);
|
||||
}
|
||||
await invalidate();
|
||||
}
|
||||
|
||||
/** Hard unload (tab close / external nav): fire-and-forget with keepalive. */
|
||||
function onPageHide() {
|
||||
for (const [id] of entries) {
|
||||
void fetch(`/api/playground/jobs/${id}`, {
|
||||
method: 'DELETE',
|
||||
keepalive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
if (inBrowser) window.removeEventListener('pagehide', onPageHide);
|
||||
// SPA route change: the JS context survives, normal commits are fine.
|
||||
for (const [id] of entries) {
|
||||
const entry = entries.get(id)!;
|
||||
clearTimeout(entry.timer);
|
||||
void commit(id);
|
||||
}
|
||||
}
|
||||
|
||||
const inBrowser = typeof window !== 'undefined';
|
||||
if (inBrowser) window.addEventListener('pagehide', onPageHide);
|
||||
|
||||
return {
|
||||
pending,
|
||||
request(job, handlers) {
|
||||
if (destroyed || pending.has(job.id)) return;
|
||||
pending.add(job.id);
|
||||
const timer = setTimeout(() => void commit(job.id), UNDO_MS);
|
||||
entries.set(job.id, { job, handlers, timer });
|
||||
showToast(handlers.message(job), 'info', UNDO_MS, {
|
||||
label: t('toast.undo'),
|
||||
run: () => {
|
||||
const e = entries.get(job.id);
|
||||
if (!e) return;
|
||||
clearTimeout(e.timer);
|
||||
entries.delete(job.id);
|
||||
pending.delete(job.id);
|
||||
handlers.onUndo?.(job);
|
||||
},
|
||||
});
|
||||
},
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { resolutionSelectorSize } from '$lib/comfy/resolution';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { classifySchema, inputRole, isFirstFrame, isLastFrame, isMedia } from './schema';
|
||||
|
||||
/** Read-only views of a finished / running take (one playground job). */
|
||||
|
||||
export type FeedFilter = 'all' | 'image' | 'video' | 'failed';
|
||||
|
||||
export function jobValues(job: Job): Record<string, unknown> {
|
||||
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function jobValueByRole(job: Job, wf: Workflow | undefined, ...roles: string[]): string | null {
|
||||
if (!wf) return null;
|
||||
const vals = jobValues(job);
|
||||
for (const inp of wf.input_schema ?? []) {
|
||||
const r = inputRole(inp);
|
||||
if (r && roles.includes(r)) {
|
||||
const v = vals[inp.nodeId];
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function promptOf(job: Job, wf: Workflow | undefined): string {
|
||||
return jobValueByRole(job, wf, 'prompt') ?? '';
|
||||
}
|
||||
|
||||
export interface TakeInput {
|
||||
label: string;
|
||||
path: string;
|
||||
kind: 'image' | 'video' | 'audio';
|
||||
}
|
||||
|
||||
/** Media the take was generated from, in workflow order. */
|
||||
export function inputsOf(job: Job, wf: Workflow | undefined): TakeInput[] {
|
||||
if (!wf) return [];
|
||||
const kind = wf.kind;
|
||||
const vals = jobValues(job);
|
||||
const out: TakeInput[] = [];
|
||||
let refIdx = 0;
|
||||
for (const inp of wf.input_schema ?? []) {
|
||||
if (!isMedia(inp)) continue;
|
||||
const v = vals[inp.nodeId];
|
||||
const list = Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : [];
|
||||
const mk = inp.kind.startsWith('video') ? 'video' : inp.kind.startsWith('audio') ? 'audio' : 'image';
|
||||
list.forEach((p, i) => {
|
||||
if (typeof p !== 'string' || !p) return;
|
||||
let label = inp.label;
|
||||
if (inp.kind === 'image_list') label = t('studio.slotRefN', { n: i + 1 });
|
||||
else if (isFirstFrame(inp, kind)) label = t('studio.slotFirst');
|
||||
else if (isLastFrame(inp, kind)) label = t('studio.slotLast');
|
||||
else if (inp.kind === 'image' && inputRole(inp) !== 'mask') label = t('studio.slotRefN', { n: ++refIdx });
|
||||
out.push({ label, path: p, kind: mk });
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 888×1184 · 种子 42 · 5 秒 — values that differ per take. */
|
||||
export function specOf(job: Job, wf: Workflow | undefined): Array<{ label: string; value: string }> {
|
||||
const out: Array<{ label: string; value: string }> = [];
|
||||
if (!wf) return out;
|
||||
const w = jobValueByRole(job, wf, 'width');
|
||||
const h = jobValueByRole(job, wf, 'height');
|
||||
if (w && h) out.push({ label: t('desk.specSize'), value: `${w}×${h}` });
|
||||
else {
|
||||
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
|
||||
if (ds.ratio?.resolution) {
|
||||
const vals = jobValues(job);
|
||||
const size = resolutionSelectorSize(
|
||||
String(vals[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
|
||||
Number(vals[ds.ratio.resolution.megapixels] ?? ds.megapixels?.defaultValue),
|
||||
ds.ratio.resolution.multiple,
|
||||
);
|
||||
if (size) out.push({ label: t('desk.specSize'), value: `${size.width}×${size.height}` });
|
||||
}
|
||||
}
|
||||
const seed = jobValueByRole(job, wf, 'seed');
|
||||
if (seed) out.push({ label: t('studio.seedLabel'), value: seed });
|
||||
const dur = jobValueByRole(job, wf, 'duration');
|
||||
if (dur) out.push({ label: t('studio.durationLabel'), value: t('desk.chipSeconds', { v: dur }) });
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isVideoOutput(job: Job, path: string): boolean {
|
||||
return job.kind === 'video' && !/\.(png|jpe?g|webp)$/i.test(path);
|
||||
}
|
||||
|
||||
export function elapsedSec(job: Job, nowMs: number): number | null {
|
||||
const from = job.started_at ? parseDbTs(job.started_at) : parseDbTs(job.created_at);
|
||||
if (!Number.isFinite(from)) return null;
|
||||
const to = job.completed_at ? parseDbTs(job.completed_at) : nowMs;
|
||||
return Math.max(0, (to - from) / 1000);
|
||||
}
|
||||
|
||||
export function fmtDur(sec: number | null): string {
|
||||
if (sec == null) return '—';
|
||||
if (sec < 60) return t('desk.durSec', { s: Math.floor(sec) });
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return t('desk.durMin', { m, s: String(s).padStart(2, '0') });
|
||||
}
|
||||
|
||||
export function fmtClock(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 今天 / 昨天 / 9月28日 heading for the feed. */
|
||||
export function dayLabel(ms: number, nowMs: number): string {
|
||||
const d = new Date(ms);
|
||||
const today = new Date(nowMs);
|
||||
const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
||||
const diff = Math.round((startOf(today) - startOf(d)) / 86_400_000);
|
||||
if (diff === 0) return t('desk.today');
|
||||
if (diff === 1) return t('desk.yesterday');
|
||||
return t('desk.dayDate', { m: d.getMonth() + 1, d: d.getDate() });
|
||||
}
|
||||
|
||||
export function matchesFilter(job: Job, filter: FeedFilter): boolean {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'failed') return jobStatusInfo(job).tone === 'fail';
|
||||
return job.kind === filter;
|
||||
}
|
||||
@@ -39,7 +39,8 @@
|
||||
function segClass(key: string): string {
|
||||
const state = stats.stage_status?.[key];
|
||||
if (state === 'confirmed') return 'seg-confirmed';
|
||||
if (state === 'running' || state === 'awaiting_confirm') return 'seg-draft';
|
||||
if (state === 'running') return 'seg-running';
|
||||
if (state === 'awaiting_confirm') return 'seg-draft';
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -51,7 +52,12 @@
|
||||
'var(--avatar-4)',
|
||||
'var(--avatar-5)',
|
||||
];
|
||||
const placeholder = $derived(PLACEHOLDER_GRADIENTS[project.id % PLACEHOLDER_GRADIENTS.length]);
|
||||
const placeholderIdx = $derived(project.id % PLACEHOLDER_GRADIENTS.length);
|
||||
const placeholder = $derived(PLACEHOLDER_GRADIENTS[placeholderIdx]);
|
||||
// HEL-680 灰阶封面占位:1–2 亮阶配深图标,3–5 暗阶配浅图标
|
||||
const placeholderIconColor = $derived(
|
||||
placeholderIdx <= 1 ? 'var(--accent-ink)' : 'var(--text)',
|
||||
);
|
||||
|
||||
const coverUrl = $derived(assetUrl(project.cover_path));
|
||||
|
||||
@@ -70,7 +76,7 @@
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.running')}` };
|
||||
}
|
||||
if (state === 'awaiting_confirm') {
|
||||
return { cls: 'st-progress', 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')}` };
|
||||
@@ -173,7 +179,12 @@
|
||||
onerror={() => (coverFailed = true)}
|
||||
/>
|
||||
{:else}
|
||||
<span class="cover-ph" style:background={placeholder} aria-hidden="true">
|
||||
<span
|
||||
class="cover-ph"
|
||||
style:background={placeholder}
|
||||
style:color={placeholderIconColor}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="film" size={22} />
|
||||
</span>
|
||||
{/if}
|
||||
@@ -329,7 +340,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(22, 19, 10, 0.82);
|
||||
color: color-mix(in srgb, var(--accent-ink) 82%, transparent);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.status-tag {
|
||||
@@ -340,7 +351,7 @@
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
background: rgba(13, 14, 17, 0.78);
|
||||
background: var(--media-scrim);
|
||||
border: 1px solid var(--border-strong);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
@@ -350,7 +361,10 @@
|
||||
color: var(--text-2);
|
||||
}
|
||||
.st-progress {
|
||||
color: var(--accent);
|
||||
color: var(--run);
|
||||
}
|
||||
.st-pending {
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-done {
|
||||
color: var(--success);
|
||||
@@ -377,7 +391,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.arrow {
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
@@ -409,8 +423,11 @@
|
||||
border-radius: 2px;
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.seg-running {
|
||||
background: var(--run);
|
||||
}
|
||||
.seg-draft {
|
||||
background: var(--accent);
|
||||
background: var(--warn);
|
||||
}
|
||||
.seg-confirmed {
|
||||
background: var(--success);
|
||||
@@ -425,8 +442,8 @@
|
||||
margin-left: auto;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -462,7 +479,7 @@
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: rgba(13, 14, 17, 0.6);
|
||||
background: var(--media-scrim);
|
||||
color: var(--text-2);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
<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';
|
||||
|
||||
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
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 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;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void query;
|
||||
activeIndex = 0;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) query = '';
|
||||
});
|
||||
|
||||
function optionId(index: number) {
|
||||
return `${listId}-${index}`;
|
||||
}
|
||||
|
||||
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 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());
|
||||
}
|
||||
</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>
|
||||
|
||||
<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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { projects } from '$lib/api';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from './ShellPop.svelte';
|
||||
|
||||
/**
|
||||
* 顶栏范围胶囊 — the project picker that replaced the old sidebar project
|
||||
* entry: shows the project the page belongs to (or 全部项目) and lists
|
||||
* recent projects, each opening its five-stage workbench.
|
||||
*/
|
||||
interface Props {
|
||||
projectId: number | null;
|
||||
onProjectsList: boolean;
|
||||
}
|
||||
|
||||
let { projectId, onProjectsList }: Props = $props();
|
||||
|
||||
const RECENT_LIMIT = 8;
|
||||
let open = $state(false);
|
||||
|
||||
const projectsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['projects'],
|
||||
queryFn: projects.list,
|
||||
enabled: open || projectId != null,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
);
|
||||
|
||||
const all = $derived($projectsQuery.data ?? []);
|
||||
const current = $derived(projectId == null ? null : (all.find((p) => p.id === projectId) ?? null));
|
||||
const recent = $derived(
|
||||
[...all]
|
||||
.sort((a, b) => (parseDbTs(b.updated_at) || 0) - (parseDbTs(a.updated_at) || 0))
|
||||
.slice(0, RECENT_LIMIT),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="scope">
|
||||
<button
|
||||
type="button"
|
||||
class="status-chip scope-btn"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={t('shell.scopeHint')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span class="scope-label">{current?.title ?? t('shell.scopeAll')}</span>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
|
||||
<ShellPop bind:open label={t('shell.scopeHint')} width="300px">
|
||||
<a
|
||||
class="a-menu-item"
|
||||
href="/"
|
||||
aria-current={onProjectsList ? 'page' : undefined}
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
<Icon name="folder" size={15} />
|
||||
<span class="a-menu-label">{t('shell.scopeAllList')}</span>
|
||||
{#if all.length > 0}<span class="a-menu-meta mono">{all.length}</span>{/if}
|
||||
</a>
|
||||
<div class="a-menu-group">{t('shell.scopeRecent')}</div>
|
||||
{#if $projectsQuery.isPending}
|
||||
<p class="scope-note">{t('common.loading')}…</p>
|
||||
{:else if $projectsQuery.isError}
|
||||
<p class="scope-note err">{t('shell.scopeLoadFailed')}</p>
|
||||
{:else if recent.length === 0}
|
||||
<a class="a-menu-item" href="/" onclick={() => (open = false)}>
|
||||
<Icon name="plus" size={15} />
|
||||
<span class="a-menu-label">{t('shell.scopeEmpty')}</span>
|
||||
</a>
|
||||
{:else}
|
||||
{#each recent as project (project.id)}
|
||||
<a
|
||||
class="a-menu-item"
|
||||
href="/workbench/{project.id}"
|
||||
aria-current={project.id === projectId ? 'true' : undefined}
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
<Icon name="story" size={15} />
|
||||
<span class="a-menu-label">{project.title}</span>
|
||||
{#if project.stats}
|
||||
<span class="a-menu-meta">
|
||||
{t('shell.scopeStage', { n: project.stats.stage_confirmed })}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scope {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scope-btn {
|
||||
max-width: 220px;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.scope-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.scope-note {
|
||||
margin: 0;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.scope-note.err {
|
||||
color: var(--err);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.scope-btn {
|
||||
max-width: 132px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { workflows, type Job } from '$lib/api';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { jobSourceLabel } from '$lib/jobLabels';
|
||||
import { jobStatusInfo, type JobStatusTone } from '$lib/jobStatus';
|
||||
import ShellPop from './ShellPop.svelte';
|
||||
|
||||
/**
|
||||
* 顶栏队列胶囊 + 任务托盘 (§二). The dot is cyan only while something is
|
||||
* actually generating — queued work stays gray, as on the task page.
|
||||
*/
|
||||
let { jobs }: { jobs: Job[] } = $props();
|
||||
|
||||
const TRAY_LIMIT = 6;
|
||||
const TAG_CLASS: Record<JobStatusTone, string> = { ok: 'ok', run: 'run', queue: '', fail: 'err' };
|
||||
|
||||
let open = $state(false);
|
||||
let capsule = $state<HTMLButtonElement | null>(null);
|
||||
|
||||
const workflowsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['workflows'],
|
||||
queryFn: workflows.list,
|
||||
enabled: open,
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
);
|
||||
|
||||
const active = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running'));
|
||||
const running = $derived(active.filter((j) => jobStatusInfo(j).tone === 'run').length);
|
||||
const queued = $derived(active.length - running);
|
||||
const recent = $derived([...jobs].sort((a, b) => b.id - a.id).slice(0, TRAY_LIMIT));
|
||||
|
||||
const label = $derived.by(() => {
|
||||
const parts: string[] = [];
|
||||
if (running > 0) parts.push(t('shell.queueRunning', { n: running }));
|
||||
if (queued > 0) parts.push(t('shell.queueQueued', { n: queued }));
|
||||
return parts.length ? parts.join(' · ') : t('shell.queueIdle');
|
||||
});
|
||||
|
||||
// 任务数变化时胶囊轻弹一次(320ms,只播一次).
|
||||
let lastCount: number | null = null;
|
||||
$effect(() => {
|
||||
const count = active.length;
|
||||
if (lastCount !== null && count !== lastCount && capsule) {
|
||||
capsule.classList.remove('bump');
|
||||
void capsule.offsetWidth;
|
||||
capsule.classList.add('bump');
|
||||
}
|
||||
lastCount = count;
|
||||
});
|
||||
|
||||
function workflowName(job: Job): string {
|
||||
return (
|
||||
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)?.name ??
|
||||
t('studio.jobName', { id: job.id })
|
||||
);
|
||||
}
|
||||
|
||||
function timeOf(job: Job): string {
|
||||
const ts = parseDbTs(job.created_at);
|
||||
if (!Number.isFinite(ts)) return '—';
|
||||
const d = new Date(ts);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tray">
|
||||
<button
|
||||
bind:this={capsule}
|
||||
type="button"
|
||||
class="status-chip queue-btn"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onclick={() => (open = !open)}
|
||||
onanimationend={() => capsule?.classList.remove('bump')}
|
||||
>
|
||||
<span class="status-chip-dot {running > 0 ? 'run' : 'idle'}" aria-hidden="true"></span>
|
||||
<span class="queue-label">{label}</span>
|
||||
<span class="queue-count mono">{active.length}</span>
|
||||
</button>
|
||||
|
||||
<ShellPop bind:open label={t('shell.trayTitle')} align="end" width="340px">
|
||||
<div class="a-menu-group">{t('shell.trayRecent')}</div>
|
||||
{#if recent.length === 0}
|
||||
<p class="tray-note">{t('shell.trayEmpty')}</p>
|
||||
{:else}
|
||||
{#each recent as job (job.id)}
|
||||
{@const info = jobStatusInfo(job)}
|
||||
<a class="a-menu-item tray-row" href="/tasks" title={info.detail} onclick={() => (open = false)}>
|
||||
<span class="tray-copy">
|
||||
<span class="a-menu-label">{workflowName(job)}</span>
|
||||
<span class="tray-meta">{jobSourceLabel(job)} · <span class="mono">{timeOf(job)}</span></span>
|
||||
</span>
|
||||
<span class="a-tag {TAG_CLASS[info.tone]}">{info.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
<a class="a-menu-item tray-all" href="/tasks" onclick={() => (open = false)}>
|
||||
<span class="a-menu-label">{t('shell.trayAll')}</span>
|
||||
</a>
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tray {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.queue-btn:global(.bump) {
|
||||
animation: queue-bump 320ms var(--ease-out);
|
||||
}
|
||||
@keyframes queue-bump {
|
||||
40% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
.queue-count {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.queue-label {
|
||||
display: none;
|
||||
}
|
||||
.queue-count {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
.tray-row {
|
||||
align-items: center;
|
||||
}
|
||||
.tray-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.tray-meta {
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tray-note {
|
||||
margin: 0;
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-5);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.tray-all {
|
||||
margin-top: var(--sp-1);
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: 0 0 var(--r-md) var(--r-md);
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cubicOut, quintOut } from 'svelte/easing';
|
||||
|
||||
/**
|
||||
* Anchored pop for the shell (§四.3 pop): opens 180ms from its trigger,
|
||||
* closes on Esc / outside press, Tab cycles inside, ↑↓ move between rows.
|
||||
* At ≤760px it becomes a bottom sheet. Render it next to its trigger
|
||||
* inside a `position: relative` wrapper — that wrapper counts as inside.
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
label: string;
|
||||
align?: 'start' | 'end';
|
||||
/** `top` opens above the trigger (triggers near the bottom edge, e.g. the composer). */
|
||||
placement?: 'bottom' | 'top';
|
||||
width?: string;
|
||||
/** Set false when the content runs its own ↑↓ roving selection. */
|
||||
arrowRoving?: boolean;
|
||||
onclose?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
label,
|
||||
align = 'start',
|
||||
placement = 'bottom',
|
||||
width = '300px',
|
||||
arrowRoving = true,
|
||||
onclose,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
const SHEET_QUERY = '(max-width: 760px)';
|
||||
|
||||
let panel = $state<HTMLDivElement | null>(null);
|
||||
let scrim = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function focusables(): HTMLElement[] {
|
||||
if (!panel) return [];
|
||||
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
open = false;
|
||||
return;
|
||||
}
|
||||
const list = focusables();
|
||||
if (list.length === 0) return;
|
||||
const current = document.activeElement as HTMLElement | null;
|
||||
const index = current ? list.indexOf(current) : -1;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
if (!arrowRoving || current?.tagName === 'SELECT') return;
|
||||
event.preventDefault();
|
||||
const step = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const next = index < 0 ? (step > 0 ? 0 : list.length - 1) : index + step;
|
||||
list[(next + list.length) % list.length].focus();
|
||||
} else if (event.key === 'Tab') {
|
||||
if (event.shiftKey && index <= 0) {
|
||||
event.preventDefault();
|
||||
list[list.length - 1].focus();
|
||||
} else if (!event.shiftKey && (index < 0 || index === list.length - 1)) {
|
||||
event.preventDefault();
|
||||
list[0].focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const host = panel?.parentElement;
|
||||
const target = event.target as Node;
|
||||
if (!host) return;
|
||||
if (!host.contains(target) || target === scrim) open = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const invoker = document.activeElement as HTMLElement | null;
|
||||
document.addEventListener('keydown', handleKeydown, true);
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
requestAnimationFrame(() => focusables()[0]?.focus());
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown, true);
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
const focusWasInside =
|
||||
document.activeElement === document.body || !!panel?.contains(document.activeElement);
|
||||
if (focusWasInside && invoker?.isConnected) invoker.focus();
|
||||
onclose?.();
|
||||
};
|
||||
});
|
||||
|
||||
function cssMs(name: string, fallback: number): number {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(name);
|
||||
const ms = parseFloat(raw);
|
||||
return Number.isFinite(ms) ? ms : fallback;
|
||||
}
|
||||
|
||||
function popIn(_node: Element) {
|
||||
const reduced = document.documentElement.dataset.reduce === '1';
|
||||
const sheet = window.matchMedia(SHEET_QUERY).matches;
|
||||
if (reduced) {
|
||||
return { duration: cssMs('--d-state', 120), css: (t: number) => `opacity: ${t}` };
|
||||
}
|
||||
if (sheet) {
|
||||
return {
|
||||
duration: cssMs('--d-drawer', 280),
|
||||
easing: quintOut,
|
||||
css: (t: number) => `transform: translateY(${(1 - t) * 100}%)`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
duration: cssMs('--d-pop', 180),
|
||||
easing: cubicOut,
|
||||
css: (t: number) => `opacity: ${t}; transform: scale(${0.96 + 0.04 * t})`,
|
||||
};
|
||||
}
|
||||
|
||||
function fadeScrim(_node: Element) {
|
||||
return { duration: cssMs('--d-pop', 180), css: (t: number) => `opacity: ${t}` };
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="pop-scrim" bind:this={scrim} aria-hidden="true" transition:fadeScrim></div>
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="shell-pop a-float align-{align} place-{placement}"
|
||||
style:--pop-w={width}
|
||||
role="dialog"
|
||||
aria-label={label}
|
||||
transition:popIn
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.shell-pop {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--sp-2));
|
||||
z-index: 60;
|
||||
width: var(--pop-w);
|
||||
max-width: calc(100vw - 2 * var(--sp-5));
|
||||
max-height: min(520px, 70vh);
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-2);
|
||||
box-sizing: border-box;
|
||||
transform-origin: top left;
|
||||
}
|
||||
.shell-pop.align-start {
|
||||
left: 0;
|
||||
}
|
||||
.shell-pop.align-end {
|
||||
right: 0;
|
||||
transform-origin: top right;
|
||||
}
|
||||
.shell-pop.place-top {
|
||||
top: auto;
|
||||
bottom: calc(100% + var(--sp-2));
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
.shell-pop.place-top.align-end {
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
.pop-scrim {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.pop-scrim {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 59;
|
||||
background: var(--overlay);
|
||||
}
|
||||
.shell-pop,
|
||||
.shell-pop.place-top {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
max-height: 78vh;
|
||||
border-radius: var(--r-lg) var(--r-lg) 0 0;
|
||||
padding: var(--sp-3) var(--sp-3) calc(var(--sp-3) + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
|
||||
export type NavId =
|
||||
| 'projects'
|
||||
| 'workbench'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'tasks'
|
||||
| 'agents'
|
||||
| 'connections'
|
||||
| 'models'
|
||||
| 'library'
|
||||
| 'settings';
|
||||
|
||||
export interface NavItem {
|
||||
id: NavId;
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
}
|
||||
|
||||
// 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' },
|
||||
];
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
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),
|
||||
);
|
||||
@@ -1,37 +1,33 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* A-style media upload slot (spec-v2 §5 上传槽, ref-upload-spec §2):
|
||||
* empty = 1.5px dashed border + "click or drop" + format line, drag-hover
|
||||
* = amber dashed, filled = solid border + thumb + name + size + 替换 + ✕,
|
||||
* error = red border + red reason.
|
||||
* Single media input slot as a composer tile (spec-v2 §3.3 输入槽):
|
||||
* empty = dashed + "+", drag-hover = gold, uploading = ring, filled = thumb
|
||||
* (click to replace) with a ✕ on hover/focus. `need` = required but empty
|
||||
* after a blocked 生成 (amber); `error` = rejected file / invalid (red).
|
||||
*/
|
||||
interface Props {
|
||||
label: string;
|
||||
/** Hint on the right of the label, e.g. 必填 · 1 张 / 可从生图结果送入. */
|
||||
/** Hint after the label, e.g. 必填 · 1 张. */
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
/** Current value: a server-side path ('' = empty). */
|
||||
value: string;
|
||||
kind: 'image' | 'video' | 'audio';
|
||||
/** Thumb aspect: wide = 16:9 (first/last frame slots). */
|
||||
aspect?: 'square' | 'wide';
|
||||
/** Empty-state main line (defaults to 点击或拖入). */
|
||||
emptyText?: string;
|
||||
/** Empty-state mono line, e.g. accepted formats and size limit. */
|
||||
emptySub?: string;
|
||||
/** Byte size of the current file when known. */
|
||||
size?: number;
|
||||
uploading?: boolean;
|
||||
uploadName?: string;
|
||||
need?: boolean;
|
||||
error?: string;
|
||||
/** Show the "pick from image results" link (video-page image slots). */
|
||||
/** Show the 从结果选 action (image slots fed from finished takes). */
|
||||
allowFromResults?: boolean;
|
||||
/** Anchor id so 「还缺:…」 can jump focus here. */
|
||||
anchorId?: string;
|
||||
onFile: (file: File) => void;
|
||||
onFromResults?: () => void;
|
||||
onClear: () => void;
|
||||
@@ -43,14 +39,13 @@
|
||||
required = false,
|
||||
value,
|
||||
kind,
|
||||
aspect = 'square',
|
||||
emptyText,
|
||||
emptySub,
|
||||
size,
|
||||
uploading = false,
|
||||
uploadName,
|
||||
need = false,
|
||||
error,
|
||||
allowFromResults = false,
|
||||
anchorId,
|
||||
onFile,
|
||||
onFromResults,
|
||||
onClear,
|
||||
@@ -66,7 +61,10 @@
|
||||
dims = '';
|
||||
});
|
||||
|
||||
const meta = $derived([dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '));
|
||||
const filled = $derived(!!value && !broken);
|
||||
const meta = $derived(
|
||||
[uploadBaseName(value), dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '),
|
||||
);
|
||||
|
||||
function accept(): string {
|
||||
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
|
||||
@@ -87,17 +85,34 @@
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragDepth = 0;
|
||||
if (uploading) return;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) onFile(file);
|
||||
}
|
||||
|
||||
const dragHandlers = {
|
||||
ondragenter: (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
},
|
||||
ondragleave: () => (dragDepth = Math.max(0, dragDepth - 1)),
|
||||
ondragover: (e: DragEvent) => e.preventDefault(),
|
||||
ondrop: onDrop,
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="media-slot" class:wide={aspect === 'wide'}>
|
||||
<div class="slot-label">
|
||||
<span class="lbl">{label}{#if required}<em>*</em>{/if}</span>
|
||||
{#if hint}<span class="slot-hint">{hint}</span>{/if}
|
||||
<div class="a-slot">
|
||||
<div class="a-slot-label">
|
||||
<span>{label}</span>
|
||||
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
|
||||
{#if hint}<span>{hint}</span>{/if}
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -113,110 +128,61 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if value && !broken}
|
||||
<div class="slot-filled" class:invalid={!!error}>
|
||||
<div class="slot-thumb">
|
||||
{#if uploading}
|
||||
<Spinner size="sm" />
|
||||
{:else if kind === 'image'}
|
||||
<img src={assetUrl(value)} alt={label} onload={noteDims} onerror={() => (broken = true)} />
|
||||
{:else if kind === 'video'}
|
||||
<video src={assetUrl(value)} preload="metadata" muted></video>
|
||||
<span class="kind-badge" aria-hidden="true"><Icon name="film" size={12} /></span>
|
||||
{:else}
|
||||
<span class="kind-badge" aria-hidden="true"><Icon name="music" size={12} /></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="slot-meta">
|
||||
<span class="name" title={uploading ? uploadName : uploadBaseName(value)}>
|
||||
{uploading ? uploadName : uploadBaseName(value)}
|
||||
</span>
|
||||
{#if meta && !uploading}<span class="mono dims">{meta}</span>{/if}
|
||||
</div>
|
||||
<div class="slot-actions">
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="mini-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="mini-link" disabled={uploading} onclick={pick}>
|
||||
{t('studio.refReplace')}
|
||||
</button>
|
||||
<div class="a-slot-row">
|
||||
{#if filled}
|
||||
<div class="a-tile" class:drag={dragDepth > 0} class:bad={!!error} {...dragHandlers}>
|
||||
<button
|
||||
type="button"
|
||||
class="slot-clear"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
id={anchorId}
|
||||
class="a-tile-hit"
|
||||
title={`${t('studio.refReplace')} · ${meta}`}
|
||||
aria-label={`${t('studio.refReplace')} ${label}`}
|
||||
disabled={uploading}
|
||||
onclick={onClear}
|
||||
onclick={pick}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
{#if kind === 'image'}
|
||||
<img src={assetUrl(value)} alt="" onload={noteDims} onerror={() => (broken = true)} />
|
||||
{:else if kind === 'video'}
|
||||
<video src={assetUrl(value)} preload="metadata" muted playsinline></video>
|
||||
{:else}
|
||||
<Icon name="music" size={18} />
|
||||
{/if}
|
||||
</button>
|
||||
{#if uploading}
|
||||
<span class="a-tile-ring" title={uploadName}><i></i></span>
|
||||
{:else}
|
||||
<button type="button" class="a-tile-x" aria-label={`${t('studio.deleteBtn')} ${label}`} onclick={onClear}>
|
||||
<Icon name="close" size={10} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="slot-empty"
|
||||
class:drag={dragDepth > 0}
|
||||
class:invalid={!!error}
|
||||
class:wide={aspect === 'wide'}
|
||||
disabled={uploading}
|
||||
aria-busy={uploading || undefined}
|
||||
onclick={pick}
|
||||
ondragenter={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
}}
|
||||
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if uploading}
|
||||
<Spinner size="sm" />
|
||||
<span class="pick-text">{uploadName ?? t('studio.slotPick')}</span>
|
||||
{:else}
|
||||
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'image'} size={16} />
|
||||
<span class="pick-text">{emptyText ?? t('studio.slotPick')}</span>
|
||||
{#if emptySub}<span class="pick-sub mono">{emptySub}</span>{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="from-results-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResults')}
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
id={anchorId}
|
||||
class="a-tile add"
|
||||
class:drag={dragDepth > 0}
|
||||
class:need={need && !error}
|
||||
class:bad={!!error}
|
||||
title={t('studio.slotPick')}
|
||||
aria-label={`${label} · ${t('studio.slotPick')}`}
|
||||
aria-busy={uploading || undefined}
|
||||
disabled={uploading}
|
||||
onclick={pick}
|
||||
{...dragHandlers}
|
||||
>
|
||||
{#if uploading}
|
||||
<span class="a-tile-ring" title={uploadName}><i></i></span>
|
||||
{:else}
|
||||
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'plus'} size={18} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if error}<span class="slot-error" role="alert">{error}</span>{/if}
|
||||
</div>
|
||||
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.media-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.slot-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lbl em {
|
||||
color: var(--error);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.slot-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -226,164 +192,4 @@
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.slot-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 86px;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
color 150ms;
|
||||
}
|
||||
.media-slot.wide .slot-empty {
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.slot-empty:hover:not(:disabled) {
|
||||
color: var(--text-2);
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
.slot-empty.drag {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.slot-empty.invalid {
|
||||
border-color: rgba(229, 72, 77, 0.65);
|
||||
}
|
||||
.slot-empty:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.pick-text {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.pick-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.from-results-link {
|
||||
align-self: flex-end;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.from-results-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.slot-filled {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
padding: 8px;
|
||||
}
|
||||
.slot-filled.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.slot-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.media-slot.wide .slot-thumb {
|
||||
width: 88px;
|
||||
height: 50px;
|
||||
}
|
||||
.slot-thumb img,
|
||||
.slot-thumb video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.slot-thumb .kind-badge {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.slot-meta {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-size: 12.5px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dims {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.slot-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mini-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-link:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini-link:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.slot-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.slot-clear:hover {
|
||||
color: var(--error);
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.slot-error {
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
|
||||
import {
|
||||
IMAGE_SLOT_ACCEPT,
|
||||
formatBytes,
|
||||
mediaSlotAccept,
|
||||
uploadBaseName,
|
||||
type MediaSlotKind,
|
||||
} from '$lib/comfy/imageFile';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Reference-image grid (ref-upload-spec §2 `.refgrid`): numbered cells
|
||||
* 参考图 1…N in the order they reach the workflow, each with replace and
|
||||
* ✕; a dashed add cell while below `max`; rejected files show as error
|
||||
* cells that take no place in the list.
|
||||
* Reference-media list as numbered composer tiles (spec-v2 §3.3 参考图):
|
||||
* 1…N in the order they reach the workflow — click a tile to replace it,
|
||||
* ✕ to remove; a dashed add tile while below `max`; rejected files show
|
||||
* as red tiles that take no place in the list.
|
||||
*/
|
||||
interface Reject {
|
||||
name: string;
|
||||
@@ -19,6 +24,8 @@
|
||||
label: string;
|
||||
paths: string[];
|
||||
max: number;
|
||||
/** Media family of the slots — drives thumbnails, accept and copy. */
|
||||
mediaKind?: MediaSlotKind;
|
||||
/** Byte sizes of files uploaded in this session, by server path. */
|
||||
sizes?: Record<string, number>;
|
||||
/** Name of the file being uploaded (add or replace), if any. */
|
||||
@@ -26,8 +33,12 @@
|
||||
/** Index being replaced while uploading (-1 = adding). */
|
||||
uploadingIndex?: number;
|
||||
rejects?: Reject[];
|
||||
/** Below the minimum after a blocked 生成 (amber add tile). */
|
||||
need?: boolean;
|
||||
error?: string;
|
||||
allowFromResults?: boolean;
|
||||
/** Anchor id on the add tile so 「还缺:…」 can jump focus here. */
|
||||
anchorId?: string;
|
||||
onAdd: (files: File[]) => void;
|
||||
onReplace: (index: number, file: File) => void;
|
||||
onRemove: (index: number) => void;
|
||||
@@ -39,12 +50,15 @@
|
||||
label,
|
||||
paths,
|
||||
max,
|
||||
mediaKind = 'image',
|
||||
sizes = {},
|
||||
uploadingName,
|
||||
uploadingIndex = -1,
|
||||
rejects = [],
|
||||
need = false,
|
||||
error,
|
||||
allowFromResults = false,
|
||||
anchorId,
|
||||
onAdd,
|
||||
onReplace,
|
||||
onRemove,
|
||||
@@ -57,9 +71,39 @@
|
||||
let replaceIndex = $state(-1);
|
||||
let dragDepth = $state(0);
|
||||
let dims = $state<Record<string, string>>({});
|
||||
let durations = $state<Record<string, number>>({});
|
||||
|
||||
const uploading = $derived(!!uploadingName);
|
||||
const remaining = $derived(Math.max(0, max - paths.length));
|
||||
const accept = $derived(mediaKind === 'image' ? IMAGE_SLOT_ACCEPT : mediaSlotAccept(mediaKind));
|
||||
const isMedia = $derived(mediaKind !== 'image');
|
||||
const hintLabel = $derived(
|
||||
isMedia
|
||||
? t('studio.refHintMedia', { n: paths.length, max })
|
||||
: t('studio.refHint', { n: paths.length, max }),
|
||||
);
|
||||
|
||||
function noteDuration(path: string, e: Event): void {
|
||||
const el = e.currentTarget as HTMLVideoElement | HTMLAudioElement | null;
|
||||
if (el && Number.isFinite(el.duration)) durations = { ...durations, [path]: el.duration };
|
||||
}
|
||||
|
||||
function noteDims(path: string, e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
|
||||
}
|
||||
|
||||
function metaOf(path: string): string {
|
||||
const d = durations[path];
|
||||
return [
|
||||
uploadBaseName(path),
|
||||
dims[path],
|
||||
d != null && Number.isFinite(d) ? `${d.toFixed(1)}s` : null,
|
||||
sizes[path] != null ? formatBytes(sizes[path]) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function pickAdd() {
|
||||
if (uploading || !addInput) return;
|
||||
@@ -76,26 +120,24 @@
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragDepth = 0;
|
||||
if (uploading) return;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) onAdd(files);
|
||||
}
|
||||
|
||||
function noteDims(path: string, e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
|
||||
}
|
||||
|
||||
function metaOf(path: string): string {
|
||||
return [dims[path], sizes[path] != null ? formatBytes(sizes[path]) : null].filter(Boolean).join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="refgrid-field">
|
||||
<div class="slot-label">
|
||||
<span class="lbl">{label}<em>*</em></span>
|
||||
<span class="slot-hint">{t('studio.refHint', { n: paths.length, max })}</span>
|
||||
<div class="a-slot">
|
||||
<div class="a-slot-label">
|
||||
<span>{label}</span>
|
||||
<span class="req" aria-hidden="true">*</span>
|
||||
<span class="mono">{hintLabel}</span>
|
||||
{#if allowFromResults && onFromResults && remaining > 0}
|
||||
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -104,7 +146,7 @@
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={IMAGE_SLOT_ACCEPT}
|
||||
{accept}
|
||||
multiple
|
||||
onchange={(e) => {
|
||||
const files = Array.from(e.currentTarget.files ?? []);
|
||||
@@ -117,70 +159,86 @@
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={IMAGE_SLOT_ACCEPT}
|
||||
{accept}
|
||||
onchange={(e) => {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
if (f && replaceIndex >= 0) onReplace(replaceIndex, f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="refgrid">
|
||||
<div class="a-slot-row">
|
||||
{#each paths as path, i (path + i)}
|
||||
<div class="ref-cell" class:busy={uploadingIndex === i}>
|
||||
<div class="a-tile">
|
||||
<button
|
||||
type="button"
|
||||
class="ref-thumb"
|
||||
title={t('studio.refReplace')}
|
||||
class="a-tile-hit"
|
||||
title={`${t('studio.refReplace')} · ${metaOf(path)}`}
|
||||
aria-label={`${t('studio.refReplace')} ${label} ${i + 1}`}
|
||||
disabled={uploading}
|
||||
onclick={() => pickReplace(i)}
|
||||
>
|
||||
<img src={assetUrl(path)} alt={t('studio.slotRefN', { n: i + 1 })} onload={(e) => noteDims(path, e)} />
|
||||
<span class="ref-no mono">{i + 1}</span>
|
||||
{#if uploadingIndex === i}
|
||||
<span class="ref-busy"><Spinner size="sm" /></span>
|
||||
{#if mediaKind === 'image'}
|
||||
<img src={assetUrl(path)} alt="" onload={(e) => noteDims(path, e)} />
|
||||
{:else if mediaKind === 'video'}
|
||||
<video
|
||||
src={assetUrl(path)}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
onloadedmetadata={(e) => noteDuration(path, e)}
|
||||
></video>
|
||||
{:else}
|
||||
<Icon name="music" size={18} />
|
||||
<audio src={assetUrl(path)} preload="metadata" onloadedmetadata={(e) => noteDuration(path, e)}
|
||||
></audio>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ref-x"
|
||||
aria-label={t('studio.refRemove', { n: i + 1 })}
|
||||
onclick={() => onRemove(i)}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
</button>
|
||||
<span class="ref-name" title={uploadBaseName(path)}>{uploadBaseName(path)}</span>
|
||||
<span class="ref-meta mono">
|
||||
{metaOf(path)}
|
||||
<button type="button" class="mini-link" onclick={() => pickReplace(i)}>{t('studio.refReplace')}</button>
|
||||
</span>
|
||||
<span class="a-tile-idx">{i + 1}</span>
|
||||
{#if uploadingIndex === i}
|
||||
<span class="a-tile-ring" title={uploadingName}><i></i></span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="a-tile-x"
|
||||
aria-label={isMedia
|
||||
? t('studio.refRemoveMedia', { kind: label, n: i + 1 })
|
||||
: t('studio.refRemove', { n: i + 1 })}
|
||||
onclick={() => onRemove(i)}
|
||||
>
|
||||
<Icon name="close" size={10} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each rejects as rej, i (rej.name + i)}
|
||||
<div class="ref-cell rejected" role="alert">
|
||||
<div class="ref-thumb error">
|
||||
<Icon name="close" size={16} />
|
||||
<span class="mono">{t('studio.refRejectedShort')}</span>
|
||||
</div>
|
||||
<div class="a-tile bad" title={`${rej.name} · ${rej.message}`} role="img" aria-label={rej.name}>
|
||||
<Icon name="alert" size={16} />
|
||||
<button
|
||||
type="button"
|
||||
class="ref-x"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
class="a-tile-x"
|
||||
aria-label={`${t('studio.deleteBtn')} ${rej.name}`}
|
||||
onclick={() => onDismissReject(i)}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
<Icon name="close" size={10} />
|
||||
</button>
|
||||
<span class="ref-name err" title={rej.name}>{rej.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if remaining > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="ref-add"
|
||||
id={anchorId}
|
||||
class="a-tile add"
|
||||
class:drag={dragDepth > 0}
|
||||
class:invalid={!!error}
|
||||
disabled={uploading}
|
||||
class:need={need && !error?.trim()}
|
||||
class:bad={!!error?.trim()}
|
||||
title={isMedia
|
||||
? t('studio.refAddSubMedia', { n: remaining, max })
|
||||
: t('studio.refAddSub', { n: remaining, max })}
|
||||
aria-label={isMedia ? t('studio.refAddMedia', { kind: label }) : t('studio.refAdd')}
|
||||
aria-busy={uploading || undefined}
|
||||
disabled={uploading}
|
||||
onclick={pickAdd}
|
||||
ondragenter={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -191,59 +249,24 @@
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if uploading && uploadingIndex < 0}
|
||||
<Spinner size="sm" />
|
||||
<span class="add-main">{uploadingName}</span>
|
||||
<span class="a-tile-ring" title={uploadingName}><i></i></span>
|
||||
{:else}
|
||||
<Icon name="plus" size={16} />
|
||||
<span class="add-main">{t('studio.refAdd')}</span>
|
||||
<span class="add-sub mono">{t('studio.refAddSub', { n: remaining, max })}</span>
|
||||
<Icon name="plus" size={18} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if allowFromResults && onFromResults && remaining > 0}
|
||||
<button type="button" class="from-results-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResults')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if remaining === 0}
|
||||
<span class="full-note">{t('studio.refFull', { max })}</span>
|
||||
<span class="a-slot-hint">{isMedia ? t('studio.refFullMedia', { max }) : t('studio.refFull', { max })}</span>
|
||||
{/if}
|
||||
{#each rejects as rej, i (rej.name + i)}
|
||||
<span class="slot-error">{rej.message}</span>
|
||||
<span class="a-slot-hint err" role="alert">{rej.message}</span>
|
||||
{/each}
|
||||
{#if error?.trim()}<span class="slot-error" role="alert">{error}</span>{/if}
|
||||
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.refgrid-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.slot-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lbl em {
|
||||
color: var(--error);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.slot-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -253,194 +276,7 @@
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.refgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.ref-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.ref-cell.rejected {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.ref-thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ref-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.ref-thumb.error {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
cursor: default;
|
||||
color: var(--error);
|
||||
font-size: 10px;
|
||||
}
|
||||
.ref-no {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 4px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: var(--text);
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.ref-busy {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.ref-x {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ref-x:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
.ref-name {
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ref-name.err {
|
||||
color: var(--error);
|
||||
}
|
||||
.ref-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
font-size: 9.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mini-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ref-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 110px;
|
||||
padding: 8px 4px;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
color 150ms;
|
||||
}
|
||||
.ref-add:hover:not(:disabled) {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.ref-add.drag {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.ref-add.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.ref-add:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.add-main {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.add-sub {
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.from-results-link {
|
||||
align-self: flex-end;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.from-results-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.full-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.slot-error {
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.refgrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
}
|
||||
audio {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,9 @@
|
||||
import Spinner from './Spinner.svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md';
|
||||
/** `danger` = outlined (list rows); `danger-solid` = confirm dialogs only. */
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-solid';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Stretch to the container width (e.g. a panel's main action). */
|
||||
block?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -32,6 +33,7 @@
|
||||
{type}
|
||||
class="a-btn btn {variant}"
|
||||
class:sm={size === 'sm'}
|
||||
class:lg={size === 'lg'}
|
||||
class:block
|
||||
class:loading
|
||||
disabled={disabled || loading}
|
||||
@@ -49,7 +51,7 @@
|
||||
</button>
|
||||
|
||||
<style>
|
||||
/* Look comes from the shared `.a-btn` rules in app.css (§5). */
|
||||
/* Look comes from the shared `.a-btn` rules in app.css (§四.1). */
|
||||
.btn {
|
||||
position: relative;
|
||||
}
|
||||
@@ -72,10 +74,14 @@
|
||||
justify-content: center;
|
||||
}
|
||||
.btn.primary .btn-spinner {
|
||||
--spinner-track: rgba(22, 19, 10, 0.3);
|
||||
--spinner-arc: var(--accent-contrast);
|
||||
--spinner-track: color-mix(in srgb, var(--accent-ink) 30%, transparent);
|
||||
--spinner-arc: var(--accent-ink);
|
||||
}
|
||||
.btn.danger .btn-spinner {
|
||||
--spinner-arc: var(--error);
|
||||
--spinner-arc: var(--err);
|
||||
}
|
||||
.btn.danger-solid .btn-spinner {
|
||||
--spinner-track: color-mix(in srgb, var(--on-media) 30%, transparent);
|
||||
--spinner-arc: var(--on-media);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,16 +46,18 @@
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
.card.hoverable {
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
transform 150ms ease;
|
||||
border-color var(--d-state) ease,
|
||||
transform var(--d-state) var(--ease-out);
|
||||
}
|
||||
.card.hoverable:hover {
|
||||
border-color: #3f3f46;
|
||||
transform: translateY(-1px);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card.hoverable:hover {
|
||||
border-color: var(--line-2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (open = false)}>{cancelLabel}</Button
|
||||
>
|
||||
<Button variant={danger ? 'danger' : 'primary'} onclick={handleConfirm}
|
||||
<Button variant={danger ? 'danger-solid' : 'primary'} onclick={handleConfirm}
|
||||
>{confirmLabel}</Button
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Drawer — the shared right-side floating layer (HEL-649 §四.3):
|
||||
* slides in over 280ms with --ease-drawer and reverses mid-flight,
|
||||
* focus is trapped while open, Esc / backdrop click close it, and
|
||||
* reduced-motion keeps only a ≤120ms opacity fade (no slide).
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
/** Panel width on >760px screens. */
|
||||
width?: number;
|
||||
dismissible?: boolean;
|
||||
onclose?: () => void;
|
||||
footer?: Snippet;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title,
|
||||
width = 380,
|
||||
dismissible = true,
|
||||
onclose,
|
||||
footer,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const titleId = `drawer-title-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
let panel = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function requestClose() {
|
||||
if (!dismissible) return;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Tab') trapFocus(event);
|
||||
}
|
||||
|
||||
function trapFocus(event: KeyboardEvent) {
|
||||
if (!panel) return;
|
||||
const focusables = Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(FOCUSABLE),
|
||||
).filter((el) => el.getClientRects().length > 0);
|
||||
if (focusables.length === 0) {
|
||||
event.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement;
|
||||
const focusInside = panel.contains(active);
|
||||
if (event.shiftKey && (!focusInside || active === first)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!focusInside || active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) requestClose();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const invoker = document.activeElement as HTMLElement | null;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.addEventListener('keydown', handleKeydown, true);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!panel || panel.contains(document.activeElement)) return;
|
||||
const first = panel.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(first ?? panel).focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeydown, true);
|
||||
if (invoker?.isConnected) invoker.focus();
|
||||
onclose?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="drawer-backdrop" onclick={handleBackdropClick}>
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="drawer-panel"
|
||||
style:--drawer-w="{width}px"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
aria-label={title ? undefined : t('modal.dialog')}
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if title || dismissible}
|
||||
<div class="drawer-header">
|
||||
{#if title}
|
||||
<h2 class="drawer-title" id={titleId}>{title}</h2>
|
||||
{/if}
|
||||
{#if dismissible}
|
||||
<button
|
||||
type="button"
|
||||
class="drawer-close"
|
||||
aria-label={t('modal.closeDialog')}
|
||||
onclick={requestClose}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="drawer-body">
|
||||
{@render children()}
|
||||
</div>
|
||||
{#if footer}
|
||||
<div class="drawer-footer">
|
||||
{@render footer()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: var(--overlay);
|
||||
animation: drawer-fade var(--d-state) ease-out;
|
||||
}
|
||||
.drawer-panel {
|
||||
width: min(var(--drawer-w), 100vw);
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--float-bg);
|
||||
border-radius: var(--r-lg) 0 0 var(--r-lg);
|
||||
box-shadow: var(--shadow-float);
|
||||
outline: none;
|
||||
animation: drawer-slide var(--d-drawer) var(--ease-drawer) both;
|
||||
}
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.drawer-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.drawer-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--d-state) ease,
|
||||
color var(--d-state) ease;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.drawer-close:hover {
|
||||
background: var(--bg-4);
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
.drawer-close:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.drawer-body {
|
||||
padding: 14px 20px 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
}
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes drawer-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes drawer-slide {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
/* 减少动态效果 (§五):去位移,只留 ≤120ms 透明度 */
|
||||
:global(:root[data-reduce='1']) .drawer-panel {
|
||||
animation: drawer-fade var(--d-drawer) ease-out both;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.drawer-panel {
|
||||
animation: drawer-fade var(--d-drawer) ease-out both;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.drawer-panel {
|
||||
width: 100vw;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -144,22 +144,19 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(5, 5, 8, 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
animation: modal-fade 150ms ease-out;
|
||||
background: var(--overlay);
|
||||
animation: modal-fade var(--d-state) ease-out;
|
||||
}
|
||||
.modal-panel {
|
||||
width: min(520px, 100%);
|
||||
max-height: calc(100vh - 96px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||||
background: var(--float-bg);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-float);
|
||||
outline: none;
|
||||
animation: modal-rise 180ms ease-out;
|
||||
animation: modal-rise var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.modal-panel.size-lg {
|
||||
width: min(920px, 100%);
|
||||
@@ -177,7 +174,7 @@
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -194,12 +191,14 @@
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
background-color var(--d-state) ease,
|
||||
color var(--d-state) ease;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.modal-close:hover {
|
||||
background: var(--bg-4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
.modal-close:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
indeterminate?: boolean;
|
||||
label?: string;
|
||||
size?: 'sm' | 'md';
|
||||
/** §5 进度条: running = amber, done = green, failed = red (static). */
|
||||
tone?: 'accent' | 'success' | 'error';
|
||||
/** 进度条: running = 信号青, done = green, failed = red (static). */
|
||||
tone?: 'run' | 'success' | 'error';
|
||||
/** Track only — no label / percentage row. */
|
||||
bare?: boolean;
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
indeterminate = false,
|
||||
label,
|
||||
size = 'md',
|
||||
tone = 'accent',
|
||||
tone = 'run',
|
||||
bare = false,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
{#if indeterminate}
|
||||
<div class="fill indeterminate"></div>
|
||||
{:else}
|
||||
<div class="fill" style:width={pct + '%'}></div>
|
||||
<div class="fill" style:transform="scaleX({pct / 100})"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,8 +75,8 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--surface-3);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-4);
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.progress-sm .track {
|
||||
height: 4px;
|
||||
@@ -86,15 +86,18 @@
|
||||
}
|
||||
.fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
background: var(--run-grad);
|
||||
box-shadow: 0 0 8px var(--run-soft);
|
||||
transform-origin: left;
|
||||
transition: transform var(--d-progress) linear;
|
||||
}
|
||||
.tone-success .fill {
|
||||
background: var(--success);
|
||||
background: var(--ok);
|
||||
box-shadow: none;
|
||||
}
|
||||
.tone-error .fill {
|
||||
background: var(--error);
|
||||
background: var(--err);
|
||||
box-shadow: none;
|
||||
}
|
||||
.fill.indeterminate {
|
||||
position: absolute;
|
||||
@@ -104,10 +107,11 @@
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--accent) 40%,
|
||||
var(--accent) 60%,
|
||||
var(--run) 40%,
|
||||
var(--run) 60%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: none;
|
||||
transition: none;
|
||||
animation: progress-slide 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -19,36 +19,26 @@
|
||||
></div>
|
||||
|
||||
<style>
|
||||
/* §四.9 骨架屏:--bg-3 圆角块,1.4s 呼吸透明度;减少动态时静态。 */
|
||||
.skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-3);
|
||||
border-radius: var(--r-sm);
|
||||
animation: skeleton-breath 1.4s ease-in-out infinite;
|
||||
}
|
||||
.skeleton.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
.skeleton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.06),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: skeleton-shimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
@keyframes skeleton-breath {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton::after {
|
||||
display: none;
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .skeleton {
|
||||
animation: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,27 +6,27 @@
|
||||
|
||||
let { status, label }: Props = $props();
|
||||
|
||||
type Tone = 'success' | 'info' | 'muted' | 'error' | 'warning' | 'accent';
|
||||
type Tone = 'ok' | 'run' | 'muted' | 'err' | 'warn';
|
||||
|
||||
const toneByStatus: Record<string, Tone> = {
|
||||
ready: 'success',
|
||||
done: 'success',
|
||||
completed: 'success',
|
||||
running: 'info',
|
||||
generating: 'info',
|
||||
queued: 'info',
|
||||
ready: 'ok',
|
||||
done: 'ok',
|
||||
completed: 'ok',
|
||||
running: 'run',
|
||||
generating: 'run',
|
||||
in_progress: 'run',
|
||||
queued: 'muted',
|
||||
pending: 'muted',
|
||||
idle: 'muted',
|
||||
draft: 'muted',
|
||||
failed: 'error',
|
||||
error: 'error',
|
||||
paused: 'warning',
|
||||
in_progress: 'accent',
|
||||
failed: 'err',
|
||||
error: 'err',
|
||||
paused: 'warn',
|
||||
};
|
||||
|
||||
const key = $derived(status.trim().toLowerCase());
|
||||
const tone: Tone = $derived(toneByStatus[key] ?? 'muted');
|
||||
const pulsing = $derived(key === 'running');
|
||||
const pulsing = $derived(tone === 'run');
|
||||
</script>
|
||||
|
||||
<span class="chip chip-{tone}">
|
||||
@@ -35,59 +35,48 @@
|
||||
</span>
|
||||
|
||||
<style>
|
||||
/* §四.2 状态标签:20px 高、--r-sm、12px;语义色文字 + 13% 软底。 */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
gap: var(--sp-2);
|
||||
height: 20px;
|
||||
padding: 0 var(--sp-2);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--fs-aux);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-success {
|
||||
color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--success) 35%, transparent);
|
||||
.chip-ok {
|
||||
color: var(--ok);
|
||||
background: var(--ok-soft);
|
||||
}
|
||||
.chip-info {
|
||||
color: var(--info);
|
||||
background: color-mix(in srgb, var(--info) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--info) 35%, transparent);
|
||||
.chip-run {
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.chip-error {
|
||||
color: var(--error);
|
||||
background: color-mix(in srgb, var(--error) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--error) 35%, transparent);
|
||||
.chip-err {
|
||||
color: var(--err);
|
||||
background: var(--err-soft);
|
||||
}
|
||||
.chip-warning {
|
||||
color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--warning) 35%, transparent);
|
||||
}
|
||||
.chip-accent {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
.chip-warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
.chip-muted {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: var(--border);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.dot.pulsing {
|
||||
animation: chip-pulse 1.2s ease-in-out infinite;
|
||||
animation: chip-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes chip-pulse {
|
||||
0%,
|
||||
@@ -103,4 +92,7 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .dot.pulsing {
|
||||
animation: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
let { checked, label, disabled = false, onchange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- §5 开关: 34×19 capsule; on = amber track + dark knob, off = --surface-3. -->
|
||||
<!-- §3.10 开关: 34×19 capsule; on = --accent-lo track + ink knob, off = --bg-4. -->
|
||||
<button
|
||||
type="button"
|
||||
class="switch"
|
||||
@@ -28,38 +28,38 @@
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 19px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-4);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition:
|
||||
background-color 150ms,
|
||||
border-color 150ms;
|
||||
background-color var(--d-state) ease,
|
||||
border-color var(--d-state) ease;
|
||||
}
|
||||
.switch:disabled {
|
||||
opacity: 0.4;
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--text-3);
|
||||
transition:
|
||||
transform 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background-color 150ms;
|
||||
transform var(--d-state) var(--ease-out),
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.switch.on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-lo);
|
||||
border-color: var(--accent-lo);
|
||||
}
|
||||
.switch.on .knob {
|
||||
transform: translateX(15px);
|
||||
background: var(--accent-contrast);
|
||||
background: var(--accent-ink);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,6 +51,7 @@ export const icons = {
|
||||
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
|
||||
// Status
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
alert:
|
||||
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
||||
|
||||
@@ -132,9 +132,7 @@ let {
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(139, 92, 246, 0.08), transparent 55%),
|
||||
#050508;
|
||||
background: var(--stage-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
|
||||
@@ -79,14 +79,14 @@
|
||||
let criticNotes = $state<string[]>([]);
|
||||
|
||||
const preview = createMutation({
|
||||
mutationFn: async (vars?: { force?: boolean }) => {
|
||||
mutationFn: async (vars: { force?: boolean } | void) => {
|
||||
if (!clip && !scene) throw new Error(t('promptPreview.noClipSelected'));
|
||||
return jobsApi.previewPrompt(projectId, {
|
||||
clip_id: clip?.id,
|
||||
scene_id: clip ? undefined : scene?.id,
|
||||
workflow_id: workflow?.id,
|
||||
input_values: compactInputValues(inputValues),
|
||||
force: Boolean(vars?.force),
|
||||
force: Boolean((vars as { force?: boolean } | undefined)?.force),
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
|
||||
@@ -203,7 +203,7 @@ title={t('filmstrip.clipTitle', {
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
z-index: 1;
|
||||
background: #3f3f46;
|
||||
background: var(--bg-4);
|
||||
}
|
||||
|
||||
.clip.status-done .bar {
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import JobChip from './JobChip.svelte';
|
||||
import PromptBlock from './PromptBlock.svelte';
|
||||
import { deskHref } from '$lib/components/generate/context';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
stage: WorkbenchStage;
|
||||
/** Enables the 在生成台调参 link (desk opens with this asset's context). */
|
||||
projectId?: number;
|
||||
onSave?: (content: Record<string, unknown>) => void;
|
||||
onRun?: (instruction?: string) => void;
|
||||
onGenerate?: (payload: {
|
||||
@@ -20,7 +23,7 @@
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
|
||||
let { stage, projectId, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let draft = $state<WorkbenchAsset[]>([]);
|
||||
@@ -78,12 +81,12 @@
|
||||
{#each draft as asset (asset.id)}
|
||||
<div class="asset-edit">
|
||||
<div class="asset-edit-head">
|
||||
<select class="wb-input w-kind" bind:value={asset.kind}>
|
||||
<select class="a-input w-kind" bind:value={asset.kind}>
|
||||
<option value="character">{t('workbench.asset.kindCharacter')}</option>
|
||||
<option value="location">{t('workbench.asset.kindLocation')}</option>
|
||||
<option value="prop">{t('workbench.asset.kindProp')}</option>
|
||||
</select>
|
||||
<input class="wb-input name" bind:value={asset.name} placeholder={t('workbench.asset.namePh')} />
|
||||
<input class="a-input name" bind:value={asset.name} placeholder={t('workbench.asset.namePh')} />
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
@@ -93,15 +96,15 @@
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<input class="wb-input" bind:value={asset.description} placeholder={t('workbench.asset.descPh')} />
|
||||
<input class="a-input" bind:value={asset.description} placeholder={t('workbench.asset.descPh')} />
|
||||
<textarea
|
||||
class="wb-input mono"
|
||||
class="a-input mono"
|
||||
rows="2"
|
||||
bind:value={asset.prompt}
|
||||
placeholder={t('workbench.asset.promptPh')}
|
||||
></textarea>
|
||||
<input
|
||||
class="wb-input mono"
|
||||
class="a-input mono"
|
||||
bind:value={asset.negative_prompt}
|
||||
placeholder={t('workbench.style.negativePh')}
|
||||
/>
|
||||
@@ -109,17 +112,17 @@
|
||||
{/each}
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<button type="button" class="btn ghost" onclick={addAsset}>
|
||||
<button type="button" class="a-btn ghost" onclick={addAsset}>
|
||||
<Icon name="plus" size={13} />
|
||||
{t('workbench.asset.add')}
|
||||
</button>
|
||||
<div class="foot-btns">
|
||||
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
|
||||
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
|
||||
{t('workbench.cancelEdit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
class="a-btn primary"
|
||||
disabled={!draft.some((a) => a.name.trim() && a.prompt.trim())}
|
||||
onclick={save}
|
||||
>
|
||||
@@ -132,7 +135,7 @@
|
||||
<div class="todo-card">
|
||||
<p class="todo-title">{t('workbench.asset.todoTitle')}</p>
|
||||
<p class="todo-body">{t('workbench.asset.todoBody')}</p>
|
||||
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="sparkle" size={14} />
|
||||
{t('workbench.asset.generate')}
|
||||
</button>
|
||||
@@ -168,9 +171,28 @@
|
||||
<PromptBlock label={t('workbench.style.negativeLabel')} text={asset.negative_prompt} />
|
||||
{/if}
|
||||
<div class="asset-foot">
|
||||
{#if projectId}
|
||||
<a
|
||||
class="a-btn ghost sm"
|
||||
href={deskHref({
|
||||
kind: 'image',
|
||||
projectId,
|
||||
stage: 'assets',
|
||||
label: t('desk.ctxAsset', {
|
||||
kind: t(KIND_LABEL[asset.kind] ?? 'workbench.asset.kindProp'),
|
||||
name: asset.name,
|
||||
}),
|
||||
prompt: asset.prompt,
|
||||
negative: asset.negative_prompt || undefined,
|
||||
})}
|
||||
title={t('desk.openInDeskHint')}
|
||||
>
|
||||
{t('desk.openInDesk')}
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost gen"
|
||||
class="a-btn ghost sm"
|
||||
disabled={busy || jobActive(asset.job)}
|
||||
title={t('workbench.asset.generateHint')}
|
||||
onclick={() =>
|
||||
@@ -192,11 +214,11 @@
|
||||
<span class="hint">{t('workbench.asset.summary', { n: assets.length })}</span>
|
||||
<div class="foot-btns">
|
||||
{#if stage.status !== 'running'}
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<Icon name="edit" size={13} />
|
||||
{t('workbench.editDraft')}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('workbench.regenerate')}
|
||||
</button>
|
||||
@@ -294,7 +316,9 @@
|
||||
}
|
||||
.asset-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.retry-link {
|
||||
@@ -354,34 +378,14 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wb-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wb-input.mono {
|
||||
.a-input.mono {
|
||||
font-family: var(--font-a-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.wb-input.name {
|
||||
.a-input.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.wb-input.w-kind {
|
||||
.a-input.w-kind {
|
||||
width: 92px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -408,44 +412,4 @@
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
opacity 150ms;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.btn.gen {
|
||||
color: var(--accent);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,8 +60,8 @@
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.st-running {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.st-done {
|
||||
color: var(--success);
|
||||
@@ -83,7 +83,7 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: wb-chip-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
@keyframes wb-chip-breath {
|
||||
|
||||
@@ -56,15 +56,15 @@
|
||||
<blockquote class="idea-quote">{projectIdea}</blockquote>
|
||||
{/if}
|
||||
<div class="todo-actions">
|
||||
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="sparkle" size={14} />
|
||||
{t('workbench.script.generate')}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" onclick={() => (importOpen = !importOpen)}>
|
||||
<button type="button" class="a-btn ghost" onclick={() => (importOpen = !importOpen)}>
|
||||
<Icon name="upload" size={14} />
|
||||
{t('workbench.script.importBtn')}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" onclick={startEdit}>
|
||||
<button type="button" class="a-btn ghost" onclick={startEdit}>
|
||||
<Icon name="edit" size={14} />
|
||||
{t('workbench.script.writeManually')}
|
||||
</button>
|
||||
@@ -72,7 +72,7 @@
|
||||
{#if importOpen}
|
||||
<div class="import-box">
|
||||
<textarea
|
||||
class="wb-input"
|
||||
class="a-input"
|
||||
rows="8"
|
||||
bind:value={importText}
|
||||
placeholder={t('workbench.script.importPlaceholder')}
|
||||
@@ -81,7 +81,7 @@
|
||||
<span class="hint">{t('workbench.script.importHint')}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary sm"
|
||||
class="a-btn primary sm"
|
||||
disabled={importText.trim().length < 30}
|
||||
onclick={doImport}
|
||||
>
|
||||
@@ -94,7 +94,7 @@
|
||||
{:else if editing}
|
||||
<div class="editor">
|
||||
<textarea
|
||||
class="wb-input mono-area"
|
||||
class="a-input mono-area"
|
||||
rows="18"
|
||||
bind:value={draft}
|
||||
data-testid="script-editor"
|
||||
@@ -102,12 +102,12 @@
|
||||
<div class="editor-foot">
|
||||
<span class="hint">{t('workbench.script.editorHint')}</span>
|
||||
<div class="foot-btns">
|
||||
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
|
||||
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
|
||||
{t('workbench.cancelEdit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
class="a-btn primary"
|
||||
disabled={draft.trim().length < 30}
|
||||
onclick={save}
|
||||
>
|
||||
@@ -122,13 +122,13 @@
|
||||
<span class="char-count mono">{t('workbench.charCount', { n: charCount })}</span>
|
||||
<div class="foot-btns">
|
||||
{#if stage.status !== 'running'}
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<Icon name="edit" size={13} />
|
||||
{t('workbench.editDraft')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost"
|
||||
class="a-btn ghost"
|
||||
disabled={busy}
|
||||
onclick={() => onRun?.()}
|
||||
title={t('workbench.regenerateHint')}
|
||||
@@ -243,65 +243,5 @@
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.wb-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
opacity 150ms;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.btn.sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import JobChip from './JobChip.svelte';
|
||||
import PromptBlock from './PromptBlock.svelte';
|
||||
import { deskHref } from '$lib/components/generate/context';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
stage: WorkbenchStage;
|
||||
/** Enables the 在生成台调参 link (desk opens with this shot's context). */
|
||||
projectId?: number;
|
||||
onSave?: (content: Record<string, unknown>) => void;
|
||||
onRun?: (instruction?: string) => void;
|
||||
onGenerate?: (payload: {
|
||||
@@ -20,7 +23,7 @@
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
|
||||
let { stage, projectId, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let draft = $state<VideoShot[]>([]);
|
||||
@@ -71,9 +74,9 @@
|
||||
{#each draft as shot (shot.id)}
|
||||
<div class="vshot-edit">
|
||||
<div class="vshot-edit-head">
|
||||
<input class="wb-input w-no mono" bind:value={shot.shot_no} placeholder={t('workbench.vshot.noPh')} />
|
||||
<input class="a-input w-no mono" bind:value={shot.shot_no} placeholder={t('workbench.vshot.noPh')} />
|
||||
<input
|
||||
class="wb-input w-dur mono"
|
||||
class="a-input w-dur mono"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
@@ -89,9 +92,9 @@
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<input class="wb-input" bind:value={shot.description} placeholder={t('workbench.vshot.descPh')} />
|
||||
<input class="a-input" bind:value={shot.description} placeholder={t('workbench.vshot.descPh')} />
|
||||
<textarea
|
||||
class="wb-input mono"
|
||||
class="a-input mono"
|
||||
rows="3"
|
||||
bind:value={shot.prompt}
|
||||
placeholder={t('workbench.vshot.promptPh')}
|
||||
@@ -100,17 +103,17 @@
|
||||
{/each}
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<button type="button" class="btn ghost" onclick={addShot}>
|
||||
<button type="button" class="a-btn ghost" onclick={addShot}>
|
||||
<Icon name="plus" size={13} />
|
||||
{t('workbench.vshot.add')}
|
||||
</button>
|
||||
<div class="foot-btns">
|
||||
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
|
||||
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
|
||||
{t('workbench.cancelEdit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
class="a-btn primary"
|
||||
disabled={!draft.some((s) => s.prompt.trim())}
|
||||
onclick={save}
|
||||
>
|
||||
@@ -123,7 +126,7 @@
|
||||
<div class="todo-card">
|
||||
<p class="todo-title">{t('workbench.vshot.todoTitle')}</p>
|
||||
<p class="todo-body">{t('workbench.vshot.todoBody')}</p>
|
||||
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="sparkle" size={14} />
|
||||
{t('workbench.vshot.generate')}
|
||||
</button>
|
||||
@@ -159,9 +162,25 @@
|
||||
{/if}
|
||||
<PromptBlock label={t('workbench.vshot.promptLabel')} text={shot.prompt} />
|
||||
<div class="vshot-foot">
|
||||
{#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,
|
||||
})}
|
||||
title={t('desk.openInDeskHint')}
|
||||
>
|
||||
{t('desk.openInDesk')}
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost gen"
|
||||
class="a-btn ghost sm"
|
||||
disabled={busy || jobActive(shot.job)}
|
||||
title={t('workbench.vshot.generateHint')}
|
||||
onclick={() =>
|
||||
@@ -183,11 +202,11 @@
|
||||
<span class="hint">{t('workbench.vshot.summary', { n: shots.length })}</span>
|
||||
<div class="foot-btns">
|
||||
{#if stage.status !== 'running'}
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<Icon name="edit" size={13} />
|
||||
{t('workbench.editDraft')}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('workbench.regenerate')}
|
||||
</button>
|
||||
@@ -241,9 +260,10 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vshot-no {
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
color: var(--accent-lo);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.vshot-dur {
|
||||
@@ -260,13 +280,15 @@
|
||||
.vshot-video {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: #000;
|
||||
border-radius: var(--r-md);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--media-scrim);
|
||||
}
|
||||
.vshot-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.retry-link {
|
||||
@@ -326,35 +348,15 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wb-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wb-input.mono {
|
||||
.a-input.mono {
|
||||
font-family: var(--font-a-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.wb-input.w-no {
|
||||
.a-input.w-no {
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wb-input.w-dur {
|
||||
.a-input.w-dur {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -382,44 +384,4 @@
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
opacity 150ms;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.btn.gen {
|
||||
color: var(--accent);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Five-stage stepper (a2): one step per agent with the spec §2.1 semantic
|
||||
* colors — gray=待开始, amber=进行中/待确认 (+breath while running),
|
||||
* green=已确认, blue=用户改过草稿. A step unlocks when itself or any
|
||||
* Five-stage stepper (a2): one step per agent with the fusion status
|
||||
* colors — gray=待开始, cyan=进行中 (+breath), amber=待确认,
|
||||
* green=已确认, --info=用户改过草稿. A step unlocks when itself or any
|
||||
* earlier stage has content; later steps stay clickable only after the
|
||||
* previous one is confirmed.
|
||||
*/
|
||||
@@ -50,7 +50,7 @@
|
||||
function stateLabel(stage: WorkbenchStage): string {
|
||||
const s = stateOf(stage);
|
||||
if (s === 'running') return t('workbench.state.running');
|
||||
if (s === 'confirmed') return t('workbench.state.confirmed');
|
||||
if (s === 'confirmed') return `✓ ${t('workbench.state.confirmed')}`;
|
||||
if (s === 'edited') return t('workbench.state.edited');
|
||||
if (s === 'draft') return t('workbench.state.draft');
|
||||
return t('workbench.state.todo');
|
||||
@@ -144,6 +144,14 @@
|
||||
.step-btn.active {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
/* §3.2 当前段:金色下划 2px 内阴影 + 段名金色。 */
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
.step-btn.active .step-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
.step-btn[data-state='confirmed']:not(.active) .step-name {
|
||||
color: var(--success);
|
||||
}
|
||||
.step-btn.locked {
|
||||
opacity: 0.55;
|
||||
@@ -159,7 +167,7 @@
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #edeef0;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -188,12 +196,14 @@
|
||||
.st-todo {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.st-running,
|
||||
.st-running {
|
||||
color: var(--run);
|
||||
}
|
||||
.st-draft {
|
||||
color: var(--accent);
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-edited {
|
||||
color: var(--info);
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-confirmed {
|
||||
color: var(--success);
|
||||
@@ -201,14 +211,14 @@
|
||||
|
||||
.step-btn.active[data-state='running'] .avatar,
|
||||
.step-btn[data-state='running'] .avatar {
|
||||
box-shadow: 0 0 10px rgba(232, 163, 61, 0.35);
|
||||
box-shadow: 0 0 10px var(--run-soft);
|
||||
}
|
||||
|
||||
.breath {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: wb-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,11 +71,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="shot-fields">
|
||||
<input class="wb-input w-xs" bind:value={shot.scene} placeholder={t('workbench.board.scene')} />
|
||||
<input class="wb-input w-xs" bind:value={shot.shot_size} placeholder={t('workbench.board.size')} />
|
||||
<input class="wb-input w-xs" bind:value={shot.camera} placeholder={t('workbench.board.camera')} />
|
||||
<input class="a-input w-xs" bind:value={shot.scene} placeholder={t('workbench.board.scene')} />
|
||||
<input class="a-input w-xs" bind:value={shot.shot_size} placeholder={t('workbench.board.size')} />
|
||||
<input class="a-input w-xs" bind:value={shot.camera} placeholder={t('workbench.board.camera')} />
|
||||
<input
|
||||
class="wb-input w-xs mono"
|
||||
class="a-input w-xs mono"
|
||||
type="number"
|
||||
min="0.5"
|
||||
step="0.5"
|
||||
@@ -84,27 +84,27 @@
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
class="wb-input"
|
||||
class="a-input"
|
||||
rows="2"
|
||||
bind:value={shot.description}
|
||||
placeholder={t('workbench.board.descPh')}
|
||||
></textarea>
|
||||
<input class="wb-input" bind:value={shot.dialog} placeholder={t('workbench.board.dialogPh')} />
|
||||
<input class="a-input" bind:value={shot.dialog} placeholder={t('workbench.board.dialogPh')} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<button type="button" class="btn ghost" onclick={addShot}>
|
||||
<button type="button" class="a-btn ghost" onclick={addShot}>
|
||||
<Icon name="plus" size={13} />
|
||||
{t('workbench.board.add')}
|
||||
</button>
|
||||
<div class="foot-btns">
|
||||
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
|
||||
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
|
||||
{t('workbench.cancelEdit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
class="a-btn primary"
|
||||
disabled={!draft.some((s) => s.description.trim())}
|
||||
onclick={save}
|
||||
>
|
||||
@@ -117,7 +117,7 @@
|
||||
<div class="todo-card">
|
||||
<p class="todo-title">{t('workbench.board.todoTitle')}</p>
|
||||
<p class="todo-body">{t('workbench.board.todoBody')}</p>
|
||||
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="sparkle" size={14} />
|
||||
{t('workbench.board.generate')}
|
||||
</button>
|
||||
@@ -149,11 +149,11 @@
|
||||
<span class="hint mono">{t('workbench.board.summary', { n: shots.length, sec: totalDuration })}</span>
|
||||
<div class="foot-btns">
|
||||
{#if stage.status !== 'running'}
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<Icon name="edit" size={13} />
|
||||
{t('workbench.editDraft')}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('workbench.regenerate')}
|
||||
</button>
|
||||
@@ -282,31 +282,11 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.wb-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wb-input.mono {
|
||||
.a-input.mono {
|
||||
font-family: var(--font-a-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.wb-input.w-xs {
|
||||
.a-input.w-xs {
|
||||
width: 110px;
|
||||
flex: 1;
|
||||
min-width: 96px;
|
||||
@@ -356,40 +336,4 @@
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
opacity 150ms;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="dir-edit">
|
||||
<div class="dir-edit-head">
|
||||
<span class="dir-index mono">{i + 1}</span>
|
||||
<input class="wb-input name" bind:value={dir.name} placeholder={t('workbench.style.namePh')} />
|
||||
<input class="a-input name" bind:value={dir.name} placeholder={t('workbench.style.namePh')} />
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
@@ -78,35 +78,35 @@
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
class="wb-input"
|
||||
class="a-input"
|
||||
bind:value={dir.description}
|
||||
placeholder={t('workbench.style.descPh')}
|
||||
/>
|
||||
<textarea
|
||||
class="wb-input mono"
|
||||
class="a-input mono"
|
||||
rows="2"
|
||||
bind:value={dir.prompt}
|
||||
placeholder={t('workbench.style.promptPh')}
|
||||
></textarea>
|
||||
<input
|
||||
class="wb-input mono"
|
||||
class="a-input mono"
|
||||
bind:value={dir.negative_prompt}
|
||||
placeholder={t('workbench.style.negativePh')}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="editor-foot">
|
||||
<button type="button" class="btn ghost" onclick={addDirection}>
|
||||
<button type="button" class="a-btn ghost" onclick={addDirection}>
|
||||
<Icon name="plus" size={13} />
|
||||
{t('workbench.style.add')}
|
||||
</button>
|
||||
<div class="foot-btns">
|
||||
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
|
||||
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
|
||||
{t('workbench.cancelEdit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
class="a-btn primary"
|
||||
disabled={draft.filter((d) => d.name.trim() && d.prompt.trim()).length < 2}
|
||||
onclick={save}
|
||||
>
|
||||
@@ -119,7 +119,7 @@
|
||||
<div class="todo-card">
|
||||
<p class="todo-title">{t('workbench.style.todoTitle')}</p>
|
||||
<p class="todo-body">{t('workbench.style.todoBody')}</p>
|
||||
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="sparkle" size={14} />
|
||||
{t('workbench.style.generate')}
|
||||
</button>
|
||||
@@ -153,11 +153,11 @@
|
||||
<span class="hint">{t('workbench.style.chooseHint')}</span>
|
||||
<div class="foot-btns">
|
||||
{#if stage.status !== 'running'}
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<Icon name="edit" size={13} />
|
||||
{t('workbench.editDraft')}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('workbench.regenerate')}
|
||||
</button>
|
||||
@@ -214,7 +214,7 @@
|
||||
}
|
||||
.dir-card.chosen {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 10px rgba(232, 163, 61, 0.35);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
.dir-card input[type='radio'] {
|
||||
position: absolute;
|
||||
@@ -295,31 +295,11 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wb-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wb-input.mono {
|
||||
.a-input.mono {
|
||||
font-family: var(--font-a-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.wb-input.name {
|
||||
.a-input.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -345,40 +325,4 @@
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
opacity 150ms;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</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>
|
||||
@@ -64,6 +64,7 @@ export const de: Dict = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': 'Ausblenden',
|
||||
'toast.undo': 'Rückgängig',
|
||||
'toast.uploadFailed': 'Upload fehlgeschlagen',
|
||||
|
||||
// --- Modal ---
|
||||
@@ -220,6 +221,25 @@ export const de: Dict = {
|
||||
'lightbox.videoPreview': 'Videovorschau',
|
||||
'lightbox.videoUnavailable': 'Video nicht verfügbar',
|
||||
'lightbox.zoom100': '100%',
|
||||
'lastFrame.export': 'Letztes Bild exportieren',
|
||||
'lastFrame.exporting': 'Letztes Bild wird exportiert…',
|
||||
'lastFrame.hint': 'Speichert das letzte Bild in Originalgröße als PNG, z. B. als Startbild-Referenz',
|
||||
'lastFrame.fileTag': 'letztes-bild',
|
||||
'lastFrame.done': 'Letztes Bild exportiert {name} ({size})',
|
||||
'lastFrame.errTimeout': 'Das Video lädt zu lange. Bitte später erneut versuchen.',
|
||||
'lastFrame.errNotFound': 'Die Videodatei fehlt oder wurde entfernt.',
|
||||
'lastFrame.errForbidden': 'Keine Berechtigung, dieses Video zu lesen.',
|
||||
'lastFrame.errNetwork': 'Das Video konnte nicht gelesen werden (Netzwerkfehler).',
|
||||
'lastFrame.errDecode': 'Das Video ist beschädigt; das Ende lässt sich nicht dekodieren.',
|
||||
'lastFrame.errUnsupported': 'Dieser Browser unterstützt das Videoformat nicht, oder die Datei ist beschädigt.',
|
||||
'lastFrame.errCrossOrigin':
|
||||
'Das Video stammt von einer anderen Website, die das Auslesen von Bildern sperrt (Cross-Origin). Video zuerst herunterladen.',
|
||||
'lastFrame.errNoPicture': 'Diese Datei enthält kein Videobild.',
|
||||
'lastFrame.errUnseekable':
|
||||
'Das Videoende ist nicht erreichbar (Länge unbekannt oder Quelle erlaubt kein Springen); kein Bild exportiert.',
|
||||
'lastFrame.errBlack': 'Die letzte Sekunde des Videos ist schwarz; kein Bild exportiert.',
|
||||
'lastFrame.errEmpty': 'Der Browser konnte das Ende nicht dekodieren. Erneut versuchen oder Desktop-Chrome / Edge nutzen.',
|
||||
'lastFrame.errUnknown': 'Export des letzten Bildes fehlgeschlagen: {msg}',
|
||||
|
||||
// --- Artifact / canvas nodes ---
|
||||
'artifact.artifactLabel': 'Artefakt',
|
||||
@@ -948,6 +968,8 @@ export const de: Dict = {
|
||||
'library.selectAll': 'Alle auswählen',
|
||||
'library.deselectAll': 'Auswahl aufheben',
|
||||
'library.itemCount': '{count} Element(e)',
|
||||
'library.loadError': 'Bibliothek konnte nicht geladen werden',
|
||||
'library.subtitle': 'Generierte Assets an einem Ort – Bilder und Videos ohne Projektbezug ansehen, filtern und löschen',
|
||||
'library.deleteSelected': 'Löschen ({count})',
|
||||
'library.addToProject': 'Zum Projekt hinzufügen',
|
||||
'library.filterLabel': 'Nach Art filtern',
|
||||
@@ -1259,15 +1281,46 @@ export const de: Dict = {
|
||||
'shell.navAgents': 'Agenten',
|
||||
'shell.navConnections': 'ComfyUI-Verbindungen',
|
||||
'shell.navModels': 'Modelle',
|
||||
'shell.navLibrary': 'Asset-Bibliothek',
|
||||
'shell.navSettings': 'Einstellungen',
|
||||
'shell.wip': 'In Entwicklung',
|
||||
'shell.wipHint': 'Diese Seite ist noch im Bau und folgt später',
|
||||
'shell.comfy_ok': '{name} verbunden',
|
||||
'shell.comfy_fail': '{name} nicht erreichbar',
|
||||
'shell.comfy_testing': '{name} wird getestet…',
|
||||
'shell.comfyUnset': 'ComfyUI · nicht konfiguriert',
|
||||
'shell.comfyManage': 'ComfyUI-Adresse in den Einstellungen pflegen',
|
||||
'shell.mobileNotice': 'Bitte öffne diese Werkbank in einem Desktop-Browser (mindestens 768px breit); Handys werden noch nicht unterstützt.',
|
||||
'shell.scopeHint': 'Projekt wechseln',
|
||||
'shell.scopeAll': 'Alle Projekte',
|
||||
'shell.scopeAllList': 'Liste aller Projekte',
|
||||
'shell.scopeRecent': 'Letzte Projekte',
|
||||
'shell.scopeLoadFailed': 'Projekte konnten nicht geladen werden',
|
||||
'shell.scopeEmpty': 'Noch keine Projekte – jetzt anlegen',
|
||||
'shell.scopeStage': '{n}/5 bestätigt',
|
||||
'shell.queueRunning': '{n} in Arbeit',
|
||||
'shell.queueQueued': '{n} in der Warteschlange',
|
||||
'shell.queueIdle': 'Warteschlange leer',
|
||||
'shell.queueActive': '{n} aktiv',
|
||||
'shell.trayTitle': 'Auftragsablage',
|
||||
'shell.trayRecent': 'Letzte Aufträge · klicken für alle Aufträge',
|
||||
'shell.trayEmpty': 'Noch keine Generierungsaufträge',
|
||||
'shell.trayAll': 'Alle Aufträge anzeigen',
|
||||
'shell.searchTitle': 'Seiten und Projekte suchen',
|
||||
'shell.searchPlaceholder': 'Seiten, Projekte suchen',
|
||||
'shell.searchShortcut': 'Suchen (Strg/⌘ + K)',
|
||||
'shell.searchEmpty': 'Keine Seite und kein Projekt passt zu „{q}“',
|
||||
'shell.searchGroupPages': 'Seiten',
|
||||
'shell.searchGroupProjects': 'Projekte · Werkbank öffnen',
|
||||
'shell.searchHintMove': 'auswählen',
|
||||
'shell.searchHintOpen': 'öffnen',
|
||||
'shell.searchHintClose': 'schließen',
|
||||
'shell.reduceMotion': 'Weniger Bewegung',
|
||||
'shell.reduceHint': 'Bewegungs-, Zoom- und Glüheffekte aus; nur kurze Überblendungen',
|
||||
'shell.reduceSystem': 'Folgt der Systemeinstellung „Bewegung reduzieren“',
|
||||
'shell.tabAria': 'Hauptnavigation (schmal)',
|
||||
'shell.tabProjects': 'Projekte',
|
||||
'shell.tabCreate': 'Erstellen',
|
||||
'shell.tabGenerate': 'Generieren',
|
||||
'shell.tabMore': 'Mehr',
|
||||
'shell.morePages': 'Weitere Seiten',
|
||||
'shell.moreTools': 'Werkzeuge',
|
||||
'home.title': 'Projektübersicht',
|
||||
'home.subtitle': 'Verwalte deine Projekte — starte mit einer Idee oder einem fertigen Drehbuch.',
|
||||
'home.projectCount': '{n} Projekte · {m} in Arbeit',
|
||||
@@ -1456,8 +1509,6 @@ export const de: Dict = {
|
||||
'studio.genParams': 'Parameter',
|
||||
'studio.modeTextToImage': 'Text zu Bild',
|
||||
'studio.modeTextToVideo': 'Text zu Video',
|
||||
'studio.chipIdle': '{name} · Warteschlange frei',
|
||||
'studio.chipBusy': '{name} · {n} in Warteschlange',
|
||||
'studio.video.resultsTitle': 'Vorschau',
|
||||
'studio.image.historyTitle': 'Bildverlauf',
|
||||
'studio.video.historyTitle': 'Videoverlauf',
|
||||
@@ -1507,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',
|
||||
@@ -1581,7 +1633,11 @@ export const de: Dict = {
|
||||
'studio.delConfirm': 'Beim Löschen werden auch die Ergebnisdateien dieses Auftrags entfernt; der Verlauf ist nicht wiederherstellbar. Löschen?',
|
||||
'studio.delTitleJob': 'Auftrag „{name}“ löschen',
|
||||
'studio.delConfirmMulti': 'Dieser Auftrag hat {n} Ergebnisse. Beim Löschen werden der gesamte Auftragsdatensatz und alle {n} Ergebnisdateien entfernt; dies ist unwiderruflich. Löschen?',
|
||||
'studio.deleteFailedToast': 'Löschen fehlgeschlagen: {detail}',
|
||||
'studio.deletedTakeNoFilesToast': '„{name}“ gelöscht',
|
||||
'studio.deletedTakeToast': '„{name}“ und seine Ergebnisdateien wurden gelöscht',
|
||||
'studio.deletedToast': 'Auftrag und zugehörige Ergebnisdateien gelöscht',
|
||||
'studio.wfDeletedTag': 'Workflow gelöscht',
|
||||
'studio.wfEmptyTitle': 'Noch keine {kind}-Workflows',
|
||||
'studio.wfEmptyBody': 'Entwicklermodus in ComfyUI aktivieren, Workflow als API-JSON exportieren und auf der Seite ComfyUI-Verbindungen als {kind} importieren.',
|
||||
'studio.wfEmptyGo': 'In ComfyUI-Verbindungen importieren',
|
||||
@@ -1600,9 +1656,12 @@ export const de: Dict = {
|
||||
'tasks.subtitle': 'Alle Bild-/Videoaufgaben — eigenständige Fenster und Kreativfluss',
|
||||
'tasks.filterAll': 'Alle',
|
||||
'tasks.filterImage': 'Bilder',
|
||||
'tasks.filterFailed': 'Fehlgeschlagen',
|
||||
'tasks.filterActive': 'Aktiv',
|
||||
'tasks.filterVideo': 'Videos',
|
||||
'tasks.count': '{n} Aufgaben · {m} aktiv',
|
||||
'tasks.empty': 'Noch keine Aufgaben. Reiche die erste in Bild oder Video ein.',
|
||||
'tasks.emptyFiltered': 'Keine Aufgaben passen zum aktuellen Filter.',
|
||||
'tasks.goGenerate': 'Bild öffnen',
|
||||
'tasks.sourceStudioImage': 'Eigenständiges Bild',
|
||||
'tasks.sourceStudioVideo': 'Eigenständiges Video',
|
||||
@@ -1616,8 +1675,10 @@ export const de: Dict = {
|
||||
'tasks.retry': 'Erneut',
|
||||
'tasks.cancel': 'Abbrechen',
|
||||
'tasks.delete': 'Löschen',
|
||||
'tasks.download': 'Herunterladen',
|
||||
'tasks.delTitle': 'Aufgabe löschen',
|
||||
'tasks.delConfirm': 'Die Ergebnisdateien werden mitgelöscht. Löschen?',
|
||||
'tasks.searchPh': 'Aufgaben suchen…',
|
||||
'tasks.outputs': '{n} Ergebnisse',
|
||||
|
||||
'agentsPage.title': 'Agenten',
|
||||
@@ -1841,12 +1902,14 @@ 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',
|
||||
'jobst.noOutput': 'Keine Ausgabe',
|
||||
'jobst.noOutputDetail': 'Der Job endete ohne Ergebnisdatei – keine fertige Generierung',
|
||||
'jobst.missing': 'Dateien fehlen',
|
||||
'jobst.missingDetail': 'Der Task lief erfolgreich, aber die Ergebnisdateien sind nicht mehr auf dem Server',
|
||||
'jobst.queued': 'Wartet',
|
||||
'jobst.queuedDetail': 'Wartet in der Workbench-Warteschlange auf ComfyUI',
|
||||
'jobst.connecting': 'Verbinde',
|
||||
@@ -1875,4 +1938,295 @@ export const de: Dict = {
|
||||
'studio.doneToast': 'Fertig: {n} Datei(en) (ComfyUI-Prompt-ID {id})',
|
||||
'studio.dryRunToast': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
|
||||
'studio.dryRunNote': 'Dry-run ist aktiv: Generieren schreibt nur Platzhalter und ruft ComfyUI nie auf. In den Einstellungen abschalten.',
|
||||
// --- HEL-643: Import-Prüfung / Echtlauf-Test / Mediengruppen ---
|
||||
'studio.errVideoFormat': '„{name}“ ist kein unterstütztes Videoformat — MP4 / WebM / MOV verwenden',
|
||||
'studio.errAudioFormat': '„{name}“ ist kein unterstütztes Audioformat — MP3 / WAV / M4A verwenden',
|
||||
'studio.errMediaSize': '„{name}“ überschreitet die Größenbeschränkung — bitte vorher komprimieren',
|
||||
'studio.errNeedRefMedia': '„{label}“ benötigt mindestens {n} Clips',
|
||||
'studio.refHintMedia': 'Pflicht · 1–{max} Clips · {n} ausgewählt',
|
||||
'studio.refAddMedia': '{kind} hinzufügen',
|
||||
'studio.refAddSubMedia': 'Noch {n} · max {max}',
|
||||
'studio.refRemoveMedia': '{kind} {n} entfernen',
|
||||
'studio.refFullMedia': 'Maximum von {max} Clips erreicht — erst eines entfernen',
|
||||
'wfMedia.h3Limits': 'Offizielle Limits des MiniMax H3 Ref2VA: bis zu 9 Referenzbilder, 3 Referenzvideos und 3 Referenzaudios; je Clip 2–15 s, Summen je Typ ≤15 s; Audio allein ist keine gültige Referenz; insgesamt höchstens 12 Referenzdateien.',
|
||||
'wfMedia.h3TotalOver': '{n} Referenzdateien — über dem offiziellen Limit von 12; die Generierung kann fehlschlagen',
|
||||
'wfMedia.h3AudioAlone': 'Audio allein kann nicht die einzige Referenz sein — mindestens ein Bild oder Video ergänzen',
|
||||
'conn.wfCheckTitle': 'Kompatibilitätsprüfung der Verbindung',
|
||||
'conn.wfCheckEndpoint': 'Ziel: {name}',
|
||||
'conn.wfCheckBtn': 'Erneut prüfen',
|
||||
'conn.wfChecking': 'Prüfe…',
|
||||
'conn.wfCheckOk': 'Diese Verbindung besitzt die Knoten und Modelle, die dieser Workflow braucht',
|
||||
'conn.wfCheckProblems': '{n} fehlende Knoten/Modelle gefunden',
|
||||
'conn.wfCheckUnreachable': 'Keine Prüfung möglich (ComfyUI unerreichbar) — Speichern bleibt möglich; nach Reparatur erneut prüfen',
|
||||
'conn.wfCheckSaveHint': 'Der Entwurf kann trotzdem gespeichert werden (wird nicht als generierbar markiert); Fehlendes installieren und erneut prüfen.',
|
||||
'conn.wfTestBtn': 'Testen',
|
||||
'conn.wfLastTest': 'Letzter Test',
|
||||
'conn.wfTestPassed': '{time} bestanden ({n} Dateien)',
|
||||
'conn.wfTestRunning': '{time} läuft',
|
||||
'conn.wfTestFailed': '{time} nicht bestanden',
|
||||
'wft.title': 'Workflow testen',
|
||||
'wft.connection': 'Zielverbindung',
|
||||
'wft.costNote': 'Ein Test schickt einen echten Auftrag an den gewählten ComfyUI und belegt VRAM und Warteschlange.',
|
||||
'wft.submit': 'Test starten',
|
||||
'wft.confirmTitle': 'Echten Lauf bestätigen',
|
||||
'wft.confirmBody': 'An „{endpoint}“ wird ein echter Auftrag gesendet ({n} Aufträge in der Warteschlange). Der Test gilt nur als bestanden, wenn mindestens eine herunterladbare Ausgabedatei des richtigen Typs zurückkommt.',
|
||||
'wft.running': 'Test läuft…',
|
||||
'wft.cancel': 'Test abbrechen',
|
||||
'wft.passed': 'Test bestanden: {n} Datei(en) erzeugt',
|
||||
'wft.passedShort': 'Bestanden ({n} Dateien)',
|
||||
'wft.failed': 'Test nicht bestanden',
|
||||
'wft.failedShort': 'Nicht bestanden',
|
||||
'wft.cancelledShort': 'Abgebrochen',
|
||||
'wft.reusedToast': 'Für diesen Workflow läuft bereits ein Test — angebunden',
|
||||
'wft.history': 'Testverlauf ({n})',
|
||||
'wft.clear': 'Testverlauf löschen',
|
||||
'wft.cleared': 'Testverlauf gelöscht',
|
||||
'wft.never': 'Nie getestet',
|
||||
'wft.download': 'Herunterladen',
|
||||
'wft.errRequired': 'Bitte „{label}“ ausfüllen',
|
||||
'wft.errListMin': '„{label}“ benötigt mindestens {n} {unit}',
|
||||
'tasks.testBadge': 'Test',
|
||||
'tasks.sourceWorkflowTest': 'Workflow-Test',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': 'Original {v}',
|
||||
'desk.title': 'Generieren',
|
||||
'desk.advanced': 'Erweitert · {n} · unverändert gelten die Workflow-Werte',
|
||||
'desk.chipResolution': 'Auflösung {v}',
|
||||
'desk.chipSeconds': '{v} s',
|
||||
'desk.chipSeed': 'Seed {v}',
|
||||
'desk.closeInspector': 'Inspektor schließen',
|
||||
'desk.countActive': '{n} laufen',
|
||||
'desk.countTakes': '{n} Einträge',
|
||||
'desk.ctxAsset': '{kind} „{name}“',
|
||||
'desk.ctxShot': 'Einstellung {no}',
|
||||
'desk.ctxBack': 'Zurück zu {stage}',
|
||||
'desk.ctxDismiss': 'Herkunftshinweis schließen',
|
||||
'desk.ctxFrom': 'Aus „{project}“',
|
||||
'desk.ctxNoBackfill': 'Ergebnisse bleiben im Verlauf und werden nicht in {stage} übernommen',
|
||||
'desk.dayDate': '{d}.{m}.',
|
||||
'desk.download': 'Herunterladen',
|
||||
'desk.dropNoSlot': '{n} Datei(en) ohne passenden Slot: dieser Workflow hat keinen solchen Eingang',
|
||||
'desk.dryRun': 'Probelauf',
|
||||
'desk.durMin': '{m} min {s} s',
|
||||
'desk.durSec': '{s} s',
|
||||
'desk.elapsed': 'Dauer',
|
||||
'desk.emptyFailed': 'Keine fehlgeschlagenen Einträge',
|
||||
'desk.emptyFeed': 'Noch keine Einträge. Prompt unten eingeben und „Generieren“ drücken.',
|
||||
'desk.filterAria': 'Einträge filtern',
|
||||
'desk.filterAll': 'Alle',
|
||||
'desk.filterImage': 'Bilder',
|
||||
'desk.filterVideo': 'Videos',
|
||||
'desk.filterFailed': 'Fehlgeschlagen',
|
||||
'desk.generate': 'Generieren',
|
||||
'desk.genHint': 'Generieren (Strg/⌘ + Enter)',
|
||||
'desk.inputs': 'Eingaben',
|
||||
'desk.inspector': 'Inspektor',
|
||||
'desk.keyClose': 'Schließen',
|
||||
'desk.keyInspector': 'Ein- / ausblenden',
|
||||
'desk.keySelect': 'Auswählen',
|
||||
'desk.keyUse': 'Verwenden',
|
||||
'desk.kvCreated': 'Eingereicht',
|
||||
'desk.kvName': 'Name',
|
||||
'desk.kvPromptId': 'ComfyUI-Auftrags-ID',
|
||||
'desk.kvUsage': 'Zweck',
|
||||
'desk.loadEarlier': '{n} ältere Takes anzeigen',
|
||||
'desk.missingSub': 'Mit gleichen Parametern wiederholen oder diesen Take entfernen',
|
||||
'desk.missingTitle': 'Die Ergebnisdateien sind nicht mehr auf dem Server',
|
||||
'desk.needWorkflow': 'Fehlt: Workflow',
|
||||
'desk.noConn': 'Nicht verbunden',
|
||||
'desk.noPromptInput': 'Dieser Workflow hat keinen Prompt-Eingang und läuft mit seinen eigenen Werten.',
|
||||
'desk.notGenerated': 'Nicht erzeugt',
|
||||
'desk.noWorkflows': 'Noch kein Bild- oder Video-Workflow bereit',
|
||||
'desk.openInDesk': 'Im Generator anpassen',
|
||||
'desk.openInDeskHint': 'Generator mit diesem Prompt öffnen, Workflow und Parameter selbst wählen; Ergebnisse bleiben im Verlauf',
|
||||
'desk.openInspector': 'Inspektor',
|
||||
'desk.origValue': 'Workflow-Wert',
|
||||
'desk.outputN': 'Ergebnis {n}',
|
||||
'desk.outputs': 'Alle Ergebnisse',
|
||||
'desk.paramError': 'Ungültiger Parameter: {msg}',
|
||||
'desk.paramsAll': 'Alle Parameter',
|
||||
'desk.paramsChanged': 'Parameter · {n} geändert',
|
||||
'desk.paramsCommon': 'Häufige Parameter',
|
||||
'desk.pickTake': 'Eintrag im Verlauf wählen, um das Ergebnis zu sehen',
|
||||
'desk.pickWorkflow': 'Workflow wählen',
|
||||
'desk.promptOnly': 'Nur Prompt',
|
||||
'desk.promptPh': 'Motiv beschreiben… (Strg/⌘ + Enter generiert, / fokussiert)',
|
||||
'desk.restoreAll': 'Alle Workflow-Werte wiederherstellen',
|
||||
'desk.redo': 'Mit gleichen Parametern wiederholen',
|
||||
'desk.removeRecord': 'Eintrag entfernen',
|
||||
'desk.restoreOrig': 'Original wiederherstellen',
|
||||
'desk.reuse': 'Wiederverwenden',
|
||||
'desk.reuseToComposer': 'In Eingabe übernehmen',
|
||||
'desk.seedRandom': 'Zufall',
|
||||
'desk.sendToSlot': 'In Bild-Slot senden',
|
||||
'desk.sent': 'Eingereicht',
|
||||
'desk.sentToSlotToast': 'In den Bild-Slot des aktuellen Workflows gesendet',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': 'Größe',
|
||||
'desk.stillNeed': 'Fehlt: {what}',
|
||||
'desk.tabParams': 'Parameter',
|
||||
'desk.tabResult': 'Ergebnis',
|
||||
'desk.today': 'Heute',
|
||||
'desk.took': 'Dauer {d}',
|
||||
'desk.wfBroken': 'Nicht nutzbar',
|
||||
'desk.wfCurrent': 'aktuell',
|
||||
'desk.wfDefaultConn': 'Standardverbindung',
|
||||
'desk.wfImport': 'Workflows importieren / verwalten',
|
||||
'desk.wfLastFailed': 'Letzter Lauf fehlgeschlagen',
|
||||
'desk.wfNoMatch': 'Kein passender Workflow',
|
||||
'desk.wfOk': 'Bereit',
|
||||
'desk.wfSearchPh': 'Workflows oder Zweck suchen: Bild zu Video, Referenz…',
|
||||
'desk.wfTesting': 'Testlauf läuft',
|
||||
'desk.wfUntested': 'Nicht getestet',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Gestern',
|
||||
'wfd.stepParse': 'JSON parsen',
|
||||
'wfd.stepNodes': 'Knoten erkennen',
|
||||
'wfd.stepModels': 'Modelle prüfen',
|
||||
'wfd.stepSlots': 'Eingangsslots aufbauen',
|
||||
'wfd.noOutput': 'Der Workflow hat keinen Ausgabeknoten — Import nicht möglich',
|
||||
'wfd.nodesAutoOk': 'Verwendung automatisch erkannt: {usage}',
|
||||
'wfd.nodesUserOk': 'Verwendung bestätigt: {usage}',
|
||||
'wfd.modelsOk': '„{name}“ hat alle benötigten Knoten und Modelle',
|
||||
'wfd.slotsOk': '{n} Eingangsslot(s) aufgebaut ({m} Medien)',
|
||||
'wfd.checksAria': 'Import-Prüfergebnisse',
|
||||
'wfd.showDetails': 'Fehlerdetails anzeigen',
|
||||
'wfd.hideDetails': 'Fehlerdetails ausblenden',
|
||||
'wfd.unreachableSuggest': 'Eine misslungene Prüfung verhindert das Speichern nicht; nach Reparatur erneut prüfen.',
|
||||
'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}',
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user