Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
225fcb49ea | ||
|
|
70a860ce2a | ||
|
|
e42212a16f | ||
|
|
f5f19f0366 | ||
|
|
bfe7b20b73 | ||
|
|
2ed9d25c24 | ||
|
|
48e16ae443 | ||
|
|
319c7b049b | ||
|
|
e3bfe771af | ||
|
|
e6f761f8c8 | ||
|
|
30e8e85766 | ||
|
|
60c043db7c | ||
|
|
0031a897d7 | ||
|
|
063a3c7063 | ||
|
|
1a7a682cd0 |
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -13,6 +14,22 @@ from calliope.comfyui.registry import IMAGE_CLASSES, AUDIO_CLASSES, VIDEO_CLASSE
|
||||
|
||||
logger = logging.getLogger("calliope.comfyui")
|
||||
|
||||
# Reachability probe budget (HEL-619): matches the connections-page test
|
||||
# probe (5s). Without it health() inherits the 120s client default, so a
|
||||
# bad endpoint hangs every queued job for two minutes before failing.
|
||||
HEALTH_TIMEOUT_SEC = 5.0
|
||||
|
||||
# /object_info fetch is a few MB on big instances; the preflight check
|
||||
# (HEL-642/643) caches it per endpoint for this long. Manual refresh
|
||||
# (`force_refresh=True`) bypasses the TTL.
|
||||
OBJECT_INFO_TTL_SEC = 300.0
|
||||
_object_info_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def clear_object_info_cache() -> None:
|
||||
"""Drop cached /object_info payloads (tests swap fake endpoints in)."""
|
||||
_object_info_cache.clear()
|
||||
|
||||
|
||||
def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
|
||||
"""Raise with ComfyUI's own error body instead of an opaque status line.
|
||||
@@ -41,15 +58,27 @@ def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
|
||||
if not isinstance(node_err, dict):
|
||||
continue
|
||||
errors = node_err.get("errors")
|
||||
node = f"{node_id}({node_err['class_type']})" if node_err.get("class_type") else node_id
|
||||
if isinstance(errors, list):
|
||||
for e in errors:
|
||||
if isinstance(e, dict):
|
||||
msg = e.get("message") or e.get("details")
|
||||
if msg:
|
||||
parts.append(f"node {node_id}: {msg}")
|
||||
detail = "; ".join(parts)
|
||||
suffix = f": {detail}" if detail else ""
|
||||
return RuntimeError(f"{prefix} ({resp.status_code}){suffix}")
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
if e.get("type") == "value_not_in_list":
|
||||
extra = e.get("extra_info") or {}
|
||||
parts.append(
|
||||
f"节点 {node}:ComfyUI 上没有「{extra.get('received_value', '?')}」"
|
||||
f"({extra.get('input_name', '参数')} 不在可选列表中,"
|
||||
"多为模型文件未安装)"
|
||||
)
|
||||
continue
|
||||
msg = e.get("message") or e.get("details")
|
||||
if msg and e.get("details") and e["details"] != msg:
|
||||
msg = f"{msg}({e['details']})"
|
||||
if msg:
|
||||
parts.append(f"节点 {node}:{msg}")
|
||||
detail = ";".join(parts)
|
||||
suffix = f":{detail}" if detail else ""
|
||||
return RuntimeError(f"{prefix}(HTTP {resp.status_code}){suffix}")
|
||||
|
||||
|
||||
class ComfyUIClient:
|
||||
@@ -64,18 +93,47 @@ class ComfyUIClient:
|
||||
|
||||
async def health(self) -> bool:
|
||||
try:
|
||||
resp = await self._http.get(f"{self.base_url}/system_stats")
|
||||
resp = await self._http.get(
|
||||
f"{self.base_url}/system_stats", timeout=HEALTH_TIMEOUT_SEC
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _cache_key(self) -> str:
|
||||
auth = self._headers.get("Authorization") or next(
|
||||
(v for k, v in self._headers.items() if k.lower() not in ("authorization",)), ""
|
||||
)
|
||||
return f"{self.base_url}|{auth}"
|
||||
|
||||
async def object_info(self, force_refresh: bool = False) -> dict[str, Any]:
|
||||
"""The server's full ``/object_info`` node catalogue, cached per endpoint.
|
||||
|
||||
Cached for OBJECT_INFO_TTL_SEC; ``force_refresh`` re-fetches (the
|
||||
connections page "重新检查" button after the user installs nodes).
|
||||
Raises on transport/HTTP errors so callers can report honestly.
|
||||
"""
|
||||
key = self._cache_key()
|
||||
now = time.monotonic()
|
||||
if not force_refresh:
|
||||
hit = _object_info_cache.get(key)
|
||||
if hit and now - hit[0] < OBJECT_INFO_TTL_SEC:
|
||||
return hit[1]
|
||||
resp = await self._http.get(f"{self.base_url}/object_info", timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("ComfyUI 返回的 /object_info 不是对象")
|
||||
_object_info_cache[key] = (now, data)
|
||||
return data
|
||||
|
||||
async def upload_image(self, path: Path, subfolder: str = "calliope") -> str:
|
||||
data = path.read_bytes()
|
||||
files = {"image": (path.name, data, "application/octet-stream")}
|
||||
form = {"overwrite": "true", "subfolder": subfolder}
|
||||
resp = await self._http.post(f"{self.base_url}/upload/image", files=files, data=form)
|
||||
if resp.status_code >= 400:
|
||||
raise _surface_error("ComfyUI image upload failed", resp)
|
||||
raise _surface_error("图片上传到 ComfyUI 失败", resp)
|
||||
result = resp.json()
|
||||
name = result.get("name", path.name)
|
||||
sub = result.get("subfolder") or subfolder
|
||||
@@ -95,7 +153,7 @@ class ComfyUIClient:
|
||||
form["subfolder"] = subfolder
|
||||
resp = await self._http.post(f"{self.base_url}/upload/image", files=files, data=form)
|
||||
if resp.status_code >= 400:
|
||||
raise _surface_error("ComfyUI audio upload failed", resp)
|
||||
raise _surface_error("音频上传到 ComfyUI 失败", resp)
|
||||
result = resp.json()
|
||||
name = result.get("name", path.name)
|
||||
sub = result.get("subfolder") or subfolder
|
||||
@@ -108,7 +166,7 @@ class ComfyUIClient:
|
||||
form = {"overwrite": "true", "subfolder": subfolder, "type": "input"}
|
||||
resp = await self._http.post(f"{self.base_url}/upload/image", files=files, data=form)
|
||||
if resp.status_code >= 400:
|
||||
raise _surface_error("ComfyUI video upload failed", resp)
|
||||
raise _surface_error("视频上传到 ComfyUI 失败", resp)
|
||||
result = resp.json()
|
||||
name = result.get("name", path.name)
|
||||
sub = result.get("subfolder") or subfolder
|
||||
@@ -202,11 +260,13 @@ class ComfyUIClient:
|
||||
payload = {"prompt": workflow, "client_id": self.client_id}
|
||||
resp = await self._http.post(f"{self.base_url}/prompt", json=payload)
|
||||
if resp.status_code >= 400:
|
||||
raise _surface_error("ComfyUI rejected the workflow", resp)
|
||||
raise _surface_error(
|
||||
"ComfyUI 拒绝执行该工作流(常见原因:缺少模型/自定义节点,或参数不合法)", resp
|
||||
)
|
||||
data = resp.json()
|
||||
prompt_id = data.get("prompt_id")
|
||||
if not prompt_id:
|
||||
raise RuntimeError(f"ComfyUI /prompt missing prompt_id: {data}")
|
||||
raise RuntimeError(f"ComfyUI 返回异常:/prompt 响应缺少 prompt_id({str(data)[:200]})")
|
||||
return prompt_id
|
||||
|
||||
async def get_history(self, prompt_id: str) -> dict[str, Any] | None:
|
||||
@@ -215,6 +275,37 @@ class ComfyUIClient:
|
||||
data = resp.json()
|
||||
return data.get(prompt_id)
|
||||
|
||||
async def locate_prompt(self, prompt_id: str) -> tuple[str, int]:
|
||||
"""Where ``prompt_id`` sits in ComfyUI's ``/queue``.
|
||||
|
||||
Returns ``("running", 0)``, ``("pending", <prompts ahead>)``,
|
||||
``("missing", 0)`` when the queue does not list it, or
|
||||
``("unknown", 0)`` when this server has no ``/queue`` route.
|
||||
"""
|
||||
resp = await self._http.get(f"{self.base_url}/queue")
|
||||
if resp.status_code == 404:
|
||||
return "unknown", 0
|
||||
resp.raise_for_status()
|
||||
data = resp.json() if resp.content else {}
|
||||
running = data.get("queue_running") or []
|
||||
pending = data.get("queue_pending") or []
|
||||
|
||||
def pid(item: Any) -> Any:
|
||||
return item[1] if isinstance(item, list) and len(item) > 1 else None
|
||||
|
||||
if any(pid(item) == prompt_id for item in running):
|
||||
return "running", 0
|
||||
mine = next((item for item in pending if pid(item) == prompt_id), None)
|
||||
if mine is None:
|
||||
return "missing", 0
|
||||
ahead = sum(
|
||||
1
|
||||
for item in pending
|
||||
if isinstance(item, list) and item and isinstance(item[0], (int, float))
|
||||
and isinstance(mine[0], (int, float)) and item[0] < mine[0]
|
||||
)
|
||||
return "pending", len(running) + ahead
|
||||
|
||||
async def download_image(
|
||||
self,
|
||||
filename: str,
|
||||
@@ -224,7 +315,10 @@ class ComfyUIClient:
|
||||
) -> Path:
|
||||
params = {"filename": filename, "subfolder": subfolder, "type": folder_type}
|
||||
resp = await self._http.get(f"{self.base_url}/view", params=params)
|
||||
resp.raise_for_status()
|
||||
if resp.status_code >= 400:
|
||||
raise _surface_error(f"从 ComfyUI 下载结果文件 {filename} 失败", resp)
|
||||
if not resp.content:
|
||||
raise RuntimeError(f"从 ComfyUI 下载的结果文件 {filename} 是空文件")
|
||||
if dest is None:
|
||||
dest = settings.assets_dir / "comfy_downloads" / filename
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -237,16 +331,46 @@ class ComfyUIClient:
|
||||
except Exception as exc:
|
||||
logger.warning("ComfyUI interrupt failed: %s", exc)
|
||||
|
||||
def extract_outputs(self, history: dict[str, Any]) -> list[dict[str, str]]:
|
||||
# History items carry no explicit media type — infer from the file
|
||||
# extension first, then the history key the node reported under.
|
||||
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mkv", ".mov", ".gif", ".m4v", ".avi", ".flv"})
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"})
|
||||
|
||||
@classmethod
|
||||
def _infer_media_kind(cls, filename: str, history_key: str) -> str | None:
|
||||
ext = Path(str(filename)).suffix.lower()
|
||||
if ext in cls._VIDEO_EXTS:
|
||||
return "video"
|
||||
if ext in cls._IMAGE_EXTS:
|
||||
return "image"
|
||||
if history_key in ("gifs", "videos"):
|
||||
return "video"
|
||||
return None
|
||||
|
||||
def extract_outputs(
|
||||
self, history: dict[str, Any], node_kinds: dict[str, str] | None = None
|
||||
) -> list[dict[str, str]]:
|
||||
"""Media items in a finished prompt's history.
|
||||
|
||||
``node_kinds`` (HEL-638) scopes the harvest to the workflow's
|
||||
confirmed output nodes — {nodeId: image|video} — so results from
|
||||
nodes the user never designated are ignored, and each item carries
|
||||
the node id plus its inferred media kind for mismatch checks.
|
||||
"""
|
||||
outputs: list[dict[str, str]] = []
|
||||
for _node_id, node_out in (history.get("outputs") or {}).items():
|
||||
for node_id, node_out in (history.get("outputs") or {}).items():
|
||||
if node_kinds is not None and str(node_id) not in node_kinds:
|
||||
continue
|
||||
for key in ("images", "gifs", "videos"):
|
||||
for item in node_out.get(key) or []:
|
||||
filename = item.get("filename", "")
|
||||
outputs.append(
|
||||
{
|
||||
"filename": item.get("filename", ""),
|
||||
"filename": filename,
|
||||
"subfolder": item.get("subfolder", ""),
|
||||
"type": item.get("type", "output"),
|
||||
"node_id": str(node_id),
|
||||
"media_kind": self._infer_media_kind(filename, key) or "",
|
||||
}
|
||||
)
|
||||
return outputs
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Structural checks on an imported ComfyUI API workflow.
|
||||
|
||||
``error`` findings mean ComfyUI will reject the prompt as exported, so the
|
||||
studio blocks generation; ``warning`` findings only explain odd parameters.
|
||||
Nothing here repairs a workflow — a broken export has to be re-exported from
|
||||
a ComfyUI that has the missing custom nodes installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
REEXPORT_HINT = "请在已安装对应自定义节点的 ComfyUI 中打开该工作流,重新导出 API 格式 JSON 后再导入"
|
||||
|
||||
# Nodes that cannot run without a model link.
|
||||
_NEEDS_MODEL = frozenset(
|
||||
{"KSampler", "KSamplerAdvanced", "BasicGuider", "CFGGuider", "BasicScheduler"}
|
||||
)
|
||||
|
||||
|
||||
def _title(node_id: str, node: dict[str, Any]) -> str:
|
||||
title = str((node.get("_meta") or {}).get("title") or "").strip()
|
||||
return f"节点 {node_id}「{title}」" if title else f"节点 {node_id}"
|
||||
|
||||
|
||||
def workflow_warnings(workflow: dict[str, Any]) -> list[dict[str, str]]:
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
found: list[dict[str, str]] = []
|
||||
nodes = {str(k): v for k, v in workflow.items() if isinstance(v, dict)}
|
||||
|
||||
untyped = [nid for nid, node in nodes.items() if not str(node.get("class_type") or "").strip()]
|
||||
for nid in untyped:
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "missing_class_type",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, nodes[nid])}缺少节点类型(class_type),"
|
||||
"导出时 ComfyUI 未加载该自定义节点",
|
||||
}
|
||||
)
|
||||
|
||||
consumed: set[str] = set()
|
||||
for nid, node in nodes.items():
|
||||
inputs = node.get("inputs") or {}
|
||||
for name, val in inputs.items():
|
||||
if isinstance(val, list) and len(val) == 2 and isinstance(val[0], (str, int)):
|
||||
src = str(val[0])
|
||||
consumed.add(src)
|
||||
if src not in nodes:
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "dangling_link",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, node)}的输入「{name}」"
|
||||
f"连到了不存在的节点 {src}",
|
||||
}
|
||||
)
|
||||
ct = str(node.get("class_type") or "")
|
||||
if ct in _NEEDS_MODEL and not isinstance(inputs.get("model"), list):
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "missing_model_link",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, node)}({ct})没有连接模型(model),"
|
||||
"模型加载链路在导出时丢失",
|
||||
}
|
||||
)
|
||||
|
||||
for nid, node in nodes.items():
|
||||
ct = str(node.get("class_type") or "")
|
||||
if ct.startswith("Empty") and "Latent" in ct and nid not in consumed:
|
||||
found.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "orphan_latent",
|
||||
"node_id": nid,
|
||||
"message": f"{_title(nid, nodes[nid])}没有接到任何节点,"
|
||||
"它的宽高不会生效,已不在表单中显示",
|
||||
}
|
||||
)
|
||||
|
||||
# HEL-636/638: a workflow without any recognizable output node can never
|
||||
# return a result — block it with a readable reason instead of letting
|
||||
# ComfyUI run blind. Unknown candidates (needs_mapping) don't trigger
|
||||
# this: the user designates them on the import/connections page, and
|
||||
# until then the row stays disabled.
|
||||
from calliope.comfyui.parser import detect_output_nodes
|
||||
|
||||
outputs = detect_output_nodes(workflow)
|
||||
if not any(o.get("kind") in ("image", "video") for o in outputs):
|
||||
unknown = [o for o in outputs if o.get("evidence") == "unknown_node"]
|
||||
if not unknown:
|
||||
found.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "no_output",
|
||||
"node_id": "",
|
||||
"message": "未在图中找到输出节点(SaveImage / 视频合成等),"
|
||||
"请确认导出的是 API 格式且包含保存节点",
|
||||
}
|
||||
)
|
||||
else:
|
||||
names = "、".join(f"节点 {o['nodeId']}" for o in unknown[:4])
|
||||
found.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "unknown_output_node",
|
||||
"node_id": names,
|
||||
"message": f"存在无法识别的输出节点({names}),"
|
||||
"需人工指定实际输出节点及其媒体类型(图片/视频)后才能生成",
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def blocking_problem(workflow: dict[str, Any]) -> str | None:
|
||||
"""One Chinese sentence explaining why this workflow cannot be submitted."""
|
||||
errors = [w["message"] for w in workflow_warnings(workflow) if w["level"] == "error"]
|
||||
if not errors:
|
||||
return None
|
||||
shown = ";".join(errors[:4]) + (f";等 {len(errors)} 处" if len(errors) > 4 else "")
|
||||
return f"该工作流文件不完整,ComfyUI 无法执行:{shown}。{REEXPORT_HINT}。"
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Stored output-node mapping for imported workflows (HEL-638).
|
||||
|
||||
Unknown custom output nodes are never auto-typed. The import confirm page
|
||||
(or the connections table) stores the user's explicit designation — which
|
||||
node ids are the real outputs and each one's media type — in
|
||||
``workflows.output_map`` with ``output_map_source='user'``. Everything that
|
||||
re-derives schemas (reanalyze, migration, plain field edits) must keep that
|
||||
decision; only a fresh explicit payload replaces it.
|
||||
|
||||
Rows without unknown candidates carry an ``auto`` map derived from the
|
||||
trusted registry/tagged outputs so the generation path always knows which
|
||||
history nodes to harvest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_outputs
|
||||
from calliope.comfyui.registry import (
|
||||
IMAGE_OUTPUT_CLASSES,
|
||||
NUMBER_CLASSES,
|
||||
TEXT_AREA_CLASSES,
|
||||
VIDEO_OUTPUT_CLASSES,
|
||||
)
|
||||
|
||||
VALID_OUTPUT_KINDS = ("image", "video")
|
||||
|
||||
|
||||
def _wiring_class(class_type: str) -> bool:
|
||||
"""Parameter / wiring classes can never be designated as outputs."""
|
||||
return (
|
||||
not class_type
|
||||
or class_type in TEXT_AREA_CLASSES
|
||||
or class_type in NUMBER_CLASSES
|
||||
or class_type.startswith("Empty")
|
||||
or class_type.startswith("Primitive")
|
||||
)
|
||||
|
||||
|
||||
def _entry_public(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
out = {
|
||||
"nodeId": str(entry.get("nodeId") or ""),
|
||||
"kind": entry.get("kind"),
|
||||
}
|
||||
if entry.get("label"):
|
||||
out["label"] = entry["label"]
|
||||
if entry.get("class_type"):
|
||||
out["class_type"] = entry["class_type"]
|
||||
return out
|
||||
|
||||
|
||||
def auto_output_map(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Trusted outputs only (tagged titles / registry classes)."""
|
||||
if not isinstance(workflow, dict):
|
||||
return []
|
||||
return [
|
||||
_entry_public(o)
|
||||
for o in parse_dynamic_outputs(workflow)
|
||||
if o.get("kind") in VALID_OUTPUT_KINDS
|
||||
]
|
||||
|
||||
|
||||
def needs_user_output_map(workflow: dict[str, Any]) -> bool:
|
||||
"""True when the graph has unknown output candidates (HEL-638)."""
|
||||
if not isinstance(workflow, dict):
|
||||
return False
|
||||
return any(
|
||||
o.get("evidence") == "unknown_node" for o in parse_dynamic_outputs(workflow)
|
||||
)
|
||||
|
||||
|
||||
def validate_output_map(
|
||||
workflow: dict[str, Any], entries: Any
|
||||
) -> list[str]:
|
||||
"""Chinese problems with a user-supplied output map; empty = valid."""
|
||||
if not isinstance(entries, list) or not entries:
|
||||
return ["输出节点映射不能为空:请至少指定一个输出节点及其媒体类型"]
|
||||
seen: set[str] = set()
|
||||
problems: list[str] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
problems.append("输出节点映射格式有误(应为对象数组)")
|
||||
continue
|
||||
nid = str(entry.get("nodeId") or "")
|
||||
node = workflow.get(nid) if isinstance(workflow, dict) else None
|
||||
if not nid or not isinstance(node, dict):
|
||||
problems.append(f"输出节点 {nid or '?'} 不存在于该工作流中")
|
||||
continue
|
||||
ct = str(node.get("class_type") or "").strip()
|
||||
if not ct:
|
||||
problems.append(f"节点 {nid} 缺少节点类型,不能作为输出节点")
|
||||
elif _wiring_class(ct) and ct not in (IMAGE_OUTPUT_CLASSES | VIDEO_OUTPUT_CLASSES):
|
||||
problems.append(f"节点 {nid}({ct})是参数/连线节点,不能作为输出节点")
|
||||
if entry.get("kind") not in VALID_OUTPUT_KINDS:
|
||||
problems.append(f"输出节点 {nid} 的媒体类型必须是图片或视频")
|
||||
if nid in seen:
|
||||
problems.append(f"输出节点 {nid} 重复指定")
|
||||
seen.add(nid)
|
||||
return problems
|
||||
|
||||
|
||||
def load_output_map(raw: str | None) -> list[dict[str, Any]]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def resolve_output_map(
|
||||
output_map_raw: str | None, source: str | None, workflow: dict[str, Any]
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""The map generation harvests, plus whose decision it is.
|
||||
|
||||
A user map wins as long as its node ids still exist in the stored JSON
|
||||
(defensive pruning — PATCH cannot edit the JSON, but keep it safe);
|
||||
otherwise the trusted auto map applies. Empty list back means "not
|
||||
confirmed yet".
|
||||
"""
|
||||
if source == "user":
|
||||
entries = [
|
||||
e for e in load_output_map(output_map_raw) if str(e.get("nodeId") or "") in
|
||||
(workflow if isinstance(workflow, dict) else {})
|
||||
]
|
||||
if entries:
|
||||
return entries, "user"
|
||||
return [], None
|
||||
auto = auto_output_map(workflow)
|
||||
return (auto, "auto") if auto else ([], None)
|
||||
|
||||
|
||||
def mapping_problem(
|
||||
workflow: dict[str, Any], output_map_raw: str | None, source: str | None
|
||||
) -> str | None:
|
||||
"""Chinese reason this workflow's outputs are not confirmed, else None."""
|
||||
if not needs_user_output_map(workflow):
|
||||
return None
|
||||
entries, resolved = resolve_output_map(output_map_raw, source, workflow)
|
||||
if resolved == "user" and entries:
|
||||
return None
|
||||
return (
|
||||
"该工作流含无法识别的自定义输出节点,需在「ComfyUI 连接」页指定"
|
||||
"实际输出节点及其媒体类型(图片/视频)后才能启用或生成"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,47 @@
|
||||
"""Patch user values into ComfyUI API-format workflow by nodeId."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.registry import class_to_patch_field
|
||||
from calliope.comfyui.registry import (
|
||||
AUDIO_CLASSES,
|
||||
IMAGE_CLASSES,
|
||||
VIDEO_CLASSES,
|
||||
VIDEO_FILE_CLASSES,
|
||||
class_to_patch_field,
|
||||
)
|
||||
|
||||
# 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:
|
||||
@@ -26,6 +63,85 @@ def _resolve_field(field: str, inputs: dict[str, Any]) -> str:
|
||||
return field
|
||||
|
||||
|
||||
def resolve_patch_field(node: dict[str, Any]) -> str:
|
||||
"""The widget a plain nodeId key writes to on this node."""
|
||||
return _resolve_field(
|
||||
class_to_patch_field(node.get("class_type", "")), dict(node.get("inputs") or {})
|
||||
)
|
||||
|
||||
|
||||
def _next_node_id(workflow: dict[str, Any]) -> str:
|
||||
numeric = [int(k) for k in workflow if str(k).isdigit()]
|
||||
return str(max(numeric, default=0) + 1)
|
||||
|
||||
|
||||
def apply_media_group(
|
||||
workflow: dict[str, Any], node_id: str, group: str, paths: list[str]
|
||||
) -> None:
|
||||
"""Rewire an Autogrow media group (image/video/audio) to exactly ``paths``.
|
||||
|
||||
``<group>.<prefix>0 … N-1`` each get their own loader node: the
|
||||
workflow's existing ones are reused in slot order, extra ones are clones
|
||||
of the first, and slots/loaders left over from the export are removed so
|
||||
no stale media reaches ComfyUI. File values are local paths —
|
||||
``prepare_media_inputs`` uploads them right before queueing.
|
||||
"""
|
||||
from calliope.comfyui.parser import image_group_prefix, image_group_slots
|
||||
|
||||
node = workflow.get(node_id)
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
prefix = image_group_prefix(node, group)
|
||||
if prefix is None:
|
||||
return
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
slots = image_group_slots(node, group, prefix)
|
||||
loaders: list[str] = []
|
||||
for _n, name, link in slots:
|
||||
inputs.pop(name, None)
|
||||
if isinstance(link, list) and len(link) == 2:
|
||||
src = workflow.get(str(link[0]))
|
||||
if isinstance(src, dict) and str(src.get("class_type") or "") in (
|
||||
IMAGE_CLASSES | VIDEO_CLASSES | AUDIO_CLASSES
|
||||
):
|
||||
loaders.append(str(link[0]))
|
||||
if not loaders:
|
||||
return
|
||||
template = copy.deepcopy(workflow[loaders[0]])
|
||||
label = _loader_label(str(template.get("class_type") or ""))
|
||||
|
||||
still_used: set[str] = set()
|
||||
for nid, other in workflow.items():
|
||||
if nid == node_id or not isinstance(other, dict):
|
||||
continue
|
||||
for val in (other.get("inputs") or {}).values():
|
||||
if isinstance(val, list) and len(val) == 2:
|
||||
still_used.add(str(val[0]))
|
||||
for nid in loaders[len(paths):]:
|
||||
if nid not in still_used and nid not in loaders[: len(paths)]:
|
||||
workflow.pop(nid, None)
|
||||
|
||||
for i, path in enumerate(paths):
|
||||
if i < len(loaders):
|
||||
nid = loaders[i]
|
||||
loader = workflow[nid]
|
||||
else:
|
||||
nid = _next_node_id(workflow)
|
||||
loader = copy.deepcopy(template)
|
||||
loader_inputs = dict(loader.get("inputs") or {})
|
||||
field = _loader_field(str(loader.get("class_type") or ""), loader_inputs)
|
||||
loader_inputs[field] = path
|
||||
loader["inputs"] = loader_inputs
|
||||
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{label} {i + 1}"}
|
||||
workflow[nid] = loader
|
||||
inputs[f"{group}.{prefix}{i}"] = [nid, 0]
|
||||
node["inputs"] = inputs
|
||||
|
||||
|
||||
# Original name kept: image groups were the first (and most common) family.
|
||||
apply_image_group = apply_media_group
|
||||
|
||||
|
||||
def patch_workflow(
|
||||
base: dict[str, Any],
|
||||
values_by_node_id: dict[str, Any],
|
||||
@@ -35,11 +151,27 @@ def patch_workflow(
|
||||
if value is None:
|
||||
continue
|
||||
key = str(node_id)
|
||||
field: str | None = None
|
||||
if key not in patched and FIELD_KEY_SEP in key:
|
||||
key, field = key.rsplit(FIELD_KEY_SEP, 1)
|
||||
node = patched.get(key)
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
field = _resolve_field(class_to_patch_field(node.get("class_type", "")), inputs)
|
||||
if field is not None and field not in inputs and any(
|
||||
k.startswith(f"{field}.") for k in inputs
|
||||
):
|
||||
paths = [value] if isinstance(value, str) else value
|
||||
if isinstance(paths, list):
|
||||
clean = [p for p in paths if isinstance(p, str) and p.strip()]
|
||||
if clean:
|
||||
apply_media_group(patched, key, field, clean)
|
||||
continue
|
||||
if field is None:
|
||||
field = resolve_patch_field(node)
|
||||
elif isinstance(inputs.get(field), list):
|
||||
# Linked input ([src_id, slot]) — never overwrite the wiring.
|
||||
continue
|
||||
inputs[field] = value
|
||||
node["inputs"] = inputs
|
||||
patched[key] = 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
|
||||
@@ -20,7 +20,7 @@ TEXT_AREA_CLASSES = frozenset(
|
||||
NUMBER_CLASSES = frozenset(
|
||||
{"INT", "FLOAT", "PrimitiveInt", "PrimitiveFloat", "KSampler", "KSamplerAdvanced"}
|
||||
)
|
||||
IMAGE_CLASSES = frozenset({"LoadImage", "ImageLoader", "ETN_LoadImageBase64"})
|
||||
IMAGE_CLASSES = frozenset({"LoadImage", "LoadImageMask", "ImageLoader", "ETN_LoadImageBase64"})
|
||||
IMAGE_URL_CLASSES = frozenset({"Load Image From Url (mtb)"})
|
||||
AUDIO_CLASSES = frozenset({"LoadAudio", "VHS_LoadAudio"})
|
||||
# Stock LoadVideo uses the `file` widget; VHS uses `video`.
|
||||
@@ -30,7 +30,8 @@ VIDEO_OUTPUT_CLASSES = frozenset(
|
||||
{"VHS_VideoCombine", "SaveVideo", "VideoOutput", "AnimateDiffCombine"}
|
||||
)
|
||||
IMAGE_OUTPUT_CLASSES = frozenset(
|
||||
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket"}
|
||||
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket",
|
||||
"SaveImageAdvanced"}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,27 @@ OUTPUT_ROLE_ALIASES: dict[str, frozenset[str]] = {
|
||||
"video": frozenset({"video", "vid"}),
|
||||
}
|
||||
|
||||
# The five workflow usages the studio distinguishes (HEL-636). Derived from
|
||||
# graph evidence only (output media + consumed image inputs) — never from
|
||||
# file names, model names or fixed node ids.
|
||||
WorkflowUsage = str
|
||||
USAGE_VALUES: tuple[str, ...] = (
|
||||
"text2image",
|
||||
"image2image",
|
||||
"text2video",
|
||||
"image2video",
|
||||
"ref2video",
|
||||
)
|
||||
|
||||
|
||||
def usage_to_kind(usage: str | None) -> str | None:
|
||||
"""Output media kind of a usage (image / video); None when unknown."""
|
||||
if usage in ("text2image", "image2image"):
|
||||
return "image"
|
||||
if usage in ("text2video", "image2video", "ref2video"):
|
||||
return "video"
|
||||
return None
|
||||
|
||||
|
||||
def parse_title_tag(title: str) -> tuple[str | None, str | None, str]:
|
||||
"""Return (kind, role, label) from a Comfy node title.
|
||||
|
||||
@@ -13,6 +13,13 @@ def _find_by_role(inputs: list[dict[str, Any]], *roles: str) -> dict[str, Any] |
|
||||
return None
|
||||
|
||||
|
||||
def _node_sort_key(node_id: object) -> tuple:
|
||||
"""Numeric order for "12", subgraph "171:154" and field keys "3@seed"."""
|
||||
return tuple(
|
||||
(0, int(p)) if p.isdigit() else (1, p) for p in str(node_id).replace("@", ":").split(":")
|
||||
)
|
||||
|
||||
|
||||
def _find_all_by_role(inputs: list[dict[str, Any]], *roles: str) -> list[dict[str, Any]]:
|
||||
"""All inputs matching any of the roles, sorted by numeric node id.
|
||||
|
||||
@@ -21,7 +28,7 @@ def _find_all_by_role(inputs: list[dict[str, Any]], *roles: str) -> list[dict[st
|
||||
in this order and it defines ``<Subject N>`` numbering in the prompt.
|
||||
"""
|
||||
matches = [inp for inp in inputs if input_has_role(inp, *roles)]
|
||||
return sorted(matches, key=lambda i: int(i["nodeId"]))
|
||||
return sorted(matches, key=lambda i: _node_sort_key(i["nodeId"]))
|
||||
|
||||
|
||||
def ref_image_slots(inputs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
@@ -151,8 +158,12 @@ def smart_fill_inputs(
|
||||
break
|
||||
|
||||
if ref_images:
|
||||
for inp, path in zip(ref_image_slots(inputs), ref_images):
|
||||
values[str(inp["nodeId"])] = path
|
||||
group = next((inp for inp in inputs if inp.get("kind") == "image_list"), None)
|
||||
if group is not None:
|
||||
values[str(group["nodeId"])] = list(ref_images[: group.get("max") or len(ref_images)])
|
||||
else:
|
||||
for inp, path in zip(ref_image_slots(inputs), ref_images):
|
||||
values[str(inp["nodeId"])] = path
|
||||
|
||||
if ref_videos:
|
||||
for inp, path in zip(ref_video_slots(inputs), ref_videos):
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Check and coerce studio form values against a workflow's input schema."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _fmt(n: Any) -> str:
|
||||
return f"{n:g}" if isinstance(n, float) else str(n)
|
||||
|
||||
|
||||
def validate_input_values(
|
||||
schema: list[dict[str, Any]], values: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Return ``(clean_values, errors)``.
|
||||
|
||||
Numbers are coerced to int/float per the schema entry (``integer``,
|
||||
``min``/``max``); select values must be one of ``options``. Blank values
|
||||
are dropped so the workflow keeps its own value. Keys not in the schema
|
||||
pass through untouched (other callers patch extra keys, e.g. uploads).
|
||||
Auto-detected required entries (positive prompt, wired image slots,
|
||||
reference-image lists) must be present — tagged workflows mark every
|
||||
input required, and blank ones fall back to the workflow's value.
|
||||
"""
|
||||
by_key = {str(e.get("nodeId")): e for e in schema if isinstance(e, dict)}
|
||||
clean: dict[str, Any] = {}
|
||||
errors: list[str] = []
|
||||
for key, raw in (values or {}).items():
|
||||
entry = by_key.get(str(key))
|
||||
if entry is None:
|
||||
clean[key] = raw
|
||||
continue
|
||||
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
||||
continue
|
||||
label = str(entry.get("label") or key)
|
||||
kind = entry.get("kind")
|
||||
if kind == "number":
|
||||
if isinstance(raw, bool):
|
||||
errors.append(f"「{label}」必须是数字")
|
||||
continue
|
||||
try:
|
||||
num = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"「{label}」必须是数字")
|
||||
continue
|
||||
if not math.isfinite(num):
|
||||
errors.append(f"「{label}」必须是有限数字")
|
||||
continue
|
||||
if entry.get("integer"):
|
||||
if num != int(num):
|
||||
errors.append(f"「{label}」必须是整数")
|
||||
continue
|
||||
value: int | float = int(num)
|
||||
else:
|
||||
value = int(num) if isinstance(raw, int) else num
|
||||
lo, hi = entry.get("min"), entry.get("max")
|
||||
if lo is not None and value < lo:
|
||||
errors.append(f"「{label}」不能小于 {_fmt(lo)}")
|
||||
continue
|
||||
if hi is not None and value > hi:
|
||||
errors.append(f"「{label}」不能大于 {_fmt(hi)}")
|
||||
continue
|
||||
clean[key] = value
|
||||
elif kind in ("image_list", "video_list", "audio_list"):
|
||||
paths = [raw] if isinstance(raw, str) else raw
|
||||
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
|
||||
errors.append(f"「{label}」的取值格式不对,请重新上传")
|
||||
continue
|
||||
paths = [p for p in paths if p.strip()]
|
||||
unit = _LIST_UNITS[kind]
|
||||
noun = _LIST_NOUNS[kind]
|
||||
hi = entry.get("max")
|
||||
if hi is not None and len(paths) > hi:
|
||||
errors.append(
|
||||
f"{label}最多 {hi} {unit}(当前 {len(paths)} {unit}),请删除多余的{noun}"
|
||||
)
|
||||
continue
|
||||
bad = [
|
||||
f"{label} {i}:{msg}"
|
||||
for i, p in enumerate(paths, 1)
|
||||
if (msg := _media_file_problem(p, kind))
|
||||
]
|
||||
if bad:
|
||||
errors.extend(bad)
|
||||
continue
|
||||
if paths:
|
||||
clean[key] = paths
|
||||
elif kind == "select":
|
||||
options = entry.get("options") or []
|
||||
if options and raw not in options:
|
||||
errors.append(f"「{label}」的取值「{raw}」不在工作流支持的选项中")
|
||||
continue
|
||||
clean[key] = raw
|
||||
else:
|
||||
clean[key] = raw
|
||||
for key, entry in by_key.items():
|
||||
required = entry.get("auto") and entry.get("required")
|
||||
label = entry.get("label") or key
|
||||
if required and entry.get("role") == "prompt":
|
||||
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
|
||||
if key not in clean and not has_fallback:
|
||||
errors.append(f"请填写「{label}」")
|
||||
elif required and entry.get("kind") in ("image_list", "video_list", "audio_list"):
|
||||
lo = entry.get("min") or 1
|
||||
unit = _LIST_UNITS[entry.get("kind")]
|
||||
if len(clean.get(key) or []) < lo and not any(label in e for e in errors):
|
||||
errors.append(f"需要至少 {lo} {unit}{label}")
|
||||
elif required and entry.get("kind") == "image" and entry.get("role") != "mask":
|
||||
if key not in clean:
|
||||
errors.append(f"请上传{label}")
|
||||
return clean, errors
|
||||
|
||||
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp"})
|
||||
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mov"})
|
||||
_AUDIO_EXTS = frozenset({".mp3", ".wav", ".m4a"})
|
||||
_LIST_UNITS = {"image_list": "张", "video_list": "段", "audio_list": "段"}
|
||||
_LIST_NOUNS = {"image_list": "图片", "video_list": "视频", "audio_list": "音频"}
|
||||
_LIST_EXTS = {"image_list": _IMAGE_EXTS, "video_list": _VIDEO_EXTS, "audio_list": _AUDIO_EXTS}
|
||||
_LIST_FORMAT_HINT = {
|
||||
"image_list": "格式不支持,请使用 PNG / JPG / WebP",
|
||||
"video_list": "格式不支持,请使用 MP4 / WebM / MOV",
|
||||
"audio_list": "格式不支持,请使用 MP3 / WAV / M4A",
|
||||
}
|
||||
|
||||
|
||||
def _media_file_problem(path: str, kind: str) -> str | None:
|
||||
"""Why ``path`` cannot fill a media-list slot of this kind (None = fine)."""
|
||||
from calliope import config
|
||||
|
||||
target = Path(path)
|
||||
if target.suffix.lower() not in _LIST_EXTS[kind]:
|
||||
return _LIST_FORMAT_HINT[kind]
|
||||
try:
|
||||
target.resolve().relative_to(config.settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return f"只能使用在本页上传或从生成结果选入的{_LIST_NOUNS[kind]}"
|
||||
if not target.is_file():
|
||||
return "文件已不存在,请重新上传"
|
||||
return None
|
||||
|
||||
|
||||
def _image_file_problem(path: str) -> str | None:
|
||||
return _media_file_problem(path, "image_list")
|
||||
@@ -121,7 +121,11 @@ CREATE TABLE IF NOT EXISTS workflows (
|
||||
description TEXT,
|
||||
prompt_profile TEXT NOT NULL DEFAULT 'prose',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
usage TEXT,
|
||||
usage_source TEXT,
|
||||
output_map TEXT,
|
||||
output_map_source TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
@@ -138,7 +142,9 @@ CREATE TABLE IF NOT EXISTS jobs (
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
comfy_prompt_id TEXT,
|
||||
phase TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
@@ -336,6 +342,74 @@ def _sqlite_connect(path: Path) -> sqlite3.Connection:
|
||||
return sqlite3.connect(path)
|
||||
|
||||
|
||||
def _backfill_workflow_usage(conn: sqlite3.Connection) -> None:
|
||||
"""HEL-636: settle `workflows.usage` for rows that still have none.
|
||||
|
||||
Determinate graphs get ``usage='auto'``; indeterminate ones keep NULL
|
||||
and are disabled (they must be confirmed on the connections page before
|
||||
they can generate). Rows the user already confirmed (usage_source='user')
|
||||
never pass through here again — their usage is set, so the WHERE clause
|
||||
skips them. Old jobs / history / `kind` semantics are untouched.
|
||||
"""
|
||||
from calliope.comfyui.parser import classify_usage
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, workflow_json FROM workflows WHERE usage IS NULL OR usage = ''"
|
||||
).fetchall()
|
||||
for row in rows: # plain connect — positional access
|
||||
try:
|
||||
workflow = json.loads(row[1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
workflow = None
|
||||
judgment = classify_usage(workflow) if isinstance(workflow, dict) else {}
|
||||
if judgment.get("usage"):
|
||||
conn.execute(
|
||||
"UPDATE workflows SET usage = ?, usage_source = 'auto' WHERE id = ?",
|
||||
(judgment["usage"], row[0]),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE workflows SET is_enabled = 0 WHERE id = ? AND usage IS NULL",
|
||||
(row[0],),
|
||||
)
|
||||
|
||||
|
||||
def _backfill_workflow_output_map(conn: sqlite3.Connection) -> None:
|
||||
"""HEL-638: settle the output-node mapping for existing rows.
|
||||
|
||||
Rows the user already mapped (output_map_source='user') are never
|
||||
touched. Determinate graphs get the trusted ``auto`` map. Rows whose
|
||||
graph now contains unknown custom output candidates get no auto map and
|
||||
are parked disabled — the usage decision survives, but until the user
|
||||
designates the output nodes the workflow must not generate.
|
||||
"""
|
||||
from calliope.comfyui.output_map import auto_output_map, needs_user_output_map
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, workflow_json FROM workflows"
|
||||
" WHERE output_map_source IS NULL OR output_map_source != 'user'"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
try:
|
||||
workflow = json.loads(row[1])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
workflow = None
|
||||
if not isinstance(workflow, dict):
|
||||
continue
|
||||
if needs_user_output_map(workflow):
|
||||
conn.execute(
|
||||
"UPDATE workflows SET output_map = NULL, output_map_source = NULL,"
|
||||
" is_enabled = 0 WHERE id = ?",
|
||||
(row[0],),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE workflows SET output_map = ?, output_map_source = 'auto'"
|
||||
" WHERE id = ?",
|
||||
(json.dumps(auto_output_map(workflow)), row[0]),
|
||||
)
|
||||
|
||||
|
||||
async def migrate_db(db_path: Path) -> None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
@@ -381,6 +455,23 @@ async def migrate_db(db_path: Path) -> None:
|
||||
# comfyui_endpoints id). Dangling ids are tolerated — enqueue falls
|
||||
# back to the active connection.
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN endpoint_id TEXT")
|
||||
# HEL-636: five-way usage (text2image…ref2video). NULL = pending human
|
||||
# confirmation; `kind` keeps meaning output media for history/paths.
|
||||
if "usage" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN usage TEXT")
|
||||
if "usage_source" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN usage_source TEXT")
|
||||
# HEL-638: explicit output-node designation for unknown custom output
|
||||
# nodes ({nodeId, kind} JSON + 'user'|'auto'). Idempotent backfill
|
||||
# below parks unmapped unknown-output rows (disabled) like usage does.
|
||||
if "output_map" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN output_map TEXT")
|
||||
if "output_map_source" not in cols:
|
||||
conn.execute("ALTER TABLE workflows ADD COLUMN output_map_source TEXT")
|
||||
# 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)
|
||||
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")
|
||||
@@ -420,6 +511,18 @@ async def migrate_db(db_path: Path) -> None:
|
||||
WHERE kind = 'video' AND scene_id IS NOT NULL AND clip_id IS NULL
|
||||
"""
|
||||
)
|
||||
# HEL-627: the ComfyUI prompt id a job was accepted under, and where the
|
||||
# job really is (connecting / comfy_queued / comfy_running / saving;
|
||||
# finished = real results saved; dry_run = placeholder, ComfyUI never called).
|
||||
if "comfy_prompt_id" not in job_cols:
|
||||
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
|
||||
|
||||
@@ -198,14 +198,35 @@ class ExpandClipsRequest(BaseModel):
|
||||
clip_cap: int | None = None
|
||||
|
||||
|
||||
WorkflowUsageKind = Literal[
|
||||
"text2image",
|
||||
"image2image",
|
||||
"text2video",
|
||||
"image2video",
|
||||
"ref2video",
|
||||
]
|
||||
|
||||
|
||||
class WorkflowCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1)
|
||||
kind: Literal["image", "video"] = "image"
|
||||
kind: Literal["image", "video"] | None = None
|
||||
workflow_json: dict[str, Any]
|
||||
description: str | None = None
|
||||
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
|
||||
# Optional binding to a ComfyUI connection id (settings.comfyui_endpoints).
|
||||
endpoint_id: str | None = None
|
||||
# HEL-636: five-way usage. Given by the import confirm page (possibly a
|
||||
# user correction of the auto judgment); when absent it is classified
|
||||
# from the graph, and indeterminate workflows stay pending (disabled).
|
||||
usage: WorkflowUsageKind | None = None
|
||||
usage_source: Literal["auto", "user"] | None = None
|
||||
# Schema corrected on the confirm page (manual role overrides included).
|
||||
input_schema: list[dict[str, Any]] | None = None
|
||||
# HEL-638: explicit output-node designation for unknown custom output
|
||||
# nodes — [{nodeId, kind: image|video}]. Required before a workflow
|
||||
# with unknown outputs can be enabled or generated.
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
output_map_source: Literal["auto", "user"] | None = None
|
||||
|
||||
|
||||
class WorkflowUpdate(BaseModel):
|
||||
@@ -215,10 +236,34 @@ class WorkflowUpdate(BaseModel):
|
||||
prompt_profile: Literal["prose", "minimax_h3_ref"] | None = None
|
||||
is_enabled: bool | None = None
|
||||
endpoint_id: str | None = None
|
||||
usage: WorkflowUsageKind | None = None
|
||||
usage_source: Literal["auto", "user"] | None = None
|
||||
input_schema: list[dict[str, Any]] | None = None
|
||||
# HEL-638: set/replace the user output mapping; an explicit empty list
|
||||
# resets the workflow to pending confirmation.
|
||||
output_map: list[dict[str, Any]] | None = None
|
||||
output_map_source: Literal["auto", "user"] | None = None
|
||||
|
||||
|
||||
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):
|
||||
|
||||
@@ -19,7 +19,8 @@ class QueueManager:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"UPDATE jobs SET status = 'pending', started_at = NULL WHERE status = 'running'"
|
||||
"UPDATE jobs SET status = 'pending', started_at = NULL, phase = NULL, "
|
||||
"comfy_prompt_id = NULL WHERE status = 'running'"
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
@@ -35,16 +36,18 @@ 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),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
@@ -105,6 +108,20 @@ class QueueManager:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def set_phase(self, job_id: int, phase: str, comfy_prompt_id: str | None = None) -> None:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
if comfy_prompt_id:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET phase = ?, comfy_prompt_id = ? WHERE id = ?",
|
||||
(phase, comfy_prompt_id, job_id),
|
||||
)
|
||||
else:
|
||||
conn.execute("UPDATE jobs SET phase = ? WHERE id = ?", (phase, job_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def mark_done(self, job_id: int, output_paths: list[str]) -> None:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
@@ -142,7 +159,7 @@ class QueueManager:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE jobs SET status = 'pending', error = NULL, started_at = NULL,
|
||||
completed_at = NULL WHERE id = ?
|
||||
completed_at = NULL, phase = NULL, comfy_prompt_id = NULL WHERE id = ?
|
||||
""",
|
||||
(job_id,),
|
||||
)
|
||||
|
||||
@@ -8,9 +8,12 @@ from typing import Any
|
||||
|
||||
from pathlib import Path as _fs_path
|
||||
|
||||
import httpx
|
||||
|
||||
from calliope import config
|
||||
from calliope.comfyui.client import ComfyUIClient
|
||||
from calliope.comfyui.dry_run import write_placeholder_mp4, write_placeholder_png
|
||||
from calliope.comfyui.lint import blocking_problem
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.roles import input_has_role
|
||||
@@ -18,10 +21,37 @@ 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")
|
||||
|
||||
|
||||
def _comfy_messages(messages: list[Any]) -> str:
|
||||
"""Pull the readable part out of ComfyUI's history status messages."""
|
||||
parts: list[str] = []
|
||||
for msg in messages:
|
||||
if not (isinstance(msg, (list, tuple)) and len(msg) == 2):
|
||||
continue
|
||||
body = msg[1] if isinstance(msg[1], dict) else {}
|
||||
node = body.get("node_type") or body.get("node_id") or "?"
|
||||
if msg[0] == "execution_error":
|
||||
reason = body.get("exception_message") or body.get("exception_type") or "未知错误"
|
||||
parts.append(f"节点 {node}:{str(reason).strip()}")
|
||||
elif msg[0] == "execution_interrupted":
|
||||
parts.append(f"任务在节点 {node} 处被中断(在 ComfyUI 端被取消,或 ComfyUI 已重启)")
|
||||
return ";".join(parts) or "ComfyUI 未给出原因"
|
||||
|
||||
|
||||
# /queue lookups that must agree before a prompt counts as lost, and /history
|
||||
# or /queue request failures in a row before the job gives up.
|
||||
_MISSING_POLLS_LIMIT = 3
|
||||
_POLL_ERRORS_LIMIT = 5
|
||||
|
||||
|
||||
class _CancelledByUser(RuntimeError):
|
||||
"""The job row was flipped to 'cancelled' out-of-band (Stop button).
|
||||
The row already carries the terminal state; the loop must not overwrite
|
||||
@@ -79,6 +109,7 @@ class QueueWorker:
|
||||
job_payload = json.loads(job["payload_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
job_payload = {}
|
||||
row = queue_manager.get_job(job["id"]) or {}
|
||||
await event_bus.publish(
|
||||
"job.completed",
|
||||
{
|
||||
@@ -90,6 +121,9 @@ class QueueWorker:
|
||||
# Source stamp lets the UI attribute sandbox jobs
|
||||
# to their enqueuer without racing the jobs poll.
|
||||
"source": job_payload.get("source"),
|
||||
"comfy_prompt_id": row.get("comfy_prompt_id"),
|
||||
# Placeholder output — ComfyUI was never called.
|
||||
"dry_run": row.get("phase") == "dry_run",
|
||||
"message": f"{self._job_label(job)} · {len(outputs)} file(s)",
|
||||
},
|
||||
)
|
||||
@@ -135,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)
|
||||
@@ -144,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":
|
||||
@@ -154,9 +199,18 @@ 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)
|
||||
|
||||
queue_manager.set_phase(job["id"], "connecting")
|
||||
healthy = await client.health()
|
||||
if not healthy:
|
||||
raise RuntimeError(
|
||||
@@ -165,9 +219,24 @@ class QueueWorker:
|
||||
)
|
||||
|
||||
workflow_id = job.get("workflow_id") or payload.get("workflow_id")
|
||||
workflow = self._load_workflow(workflow_id)
|
||||
if not workflow:
|
||||
raise RuntimeError("No workflow found for job")
|
||||
workflow_row = self._load_workflow_row(workflow_id)
|
||||
if not workflow_row:
|
||||
raise RuntimeError(
|
||||
"找不到该任务使用的工作流(可能已被删除),请重新选择工作流后再生成"
|
||||
)
|
||||
workflow = workflow_row["workflow_json"]
|
||||
broken = blocking_problem(workflow)
|
||||
if broken:
|
||||
raise RuntimeError(broken)
|
||||
|
||||
# HEL-638: harvest only the confirmed output nodes. A workflow
|
||||
# with unknown custom outputs and no user mapping must not run.
|
||||
node_kinds = self._output_node_kinds(workflow_row)
|
||||
if node_kinds is None:
|
||||
raise RuntimeError(
|
||||
"该工作流含无法识别的自定义输出节点,尚未指定输出节点及媒体类型,"
|
||||
"请到「ComfyUI 连接」页确认后再生成"
|
||||
)
|
||||
|
||||
input_values = payload.get("input_values") or {}
|
||||
if payload.get("continue_source") and kind == "video":
|
||||
@@ -177,20 +246,66 @@ class QueueWorker:
|
||||
patched = patch_workflow(workflow, input_values)
|
||||
patched = await client.prepare_media_inputs(patched)
|
||||
prompt_id = await client.queue_prompt(patched)
|
||||
queue_manager.set_phase(job["id"], "comfy_queued", prompt_id)
|
||||
await event_bus.publish(
|
||||
"job.progress",
|
||||
{
|
||||
"job_id": job["id"],
|
||||
"prompt_id": prompt_id,
|
||||
"phase": "comfy_queued",
|
||||
"message": f"ComfyUI 已接收任务(ID {prompt_id})",
|
||||
},
|
||||
)
|
||||
|
||||
history = await self._poll_history(client, prompt_id, job["id"])
|
||||
if not history:
|
||||
if queue_manager.is_cancelled(job["id"]):
|
||||
raise _CancelledByUser()
|
||||
raise RuntimeError("Timed out waiting for ComfyUI history")
|
||||
raise RuntimeError(
|
||||
f"等待 ComfyUI 出结果超时(任务 ID {prompt_id}):可在「设置 → 队列」调大超时,"
|
||||
"或到 ComfyUI 检查该任务"
|
||||
)
|
||||
|
||||
status = history.get("status") or {}
|
||||
if status.get("status_str") == "error" or status.get("completed") is False:
|
||||
messages = status.get("messages") or []
|
||||
raise RuntimeError(f"ComfyUI error: {messages}")
|
||||
raise RuntimeError(
|
||||
f"ComfyUI 执行出错(任务 ID {prompt_id}):{_comfy_messages(messages)}"
|
||||
)
|
||||
|
||||
outputs_meta = client.extract_outputs(history)
|
||||
dest_dir = config.settings.assets_dir / str(project_id) / kind
|
||||
outputs_meta = client.extract_outputs(history, node_kinds=node_kinds)
|
||||
saved = [m for m in outputs_meta if m.get("type") == "output"]
|
||||
outputs_meta = saved or outputs_meta
|
||||
if not outputs_meta:
|
||||
checked = "、".join(sorted(node_kinds))
|
||||
raise RuntimeError(
|
||||
f"ComfyUI 显示任务已结束(任务 ID {prompt_id}),但没有返回任何图片或视频,"
|
||||
"不算生成完成。已按输出映射检查节点:" f"{checked};"
|
||||
"请到 ComfyUI 历史中核对该任务,并确认所指定的输出节点正确。"
|
||||
)
|
||||
mismatch = next(
|
||||
(
|
||||
m
|
||||
for m in outputs_meta
|
||||
if m.get("media_kind") and m["media_kind"] != node_kinds.get(m["node_id"])
|
||||
),
|
||||
None,
|
||||
)
|
||||
if mismatch:
|
||||
want = node_kinds.get(mismatch["node_id"])
|
||||
raise RuntimeError(
|
||||
f"输出节点 {mismatch['node_id']} 的媒体类型与映射不符"
|
||||
f"(映射为{'视频' if want == 'video' else '图片'},"
|
||||
f"实际返回 {mismatch['filename']}),任务不算生成完成。"
|
||||
"请到「ComfyUI 连接」页修正该节点的媒体类型。"
|
||||
)
|
||||
queue_manager.set_phase(job["id"], "saving")
|
||||
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:
|
||||
@@ -204,7 +319,9 @@ 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:
|
||||
await client.close()
|
||||
@@ -344,6 +461,9 @@ class QueueWorker:
|
||||
# configurable (default 30 min) instead of a hardcoded 600 seconds.
|
||||
attempts = None if timeout <= 0 else int(timeout / interval)
|
||||
n = 0
|
||||
phase = "comfy_queued"
|
||||
missing = 0
|
||||
errors = 0
|
||||
while attempts is None or n < attempts:
|
||||
n += 1
|
||||
if self._stop.is_set():
|
||||
@@ -354,17 +474,51 @@ class QueueWorker:
|
||||
if queue_manager.is_cancelled(job_id):
|
||||
await client.interrupt()
|
||||
return None
|
||||
history = await client.get_history(prompt_id)
|
||||
if history:
|
||||
return history
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
history = await client.get_history(prompt_id)
|
||||
if history:
|
||||
return history
|
||||
where, ahead = await client.locate_prompt(prompt_id)
|
||||
except httpx.HTTPError as exc:
|
||||
errors += 1
|
||||
if errors >= _POLL_ERRORS_LIMIT:
|
||||
raise RuntimeError(
|
||||
f"连续 {errors} 次无法从 ComfyUI 查询任务状态(任务 ID {prompt_id}):"
|
||||
f"{exc or type(exc).__name__}"
|
||||
) from exc
|
||||
await asyncio.sleep(interval)
|
||||
continue
|
||||
errors = 0
|
||||
if where == "missing":
|
||||
missing += 1
|
||||
if missing >= _MISSING_POLLS_LIMIT:
|
||||
raise RuntimeError(
|
||||
f"ComfyUI 的队列和历史里都找不到该任务(任务 ID {prompt_id}),"
|
||||
"可能 ComfyUI 已重启或任务被清除,没有产出结果"
|
||||
)
|
||||
else:
|
||||
missing = 0
|
||||
if where == "running":
|
||||
phase = "comfy_running"
|
||||
message = f"ComfyUI 正在执行(任务 ID {prompt_id})"
|
||||
elif where == "pending":
|
||||
phase = "comfy_queued"
|
||||
message = f"ComfyUI 排队中,前面还有 {ahead} 个任务(任务 ID {prompt_id})"
|
||||
else:
|
||||
message = f"等待 ComfyUI 返回结果(任务 ID {prompt_id})"
|
||||
if job_id is not None:
|
||||
queue_manager.set_phase(job_id, phase)
|
||||
await event_bus.publish(
|
||||
"job.progress",
|
||||
{
|
||||
"job_id": job_id,
|
||||
"prompt_id": prompt_id,
|
||||
"message": f"Waiting on ComfyUI ({prompt_id[:8]}…)",
|
||||
"phase": phase,
|
||||
"queue_ahead": ahead if where == "pending" else None,
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(interval)
|
||||
return None
|
||||
|
||||
def _mark_project_completed(self, project_id: int) -> None:
|
||||
@@ -379,20 +533,45 @@ class QueueWorker:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _load_workflow(self, workflow_id: int | None) -> dict[str, Any] | None:
|
||||
def _load_workflow_row(self, workflow_id: int | None) -> dict[str, Any] | None:
|
||||
if not workflow_id:
|
||||
return None
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT workflow_json FROM workflows WHERE id = ?", (workflow_id,)
|
||||
"SELECT workflow_json, output_map, output_map_source FROM workflows"
|
||||
" WHERE id = ?",
|
||||
(workflow_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return json.loads(row["workflow_json"])
|
||||
return {
|
||||
"workflow_json": json.loads(row["workflow_json"]),
|
||||
"output_map": row["output_map"],
|
||||
"output_map_source": row["output_map_source"],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _output_node_kinds(workflow_row: dict[str, Any]) -> dict[str, str] | None:
|
||||
"""Confirmed {nodeId: image|video} for this workflow, or None when
|
||||
the outputs are not confirmed (unknown nodes, no user mapping)."""
|
||||
from calliope.comfyui.output_map import mapping_problem, resolve_output_map
|
||||
|
||||
workflow = workflow_row["workflow_json"]
|
||||
problem = mapping_problem(
|
||||
workflow, workflow_row.get("output_map"), workflow_row.get("output_map_source")
|
||||
)
|
||||
if problem:
|
||||
return None
|
||||
entries, _source = resolve_output_map(
|
||||
workflow_row.get("output_map"),
|
||||
workflow_row.get("output_map_source"),
|
||||
workflow,
|
||||
)
|
||||
return {str(e["nodeId"]): str(e["kind"]) for e in entries} or None
|
||||
|
||||
def _job_label(self, job: dict[str, Any]) -> str:
|
||||
payload = json.loads(job.get("payload_json") or "{}")
|
||||
kind = job.get("kind") or "job"
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Workflow-test job helpers (HEL-643).
|
||||
|
||||
A test job (jobs.origin = 'workflow_test') runs the FULL real pipeline —
|
||||
queue → ComfyUI → outputs — but its artifacts are isolated from project
|
||||
entities: files land under ``assets/_workflow_test/{workflow_id}/`` and are
|
||||
never written back to characters/locations/scenes/clips.
|
||||
|
||||
This module owns the artifact directory, the retention policy (keep the
|
||||
newest N records per workflow) and the purge paths used by "清除测试记录"
|
||||
and workflow deletion.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from calliope import config
|
||||
from calliope.db import get_db
|
||||
|
||||
logger = logging.getLogger("calliope.queue")
|
||||
|
||||
TEST_ORIGIN = "workflow_test"
|
||||
TEST_SOURCE = "workflow_test"
|
||||
# Records (and artifacts) kept per workflow before older ones are pruned.
|
||||
TEST_KEEP = 5
|
||||
|
||||
|
||||
def workflow_test_dir(workflow_id: int) -> Path:
|
||||
return config.settings.assets_dir / "_workflow_test" / str(workflow_id)
|
||||
|
||||
|
||||
def _safe_unlink(path_str: str) -> bool:
|
||||
"""Delete a file if it resolves under assets_dir (missing = already gone)."""
|
||||
try:
|
||||
target = Path(path_str).resolve()
|
||||
target.relative_to(config.settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
if not target.is_file():
|
||||
return False
|
||||
try:
|
||||
target.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _unlink_job_outputs(job: dict[str, Any]) -> None:
|
||||
try:
|
||||
paths = json.loads(job.get("output_paths_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
if not isinstance(paths, list):
|
||||
return
|
||||
for p in paths:
|
||||
if isinstance(p, str) and p.strip():
|
||||
_safe_unlink(p)
|
||||
|
||||
|
||||
def _remove_test_dir_if_empty(workflow_id: int) -> None:
|
||||
d = workflow_test_dir(workflow_id)
|
||||
try:
|
||||
if d.is_dir() and not any(d.iterdir()):
|
||||
d.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _test_job_public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(job)
|
||||
out["payload"] = json.loads(job.get("payload_json") or "{}")
|
||||
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
|
||||
out.pop("payload_json", None)
|
||||
out.pop("output_paths_json", None)
|
||||
return out
|
||||
|
||||
|
||||
def list_test_jobs(workflow_id: int, limit: int = 20) -> list[dict[str, Any]]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" ORDER BY id DESC LIMIT ?",
|
||||
(workflow_id, TEST_ORIGIN, limit),
|
||||
).fetchall()
|
||||
return [_test_job_public(dict(r)) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def find_active_test_job(workflow_id: int, endpoint_id: str | None) -> dict[str, Any] | None:
|
||||
"""The unfinished test job for (workflow, endpoint), if one exists.
|
||||
|
||||
Idempotency guard for the "测试工作流" button: a second click re-uses the
|
||||
in-flight job instead of enqueuing a duplicate.
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" AND status IN ('pending', 'running') ORDER BY id DESC",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
for r in rows:
|
||||
job = dict(r)
|
||||
try:
|
||||
payload = json.loads(job.get("payload_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
payload = {}
|
||||
snap = payload.get("endpoint") or {}
|
||||
if not isinstance(snap, dict):
|
||||
snap = {}
|
||||
if (snap.get("id") or None) == (endpoint_id or None):
|
||||
return _test_job_public(job)
|
||||
return None
|
||||
|
||||
|
||||
def prune_workflow_tests(workflow_id: int, keep: int = TEST_KEEP) -> int:
|
||||
"""Keep only the newest ``keep`` test records (older rows + files go)."""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" ORDER BY id DESC LIMIT -1 OFFSET ?",
|
||||
(workflow_id, TEST_ORIGIN, max(keep, 0)),
|
||||
).fetchall()
|
||||
removed = 0
|
||||
for r in rows:
|
||||
job = dict(r)
|
||||
if job.get("status") in ("pending", "running"):
|
||||
continue
|
||||
_unlink_job_outputs(job)
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (job["id"],))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_remove_test_dir_if_empty(workflow_id)
|
||||
if removed:
|
||||
logger.info("Pruned %s old workflow-test records for workflow %s", removed, workflow_id)
|
||||
return removed
|
||||
|
||||
|
||||
def purge_workflow_tests(workflow_id: int, *, allow_active: bool = False) -> int:
|
||||
"""Delete EVERY test record and artifact for a workflow.
|
||||
|
||||
Returns the removed row count; raises RuntimeError when an unfinished
|
||||
test job is still in flight (unless ``allow_active``).
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
if not allow_active:
|
||||
active = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" AND status IN ('pending', 'running')",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchone()["n"]
|
||||
if active:
|
||||
raise RuntimeError(
|
||||
f"该工作流还有 {active} 个进行中的测试任务,请先等待完成或取消,再清除测试记录"
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchall()
|
||||
removed = 0
|
||||
for r in rows:
|
||||
_unlink_job_outputs(dict(r))
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (r["id"],))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
shutil.rmtree(workflow_test_dir(workflow_id), ignore_errors=True)
|
||||
return removed
|
||||
@@ -77,8 +77,11 @@ def _parsed_url(base_url: str) -> tuple[str, str]:
|
||||
url = (base_url or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="API 地址不能为空")
|
||||
if "://" not in url:
|
||||
url = f"http://{url}"
|
||||
# HEL-619: the server validates format as strictly as the UI already
|
||||
# does. A schemeless garbled string used to be silently prefixed with
|
||||
# http:// and then reported as a DNS failure; it is a 400 format error.
|
||||
if "://" not in url or any(ch.isspace() for ch in url):
|
||||
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise HTTPException(status_code=400, detail="地址必须是 http:// 或 https:// 开头的有效 URL")
|
||||
@@ -135,7 +138,12 @@ async def list_endpoints() -> dict[str, Any]:
|
||||
|
||||
@router.post("/endpoints")
|
||||
async def create_endpoint(payload: EndpointIn) -> dict[str, Any]:
|
||||
base_url, _host = _parsed_url(payload.base_url)
|
||||
base_url, host = _parsed_url(payload.base_url)
|
||||
# HEL-619: the same SSRF guard as the connectivity test applies on
|
||||
# save — otherwise a blocked address can be stored and later probed by
|
||||
# the worker path. Unresolvable hosts still pass (resolution happens
|
||||
# again at probe time).
|
||||
_assert_not_ssrf(host)
|
||||
incoming = [ep for ep in settings.comfyui_endpoints or []]
|
||||
incoming.append(payload.model_dump() | {"base_url": base_url})
|
||||
try:
|
||||
@@ -151,7 +159,8 @@ async def update_endpoint(endpoint_id: str, payload: EndpointPatch) -> dict[str,
|
||||
_stored_endpoint(endpoint_id)
|
||||
base_url = payload.base_url
|
||||
if base_url is not None:
|
||||
base_url, _host = _parsed_url(base_url)
|
||||
base_url, host = _parsed_url(base_url)
|
||||
_assert_not_ssrf(host) # same save-time guard as create (HEL-619)
|
||||
patch = payload.model_dump(exclude_unset=True)
|
||||
if base_url is not None:
|
||||
patch["base_url"] = base_url
|
||||
|
||||
@@ -13,6 +13,10 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from calliope import config
|
||||
from calliope.agent.prompts import item_reference_prompt
|
||||
from calliope.comfyui.lint import blocking_problem
|
||||
from calliope.comfyui.output_map import mapping_problem
|
||||
from calliope.comfyui.parser import effective_input_schema
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.db import get_db, row_to_dict
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.queue.manager import queue_manager
|
||||
@@ -193,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}")
|
||||
@@ -332,24 +343,46 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
try:
|
||||
pid = ensure_playground_project(conn)
|
||||
wf = conn.execute(
|
||||
"SELECT id, kind, is_enabled FROM workflows WHERE id = ?",
|
||||
"SELECT id, kind, is_enabled, workflow_json, input_schema, output_map,"
|
||||
" output_map_source FROM workflows WHERE id = ?",
|
||||
(payload.workflow_id,),
|
||||
).fetchone()
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
raise HTTPException(
|
||||
status_code=404, detail="工作流不存在(可能已被删除),请刷新页面后重新选择"
|
||||
)
|
||||
if not wf["is_enabled"]:
|
||||
raise HTTPException(status_code=400, detail="Workflow is disabled")
|
||||
raise HTTPException(
|
||||
status_code=400, detail="该工作流已停用,请到「ComfyUI 连接」页启用后再生成"
|
||||
)
|
||||
kind = wf["kind"]
|
||||
workflow_json = json.loads(wf["workflow_json"])
|
||||
# HEL-638: a workflow with unknown custom output nodes must have a
|
||||
# confirmed output mapping before anything is queued.
|
||||
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
broken = blocking_problem(workflow_json)
|
||||
if broken:
|
||||
raise HTTPException(status_code=400, detail=broken)
|
||||
|
||||
input_values, problems = validate_input_values(schema, payload.input_values)
|
||||
if problems:
|
||||
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
|
||||
|
||||
job = queue_manager.enqueue(
|
||||
project_id=pid,
|
||||
kind=kind,
|
||||
workflow_id=payload.workflow_id,
|
||||
scene_id=None,
|
||||
payload={
|
||||
"input_values": payload.input_values,
|
||||
"input_values": input_values,
|
||||
"source": payload.source,
|
||||
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
|
||||
},
|
||||
|
||||
@@ -107,7 +107,11 @@ async def update_settings(payload: SettingsUpdate) -> dict[str, Any]:
|
||||
if assignments_in is not None:
|
||||
settings.ensure_llm_profiles()
|
||||
ids = {p["id"] for p in settings.llm_profiles}
|
||||
cleaned: dict[str, str | None] = {}
|
||||
# HEL-619: per-role INCREMENTAL update. Rapid dropdown switches used
|
||||
# to send whole cached snapshots that fully overwrote the map, so a
|
||||
# stale snapshot could wipe a newer choice. Roles absent from the
|
||||
# payload keep their stored value; a role sent as null is cleared.
|
||||
cleaned: dict[str, str | None] = dict(settings.agent_llm_assignments or {})
|
||||
for role, pid in assignments_in.items():
|
||||
if role not in AGENT_LLM_ROLES:
|
||||
continue
|
||||
|
||||
@@ -5,11 +5,42 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs, parse_dynamic_outputs
|
||||
from calliope.comfyui.endpoints import client_for, endpoint_snapshot
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.output_map import (
|
||||
auto_output_map,
|
||||
load_output_map,
|
||||
mapping_problem,
|
||||
needs_user_output_map,
|
||||
validate_output_map,
|
||||
)
|
||||
from calliope.comfyui.parser import (
|
||||
classify_usage,
|
||||
effective_input_schema,
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
from calliope.comfyui.preflight import check_workflow_against
|
||||
from calliope.comfyui.profiles import detect_prompt_profile
|
||||
from calliope.comfyui.roles import usage_to_kind
|
||||
from calliope.comfyui.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,
|
||||
WorkflowCreate,
|
||||
WorkflowTestRequest,
|
||||
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()
|
||||
|
||||
@@ -17,29 +48,104 @@ router = APIRouter()
|
||||
def _serialize_workflow(row: Any) -> dict[str, Any]:
|
||||
data = row_to_dict(row)
|
||||
data["workflow_json"] = json.loads(data["workflow_json"])
|
||||
data["input_schema"] = json.loads(data["input_schema"]) if data.get("input_schema") else []
|
||||
data["output_schema"] = json.loads(data["output_schema"]) if data.get("output_schema") else []
|
||||
stored = json.loads(data["input_schema"]) if data.get("input_schema") else []
|
||||
data["input_schema"] = effective_input_schema(stored, data["workflow_json"])
|
||||
# Outputs are pure graph derivation (no manual overrides) — always fresh,
|
||||
# so parser upgrades reach old rows without a re-import.
|
||||
data["output_schema"] = parse_dynamic_outputs(data["workflow_json"])
|
||||
data["is_enabled"] = bool(data.get("is_enabled"))
|
||||
data["warnings"] = workflow_warnings(data["workflow_json"])
|
||||
raw_map = data.get("output_map")
|
||||
data["output_map"] = load_output_map(raw_map)
|
||||
# Pending flag: unknown output candidates the user has not mapped yet.
|
||||
data["needs_mapping"] = (
|
||||
mapping_problem(data["workflow_json"], raw_map, data.get("output_map_source")) is not None
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def _connection_check(
|
||||
endpoint_id: str | None, workflow_json: dict[str, Any], refresh: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""L2 preflight (HEL-643): workflow vs the target ComfyUI's /object_info.
|
||||
|
||||
An unreachable connection degrades to ``reachable: False`` with an honest
|
||||
「无法核查」 message — it never blocks and never pretends to pass.
|
||||
"""
|
||||
endpoint = settings.resolve_comfyui_endpoint(endpoint_id)
|
||||
if endpoint is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"endpoint_id": endpoint_id,
|
||||
"endpoint_name": None,
|
||||
"findings": [],
|
||||
"message": "没有可用的 ComfyUI 连接,无法核查节点与模型",
|
||||
}
|
||||
base = {
|
||||
"endpoint_id": endpoint.get("id"),
|
||||
"endpoint_name": endpoint.get("name"),
|
||||
"base_url": endpoint.get("base_url"),
|
||||
}
|
||||
client = client_for(endpoint)
|
||||
try:
|
||||
object_info = await client.object_info(force_refresh=refresh)
|
||||
except Exception as exc:
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"findings": [],
|
||||
"message": f"无法核查(连不上该 ComfyUI):{type(exc).__name__}",
|
||||
}
|
||||
finally:
|
||||
await client.close()
|
||||
findings = check_workflow_against(object_info, workflow_json)
|
||||
ok = not any(f.get("level") == "error" for f in findings)
|
||||
return {**base, "ok": ok, "reachable": True, "findings": findings}
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
|
||||
inputs = parse_dynamic_inputs(payload.workflow_json)
|
||||
outputs = parse_dynamic_outputs(payload.workflow_json)
|
||||
return {
|
||||
judgment = classify_usage(payload.workflow_json)
|
||||
result: dict[str, Any] = {
|
||||
"inputs": inputs,
|
||||
"outputs": outputs,
|
||||
"suggested_profile": detect_prompt_profile(payload.workflow_json),
|
||||
"warnings": workflow_warnings(payload.workflow_json),
|
||||
"usage": judgment["usage"],
|
||||
"usage_ambiguous": judgment["ambiguous"],
|
||||
"needs_mapping": judgment["needs_mapping"],
|
||||
"usage_problem": judgment["problem"],
|
||||
"usage_evidence": judgment["evidence"],
|
||||
"usable_usage": judgment["usable_usage"],
|
||||
}
|
||||
# L2 (optional): with a target connection the analyze response also
|
||||
# reports which nodes/models that ComfyUI actually has (HEL-643).
|
||||
if payload.endpoint_id:
|
||||
result["connection_check"] = await _connection_check(
|
||||
payload.endpoint_id, payload.workflow_json, payload.refresh
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _stored_schema(payload_schema: list[dict[str, Any]] | None, workflow: dict[str, Any]) -> str:
|
||||
"""Schema to persist: the confirm page's corrected copy, else the auto one."""
|
||||
if payload_schema and all(isinstance(e, dict) for e in payload_schema):
|
||||
return json.dumps(payload_schema)
|
||||
return json.dumps(parse_dynamic_inputs(workflow))
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/reanalyze")
|
||||
async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
"""Recompute input/output schemas from the stored workflow JSON.
|
||||
"""Recompute usage and input/output schemas from the stored workflow JSON.
|
||||
|
||||
Workflows imported before a parser upgrade keep stale (often empty)
|
||||
schemas — this refreshes them without re-importing.
|
||||
schemas — this refreshes them without re-importing. A usage or output
|
||||
mapping the user set by hand is never overwritten; only ``auto`` (and
|
||||
pending NULL) rows get the new judgment.
|
||||
"""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
@@ -48,11 +154,40 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
data = row_to_dict(row)
|
||||
workflow = json.loads(data["workflow_json"])
|
||||
inputs = parse_dynamic_inputs(workflow)
|
||||
judgment = classify_usage(workflow)
|
||||
usage = data["usage"]
|
||||
usage_source = data["usage_source"]
|
||||
if data["usage_source"] != "user" and judgment["usage"]:
|
||||
usage = judgment["usage"]
|
||||
usage_source = "auto"
|
||||
stored = json.loads(data["input_schema"]) if data["input_schema"] else []
|
||||
schema = effective_input_schema(stored, workflow)
|
||||
outputs = parse_dynamic_outputs(workflow)
|
||||
if data["output_map_source"] == "user":
|
||||
output_map, output_map_source = data["output_map"], "user"
|
||||
elif needs_user_output_map(workflow):
|
||||
# Unknown output candidates: no auto map, and the row must not
|
||||
# generate until the user designates the output nodes.
|
||||
output_map, output_map_source = None, None
|
||||
else:
|
||||
output_map, output_map_source = json.dumps(auto_output_map(workflow)), "auto"
|
||||
is_enabled = data["is_enabled"]
|
||||
if output_map_source is None or usage is None:
|
||||
is_enabled = 0
|
||||
conn.execute(
|
||||
"UPDATE workflows SET input_schema = ?, output_schema = ? WHERE id = ?",
|
||||
(json.dumps(inputs), json.dumps(outputs), workflow_id),
|
||||
"UPDATE workflows SET input_schema = ?, output_schema = ?,"
|
||||
" usage = ?, usage_source = ?, output_map = ?, output_map_source = ?,"
|
||||
" is_enabled = ? WHERE id = ?",
|
||||
(
|
||||
json.dumps(schema),
|
||||
json.dumps(outputs),
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
is_enabled,
|
||||
workflow_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
fresh = conn.execute(
|
||||
@@ -63,16 +198,64 @@ 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]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
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.
|
||||
@@ -110,38 +293,77 @@ async def dedupe_workflows() -> dict[str, Any]:
|
||||
|
||||
@router.post("")
|
||||
async def create_workflow(payload: WorkflowCreate) -> dict[str, Any]:
|
||||
inputs = parse_dynamic_inputs(payload.workflow_json)
|
||||
outputs = parse_dynamic_outputs(payload.workflow_json)
|
||||
judgment = classify_usage(payload.workflow_json)
|
||||
usage = payload.usage or judgment["usage"]
|
||||
usage_source = "user" if payload.usage else "auto"
|
||||
kind = payload.kind or usage_to_kind(usage)
|
||||
if kind is None:
|
||||
# Indeterminate and nothing chosen: classify_output hint keeps old
|
||||
# callers working while the workflow stays pending confirmation.
|
||||
out_kinds = {
|
||||
o.get("kind")
|
||||
for o in parse_dynamic_outputs(payload.workflow_json)
|
||||
if o.get("kind") in ("image", "video")
|
||||
}
|
||||
kind = "video" if out_kinds == {"video"} else "image"
|
||||
profile = payload.prompt_profile or detect_prompt_profile(payload.workflow_json)
|
||||
# HEL-638: an explicit user output map wins; otherwise the trusted auto
|
||||
# map. Either way a graph with unknown output candidates is unusable
|
||||
# until the user designates the output nodes.
|
||||
if payload.output_map:
|
||||
problems = validate_output_map(payload.workflow_json, payload.output_map)
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
output_map = json.dumps(payload.output_map)
|
||||
output_map_source = payload.output_map_source or "user"
|
||||
else:
|
||||
auto_map = auto_output_map(payload.workflow_json)
|
||||
output_map = json.dumps(auto_map) if auto_map else None
|
||||
output_map_source = "auto" if auto_map else None
|
||||
mapping_ok = not needs_user_output_map(payload.workflow_json) or bool(payload.output_map)
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
# Byte-identical re-imports are the main way the library fills up with
|
||||
# same-named clones (the "@" typeahead then shows 20 identical rows).
|
||||
existing = conn.execute(
|
||||
"SELECT id, name FROM workflows WHERE name = ? AND kind = ? AND workflow_json = ?",
|
||||
(payload.name, payload.kind, json.dumps(payload.workflow_json)),
|
||||
(payload.name, kind, json.dumps(payload.workflow_json)),
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"An identical workflow already exists (id {existing['id']}, “{existing['name']}”).",
|
||||
detail=(
|
||||
f"An identical workflow already exists (id {existing['id']},"
|
||||
f" “{existing['name']}”)."
|
||||
),
|
||||
)
|
||||
# Unconfirmed workflows (ambiguous usage / unknown outputs without a
|
||||
# user mapping) are stored but disabled: they must not reach the
|
||||
# generate pages until the user confirms usage AND, for unknown
|
||||
# output nodes, designates the outputs on the connections page.
|
||||
is_enabled = 1 if (usage and mapping_ok) else 0
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO workflows (name, kind, workflow_json, input_schema,
|
||||
output_schema, description, prompt_profile,
|
||||
endpoint_id, is_enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
endpoint_id, is_enabled, usage, usage_source,
|
||||
output_map, output_map_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
payload.name,
|
||||
payload.kind,
|
||||
kind,
|
||||
json.dumps(payload.workflow_json),
|
||||
json.dumps(inputs),
|
||||
json.dumps(outputs),
|
||||
_stored_schema(payload.input_schema, payload.workflow_json),
|
||||
json.dumps(parse_dynamic_outputs(payload.workflow_json)),
|
||||
payload.description,
|
||||
profile,
|
||||
payload.endpoint_id,
|
||||
is_enabled,
|
||||
usage,
|
||||
usage_source,
|
||||
output_map,
|
||||
output_map_source,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -173,9 +395,54 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "is_enabled" in data and data["is_enabled"] is not None:
|
||||
data["is_enabled"] = 1 if data["is_enabled"] else 0
|
||||
# endpoint_id: explicit null clears the binding; other Nones are skips.
|
||||
explicit_null = {"endpoint_id"} & {k for k, v in data.items() if v is None}
|
||||
workflow_json = json.loads(existing["workflow_json"])
|
||||
# HEL-638: output_map payload. A non-empty list validates against the
|
||||
# stored JSON and becomes the user decision; an explicit empty list
|
||||
# resets the row to pending (disabled).
|
||||
if "output_map" in data:
|
||||
if data["output_map"]:
|
||||
problems = validate_output_map(workflow_json, data["output_map"])
|
||||
if problems:
|
||||
raise HTTPException(status_code=422, detail=";".join(problems))
|
||||
data["output_map"] = json.dumps(data["output_map"])
|
||||
data.setdefault("output_map_source", "user")
|
||||
else:
|
||||
data["output_map"] = None
|
||||
data["output_map_source"] = None
|
||||
data["is_enabled"] = 0
|
||||
if data.get("usage"):
|
||||
# Confirming / correcting the usage is an explicit user decision:
|
||||
# kind (output media) follows the usage. Whether it re-enables is
|
||||
# decided by the gate below — with unknown output nodes the user
|
||||
# must also designate the output nodes (HEL-638).
|
||||
data.setdefault("usage_source", "user")
|
||||
data["kind"] = usage_to_kind(data["usage"]) or existing["kind"]
|
||||
if "input_schema" in data and data["input_schema"] is not None:
|
||||
if not all(isinstance(e, dict) for e in data["input_schema"]):
|
||||
raise HTTPException(status_code=422, detail="input_schema 必须是参数对象数组")
|
||||
data["input_schema"] = json.dumps(data["input_schema"])
|
||||
# endpoint_id: explicit null clears the binding; other Nones are skips
|
||||
# (an output_map reset keeps its explicit NULLs).
|
||||
explicit_null = {"endpoint_id", "output_map", "output_map_source"} & {
|
||||
k for k, v in data.items() if v is None
|
||||
}
|
||||
data = {k: v for k, v in data.items() if v is not None or k in explicit_null}
|
||||
|
||||
# Enable gate: usage must be settled and, when the graph carries
|
||||
# unknown custom output nodes, a user output mapping must exist.
|
||||
usage_ok = bool(data.get("usage") or existing["usage"])
|
||||
map_raw = data.get("output_map", existing["output_map"])
|
||||
map_source = data.get("output_map_source", existing["output_map_source"])
|
||||
map_problem = mapping_problem(workflow_json, map_raw, map_source)
|
||||
if data.get("is_enabled"):
|
||||
if not usage_ok:
|
||||
raise HTTPException(status_code=400, detail="请先选择工作流用途,再启用该工作流")
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
elif (data.get("usage") or data.get("output_map")) and "is_enabled" not in data:
|
||||
# A usage-confirm or output-map-confirm path: enabled only when
|
||||
# both decisions are settled.
|
||||
data["is_enabled"] = 1 if (usage_ok and not map_problem) else 0
|
||||
if data:
|
||||
fields = ", ".join(f"{k} = :{k}" for k in data)
|
||||
data["id"] = workflow_id
|
||||
@@ -187,14 +454,149 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
|
||||
conn.close()
|
||||
|
||||
|
||||
def _job_public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(job)
|
||||
out["payload"] = json.loads(job.get("payload_json") or "{}")
|
||||
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
|
||||
out.pop("payload_json", None)
|
||||
out.pop("output_paths_json", None)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/test")
|
||||
async def test_workflow(workflow_id: int, payload: WorkflowTestRequest) -> dict[str, Any]:
|
||||
"""Launch (or re-attach to) one REAL test run of a workflow (HEL-643 L3).
|
||||
|
||||
The job goes through the ordinary queue → ComfyUI → outputs pipeline;
|
||||
only its artifacts and bookkeeping are isolated (origin='workflow_test').
|
||||
A dry-run setting is refused outright — placeholder output must never
|
||||
count as a test pass. Repeat clicks reuse the in-flight job.
|
||||
"""
|
||||
from calliope import config
|
||||
|
||||
if config.settings.dry_run:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
|
||||
"请到「设置」关闭 Dry-run 后再测试"
|
||||
),
|
||||
)
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
wf = conn.execute(
|
||||
"SELECT id, kind, workflow_json, input_schema, output_map, output_map_source,"
|
||||
" endpoint_id FROM workflows WHERE id = ?",
|
||||
(workflow_id,),
|
||||
).fetchone()
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
workflow_json = json.loads(wf["workflow_json"])
|
||||
broken = blocking_problem(workflow_json)
|
||||
if broken:
|
||||
raise HTTPException(status_code=400, detail=broken)
|
||||
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
|
||||
)
|
||||
input_values, problems = validate_input_values(schema, payload.input_values)
|
||||
if problems:
|
||||
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
|
||||
|
||||
endpoint_id = payload.endpoint_id or wf["endpoint_id"] or None
|
||||
snapshot = endpoint_snapshot(endpoint_id)
|
||||
active = find_active_test_job(workflow_id, (snapshot or {}).get("id"))
|
||||
if active is not None:
|
||||
return {"ok": True, "job": _job_public(active), "reused": True}
|
||||
|
||||
from calliope.routers.playground import ensure_playground_project
|
||||
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
job = queue_manager.enqueue(
|
||||
project_id=pid,
|
||||
kind=wf["kind"],
|
||||
workflow_id=workflow_id,
|
||||
scene_id=None,
|
||||
payload={
|
||||
"input_values": input_values,
|
||||
"source": TEST_SOURCE,
|
||||
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
|
||||
},
|
||||
origin="workflow_test",
|
||||
)
|
||||
await event_bus.publish(
|
||||
"job.created",
|
||||
{"job_id": job["id"], "kind": job["kind"], "source": TEST_SOURCE,
|
||||
"project_id": job["project_id"], "workflow_test": True},
|
||||
)
|
||||
return {"ok": True, "job": _job_public(job), "reused": False}
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/tests")
|
||||
async def list_workflow_tests(workflow_id: int) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
return list_test_jobs(workflow_id)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/clear-tests")
|
||||
async def clear_workflow_tests(workflow_id: int) -> dict[str, Any]:
|
||||
"""Delete every test record and artifact for this workflow."""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
try:
|
||||
removed = purge_workflow_tests(workflow_id)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
await event_bus.publish("job.deleted", {"workflow_id": workflow_id, "source": TEST_SOURCE})
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}")
|
||||
async def delete_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
cur = conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
# HEL-619: refuse to delete while queued/running jobs still use the
|
||||
# workflow — deleting sets their workflow_id NULL and the job dies
|
||||
# with "工作流已被删除" only after the user waits for it.
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workflows WHERE id = ?", (workflow_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
pending = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM jobs"
|
||||
" WHERE workflow_id = ? AND status IN ('pending', 'running')",
|
||||
(workflow_id,),
|
||||
).fetchone()["n"]
|
||||
if pending:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"该工作流还有 {pending} 个排队或执行中的任务,"
|
||||
"请先等任务完成或取消任务,再删除该工作流"
|
||||
),
|
||||
)
|
||||
# Test records/artifacts belong to the workflow — cascade (HEL-643).
|
||||
# The 409 above guarantees none are in flight here.
|
||||
purge_workflow_tests(workflow_id, allow_active=True)
|
||||
conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -10,6 +10,10 @@ from fastapi.testclient import TestClient
|
||||
from calliope.db import migrate_db
|
||||
from calliope.main import create_app
|
||||
|
||||
# Shared fake-ComfyUI HTTP server (defined in test_studio_generation,
|
||||
# reused by the generation / status / unknown-output-mapping suites).
|
||||
from tests.test_studio_generation import fake_comfy # noqa: F401,E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"value": "PASTE YOUR PROMPT HERE. For image-to-video describe the motion from the first frame; the frame fixes the look. Use the MiniMax-H3 prompt contract (integrated_multimodal_description / overall_soundscape / non_diegetic_music)."
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "PROMPT (feeds every stage)"
|
||||
}
|
||||
},
|
||||
"92": {
|
||||
"inputs": {
|
||||
"filename_prefix": "video/04_i2v_fl2v_4step_sla",
|
||||
"format": "auto",
|
||||
"format.codec": "auto",
|
||||
"codec": "auto",
|
||||
"video": [
|
||||
"130",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveVideo",
|
||||
"_meta": {
|
||||
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
|
||||
}
|
||||
},
|
||||
"115": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "16:9 (Widescreen)",
|
||||
"megapixels": 0.7,
|
||||
"multiple": 32
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "ResolutionSelector"
|
||||
}
|
||||
},
|
||||
"116": {
|
||||
"inputs": {
|
||||
"expression": "a"
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"119": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"120": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"121": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"120",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAEDecodeAudio"
|
||||
}
|
||||
},
|
||||
"122": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAEDecode"
|
||||
}
|
||||
},
|
||||
"124": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 4,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"125": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"129",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"126",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"135",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"124",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"131",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"126": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"131",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "BasicGuider"
|
||||
}
|
||||
},
|
||||
"127": {
|
||||
"inputs": {
|
||||
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNETLoader"
|
||||
}
|
||||
},
|
||||
"128": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
|
||||
"type": "minimax",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "CLIPLoader"
|
||||
}
|
||||
},
|
||||
"129": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"130": {
|
||||
"inputs": {
|
||||
"fps": 24,
|
||||
"bit_depth": 8,
|
||||
"color_space": "sRGB",
|
||||
"codec": "none",
|
||||
"images": [
|
||||
"122",
|
||||
0
|
||||
],
|
||||
"audio": [
|
||||
"121",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CreateVideo",
|
||||
"_meta": {
|
||||
"title": "CreateVideo"
|
||||
}
|
||||
},
|
||||
"131": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"width": [
|
||||
"115",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"115",
|
||||
1
|
||||
],
|
||||
"length": [
|
||||
"132",
|
||||
1
|
||||
],
|
||||
"clip": [
|
||||
"128",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
],
|
||||
"first_frame": [
|
||||
"901",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3ImageToVideo",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3ImageToVideo"
|
||||
}
|
||||
},
|
||||
"132": {
|
||||
"inputs": {
|
||||
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
|
||||
"values.a": [
|
||||
"133",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"133": {
|
||||
"inputs": {
|
||||
"value": 10
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "PrimitiveFloat"
|
||||
}
|
||||
},
|
||||
"135": {
|
||||
"inputs": {
|
||||
"sampler_name": "res_multistep"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"142": {
|
||||
"inputs": {
|
||||
"shift_video": 12,
|
||||
"shift_audio": 3,
|
||||
"model": [
|
||||
"900",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3SigmaShift",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3SigmaShift"
|
||||
}
|
||||
},
|
||||
"900": {
|
||||
"inputs": {
|
||||
"sparsity_ratio": 0.9,
|
||||
"block_size": "64",
|
||||
"min_seq_len": 8192,
|
||||
"dense_last_steps": 0,
|
||||
"protect_audio": true,
|
||||
"enabled": true,
|
||||
"dense_steps": "1",
|
||||
"dense_backend": "comfy_kitchen",
|
||||
"disable_fp16_accum": true,
|
||||
"stabilize_motion": false,
|
||||
"reference_protection": "Off",
|
||||
"tail_correction": false,
|
||||
"use_int8_qk": true,
|
||||
"engine": "comfy_kitchen",
|
||||
"model": [
|
||||
"127",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "H3SLAAttention",
|
||||
"_meta": {
|
||||
"title": "H3 SLA Attention 0.9/64 (their graph)"
|
||||
}
|
||||
},
|
||||
"901": {
|
||||
"inputs": {
|
||||
"image": "first_frame.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "FIRST FRAME (add a second LoadImage into last_frame for FL2V)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"value": "PASTE YOUR PROMPT HERE, in the reference contract:\nsubject_definitions:\n<Subject 1> is the person in <Picture 1>; their face, hair and clothing follow <Picture 1>.\n\nsummary:\n[reference generation] one continuous shot of <Subject 1> doing X, appearance taken from <Picture 1>.\n\nretention_analysis:\n<Subject 1> is fully preserved from <Picture 1>.\n\ndetailed_description:\n[Shot 1] ... camera, action, timing ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: N/A"
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "PROMPT (feeds every stage)"
|
||||
}
|
||||
},
|
||||
"92": {
|
||||
"inputs": {
|
||||
"filename_prefix": "video/05_ref2va_4step_sla",
|
||||
"format": "auto",
|
||||
"format.codec": "auto",
|
||||
"codec": "auto",
|
||||
"video": [
|
||||
"130",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveVideo",
|
||||
"_meta": {
|
||||
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
|
||||
}
|
||||
},
|
||||
"115": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "16:9 (Widescreen)",
|
||||
"megapixels": 0.7,
|
||||
"multiple": 32
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "ResolutionSelector"
|
||||
}
|
||||
},
|
||||
"116": {
|
||||
"inputs": {
|
||||
"expression": "a"
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"119": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"120": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"121": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"120",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAEDecodeAudio"
|
||||
}
|
||||
},
|
||||
"122": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAEDecode"
|
||||
}
|
||||
},
|
||||
"124": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 4,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"125": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"129",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"126",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"135",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"124",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"131",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"126": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"131",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "BasicGuider"
|
||||
}
|
||||
},
|
||||
"127": {
|
||||
"inputs": {
|
||||
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNETLoader"
|
||||
}
|
||||
},
|
||||
"128": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
|
||||
"type": "minimax",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "CLIPLoader"
|
||||
}
|
||||
},
|
||||
"129": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"130": {
|
||||
"inputs": {
|
||||
"fps": 24,
|
||||
"bit_depth": 8,
|
||||
"color_space": "sRGB",
|
||||
"codec": "none",
|
||||
"images": [
|
||||
"122",
|
||||
0
|
||||
],
|
||||
"audio": [
|
||||
"121",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CreateVideo",
|
||||
"_meta": {
|
||||
"title": "CreateVideo"
|
||||
}
|
||||
},
|
||||
"131": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"width": [
|
||||
"115",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"115",
|
||||
1
|
||||
],
|
||||
"length": [
|
||||
"132",
|
||||
1
|
||||
],
|
||||
"ref_image_size": "match",
|
||||
"clip": [
|
||||
"128",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
],
|
||||
"audio_vae": [
|
||||
"120",
|
||||
0
|
||||
],
|
||||
"ref_images.ref_image_0": [
|
||||
"901",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3ReferenceToVideo",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3ReferenceToVideo"
|
||||
}
|
||||
},
|
||||
"132": {
|
||||
"inputs": {
|
||||
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
|
||||
"values.a": [
|
||||
"133",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"133": {
|
||||
"inputs": {
|
||||
"value": 10
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "PrimitiveFloat"
|
||||
}
|
||||
},
|
||||
"135": {
|
||||
"inputs": {
|
||||
"sampler_name": "res_multistep"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"142": {
|
||||
"inputs": {
|
||||
"shift_video": 12,
|
||||
"shift_audio": 3,
|
||||
"model": [
|
||||
"900",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3SigmaShift",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3SigmaShift"
|
||||
}
|
||||
},
|
||||
"900": {
|
||||
"inputs": {
|
||||
"sparsity_ratio": 0.9,
|
||||
"block_size": "64",
|
||||
"min_seq_len": 8192,
|
||||
"dense_last_steps": 0,
|
||||
"protect_audio": true,
|
||||
"enabled": true,
|
||||
"dense_steps": "1",
|
||||
"dense_backend": "comfy_kitchen",
|
||||
"disable_fp16_accum": true,
|
||||
"stabilize_motion": false,
|
||||
"reference_protection": "Off",
|
||||
"tail_correction": false,
|
||||
"use_int8_qk": true,
|
||||
"engine": "comfy_kitchen",
|
||||
"model": [
|
||||
"127",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "H3SLAAttention",
|
||||
"_meta": {
|
||||
"title": "H3 SLA Attention 0.9/64 (their graph)"
|
||||
}
|
||||
},
|
||||
"901": {
|
||||
"inputs": {
|
||||
"image": "reference_1.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "REFERENCE IMAGE 1 (<Picture 1>); the node grows more image / video / audio slots"
|
||||
}
|
||||
}
|
||||
}
|
||||
+808
@@ -0,0 +1,808 @@
|
||||
{
|
||||
"115": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "9:16 (Portrait Widescreen)",
|
||||
"megapixels": 0.6,
|
||||
"multiple": 32
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "Resolution Selector"
|
||||
}
|
||||
},
|
||||
"207": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax-h3\\minimax_h3_video_vae_fp16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE"
|
||||
}
|
||||
},
|
||||
"208": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax-h3\\minimax_h3_audio_vae_fp32.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE"
|
||||
}
|
||||
},
|
||||
"209": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"213",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"208",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAE Decode Audio"
|
||||
}
|
||||
},
|
||||
"210": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"213",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"207",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE Decode"
|
||||
}
|
||||
},
|
||||
"211": {
|
||||
"inputs": {
|
||||
"sampler_name": "euler"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"212": {
|
||||
"inputs": {
|
||||
"scheduler": "beta",
|
||||
"steps": 6,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"6007",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"213": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"217",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"214",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"211",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"212",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"219",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"214": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"6007",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"219",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "Basic Guider"
|
||||
}
|
||||
},
|
||||
"215": {
|
||||
"inputs": {
|
||||
"unet_name": "minimax-h3\\minimax_h3_fl2va_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "Load Diffusion Model"
|
||||
}
|
||||
},
|
||||
"216": {
|
||||
"inputs": {
|
||||
"clip_name": "minimax-h3\\qwen3vl_32b_minimax_h3_int8_convrot.comfy.safetensors",
|
||||
"type": "minimax",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "Load CLIP"
|
||||
}
|
||||
},
|
||||
"217": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"219": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"6005",
|
||||
0
|
||||
],
|
||||
"width": [
|
||||
"115",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"115",
|
||||
1
|
||||
],
|
||||
"length": [
|
||||
"220",
|
||||
1
|
||||
],
|
||||
"clip": [
|
||||
"6010",
|
||||
1
|
||||
],
|
||||
"vae": [
|
||||
"207",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3ImageToVideo",
|
||||
"_meta": {
|
||||
"title": "MiniMax H3 Image to Video"
|
||||
}
|
||||
},
|
||||
"220": {
|
||||
"inputs": {
|
||||
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
|
||||
"values.a": [
|
||||
"221",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "Math Expression"
|
||||
}
|
||||
},
|
||||
"221": {
|
||||
"inputs": {
|
||||
"value": 10
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "Seconds"
|
||||
}
|
||||
},
|
||||
"223": {
|
||||
"inputs": {
|
||||
"sage_attention": "auto",
|
||||
"allow_compile": false,
|
||||
"model": [
|
||||
"6020",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "PathchSageAttentionKJ",
|
||||
"_meta": {
|
||||
"title": "Patch Sage Attention KJ"
|
||||
}
|
||||
},
|
||||
"5970": {
|
||||
"inputs": {
|
||||
"model_name": "film_net_fp16.safetensors"
|
||||
},
|
||||
"class_type": "FrameInterpolationModelLoader",
|
||||
"_meta": {
|
||||
"title": "Load Frame Interpolation Model"
|
||||
}
|
||||
},
|
||||
"5971": {
|
||||
"inputs": {
|
||||
"multiplier": 2,
|
||||
"interp_model": [
|
||||
"5970",
|
||||
0
|
||||
],
|
||||
"images": [
|
||||
"5976",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "FrameInterpolate",
|
||||
"_meta": {
|
||||
"title": "Run Frame Interpolation Model"
|
||||
}
|
||||
},
|
||||
"5974": {
|
||||
"inputs": {
|
||||
"expression": "a*2",
|
||||
"a": [
|
||||
"5986",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MathExpression|pysssss",
|
||||
"_meta": {
|
||||
"title": "Math Expression 🐍"
|
||||
}
|
||||
},
|
||||
"5975": {
|
||||
"inputs": {
|
||||
"resize_type": "scale by multiplier",
|
||||
"resize_type.scale": 1.5,
|
||||
"quality": "ULTRA",
|
||||
"images": [
|
||||
"5971",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "RTXVideoSuperResolution",
|
||||
"_meta": {
|
||||
"title": "RTX Video Super Resolution Pro"
|
||||
}
|
||||
},
|
||||
"5976": {
|
||||
"inputs": {
|
||||
"anything": [
|
||||
"5977",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy clearCacheAll",
|
||||
"_meta": {
|
||||
"title": "Clear Cache All"
|
||||
}
|
||||
},
|
||||
"5977": {
|
||||
"inputs": {
|
||||
"anything": [
|
||||
"6063:5538",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy cleanGpuUsed",
|
||||
"_meta": {
|
||||
"title": "Clean VRAM Used"
|
||||
}
|
||||
},
|
||||
"5978": {
|
||||
"inputs": {
|
||||
"frame_rate": [
|
||||
"5974",
|
||||
1
|
||||
],
|
||||
"loop_count": 0,
|
||||
"filename_prefix": "minimax-h3/vid-vfi",
|
||||
"format": "video/h264-mp4",
|
||||
"pix_fmt": "yuv420p",
|
||||
"crf": 19,
|
||||
"save_metadata": true,
|
||||
"trim_to_audio": false,
|
||||
"pingpong": false,
|
||||
"save_output": true,
|
||||
"images": [
|
||||
"5975",
|
||||
0
|
||||
],
|
||||
"audio": [
|
||||
"6063:4848",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"_meta": {
|
||||
"title": "Video Output 2"
|
||||
}
|
||||
},
|
||||
"5986": {
|
||||
"inputs": {
|
||||
"value": 24
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "PrimitiveFloat"
|
||||
}
|
||||
},
|
||||
"6005": {
|
||||
"inputs": {
|
||||
"value": "integrated_multimodal_description: [Shot 1] Cinematic, live-action, shot on iPhone, a vertical selfie frame from a phone held at arm's length above eye level. A young woman in her mid-twenties with sun-kissed tan skin and loose wavy chestnut brown hair, wearing a white cropped t-shirt with a gold pendant necklace, looks directly into the phone lens, her face filling the centre of frame. Warm golden hour sunlight hits the right side of her face from a low sun behind the trees, casting a soft amber glow across her skin. Behind her, a city park is blurred into soft green and gold bokeh — trees, dappled light, a hot pink sunset sky bleeding through the leaves. The camera holds static in selfie framing. The young woman's lips move as she speaks with a bright, energetic voice with a slight North American accent (S1): <d>[English] Oh my god, you guys, it is actually perfect out here right now.</d> Her mouth closes after the last word, then she breaks into a wide grin, teeth showing, the skin at the corners of her eyes crinkling. She tilts her head slightly to the right, the grin fading to a soft expression as her lips move and she speaks again, her voice warmer (S1): <d>[English] Like, I didn't want to leave.</d> Her mouth closes. She presses her lips together, then points her index finger at the camera and speaks with a playful tone, her lips forming each word (S1): <d>[English] So I'm not.</d> Her mouth closes, and the wide grin returns. With her free left hand she pushes the windblown hair behind her ear, her gold bracelet sliding down her wrist. She raises her free hand in a quick peace sign next to her cheek, her smile widening, and laughs — a short, genuine laugh, her mouth opening, shoulders shaking once, her eyes squeezing shut for a beat, then opening again. She drops the peace sign and brings the phone slightly closer, her face growing larger in frame, the grin settling into a warm smile. The camera pushes in with small amplitude at slow speed as the phone draws closer.\n\noverall_soundscape: A warm breeze carries birdsong and the distant hum of a city at golden hour. The woman's laugh is clear and close to the phone mic — short, genuine, with a slight exhale. Loose hair rustles as she tucks it behind her ear, and the gold bracelet lightly clinks against itself on her wrist.\n\nnon_diegetic_music: N/A"
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "Text Prompt"
|
||||
}
|
||||
},
|
||||
"6006": {
|
||||
"inputs": {
|
||||
"lora_name": "minimax-h3\\minimax_h3_fl2v_turbo_4step_v1.1_768p_comfyui_bf16.safetensors",
|
||||
"strength_model": 0.6,
|
||||
"model": [
|
||||
"215",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LoraLoaderModelOnly",
|
||||
"_meta": {
|
||||
"title": "Load LoRA"
|
||||
}
|
||||
},
|
||||
"6007": {
|
||||
"inputs": {
|
||||
"shift_video": 12,
|
||||
"shift_audio": 3,
|
||||
"model": [
|
||||
"223",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3SigmaShift",
|
||||
"_meta": {
|
||||
"title": "ModelSamplingMiniMaxH3"
|
||||
}
|
||||
},
|
||||
"6010": {
|
||||
"inputs": {
|
||||
"PowerLoraLoaderHeaderWidget": {
|
||||
"type": "PowerLoraLoaderHeaderWidget"
|
||||
},
|
||||
"lora_1": {
|
||||
"on": true,
|
||||
"lora": "minimax-h3\\h3-realism-people-t2v-i2v-r2v(r34l1sm).safetensors",
|
||||
"strength": 1
|
||||
},
|
||||
"➕ Add Lora": "",
|
||||
"model": [
|
||||
"6006",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"216",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "Power Lora Loader (rgthree)",
|
||||
"_meta": {
|
||||
"title": "Power Lora Loader (rgthree)"
|
||||
}
|
||||
},
|
||||
"6020": {
|
||||
"inputs": {
|
||||
"tau": 1.2,
|
||||
"start_percent": 0.2,
|
||||
"end_percent": 0.9,
|
||||
"min_tokens": 4096,
|
||||
"int8_qk": false,
|
||||
"sink_conditioning": "exact_kv",
|
||||
"morton": false,
|
||||
"morton_curve": "3d",
|
||||
"verbose": false,
|
||||
"model": [
|
||||
"6010",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SolAttnPatch",
|
||||
"_meta": {
|
||||
"title": "Patch Sol-Attn"
|
||||
}
|
||||
},
|
||||
"6059": {
|
||||
"inputs": {
|
||||
"anything": [
|
||||
"6060",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy clearCacheAll",
|
||||
"_meta": {
|
||||
"title": "Clear Cache All"
|
||||
}
|
||||
},
|
||||
"6060": {
|
||||
"inputs": {
|
||||
"anything": [
|
||||
"210",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "easy cleanGpuUsed",
|
||||
"_meta": {
|
||||
"title": "Clean VRAM Used"
|
||||
}
|
||||
},
|
||||
"6062:5570": {
|
||||
"inputs": {
|
||||
"vae_name": "ltx-2.5\\ltx-2.5-audio-vae-bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE - Audio"
|
||||
}
|
||||
},
|
||||
"6062:5571": {
|
||||
"inputs": {
|
||||
"vae_name": "ltx-2.5\\ltx-2.5-video-vae-bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "Load VAE - Video"
|
||||
}
|
||||
},
|
||||
"6062:5569": {
|
||||
"inputs": {
|
||||
"unet_name": "ltx-2.5\\ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "Load Diffusion Model"
|
||||
}
|
||||
},
|
||||
"6062:5577": {
|
||||
"inputs": {
|
||||
"model_name": "ltx-2.5\\ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors"
|
||||
},
|
||||
"class_type": "LatentUpscaleModelLoader",
|
||||
"_meta": {
|
||||
"title": "Load Latent Upscale Model"
|
||||
}
|
||||
},
|
||||
"6062:5574": {
|
||||
"inputs": {
|
||||
"clip_name": "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors",
|
||||
"type": "ltxv",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "Load CLIP - Text Encoder"
|
||||
}
|
||||
},
|
||||
"6065:2612": {
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"clip": [
|
||||
"6062:5574",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Negative Prompt)"
|
||||
}
|
||||
},
|
||||
"6065:2483": {
|
||||
"inputs": {
|
||||
"text": [
|
||||
"6005",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"6062:5574",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {
|
||||
"title": "CLIP Text Encode (Positive Prompt)"
|
||||
}
|
||||
},
|
||||
"6065:1241": {
|
||||
"inputs": {
|
||||
"frame_rate": [
|
||||
"5986",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"6065:2483",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"6065:2612",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVConditioning",
|
||||
"_meta": {
|
||||
"title": "LTXVConditioning"
|
||||
}
|
||||
},
|
||||
"6061:4985": {
|
||||
"inputs": {
|
||||
"sigmas": "0.85, 0.7250, 0.4219, 0.0"
|
||||
},
|
||||
"class_type": "ManualSigmas",
|
||||
"_meta": {
|
||||
"title": "ManualSigmas"
|
||||
}
|
||||
},
|
||||
"6061:4976": {
|
||||
"inputs": {
|
||||
"sampler_name": "euler"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"6061:4964": {
|
||||
"inputs": {
|
||||
"cfg": 1,
|
||||
"model": [
|
||||
"6062:5569",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"6065:1241",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"6065:1241",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "CFGGuider",
|
||||
"_meta": {
|
||||
"title": "CFG Guider"
|
||||
}
|
||||
},
|
||||
"6061:4967": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"6061:4973": {
|
||||
"inputs": {
|
||||
"av_latent": [
|
||||
"6061:4971",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVSeparateAVLatent",
|
||||
"_meta": {
|
||||
"title": "LTXVSeparateAVLatent"
|
||||
}
|
||||
},
|
||||
"6061:4971": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"6061:4967",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"6061:4964",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"6061:4976",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"6061:4985",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"6061:4969",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"6061:4969": {
|
||||
"inputs": {
|
||||
"video_latent": [
|
||||
"6061:4970",
|
||||
0
|
||||
],
|
||||
"audio_latent": [
|
||||
"6061:6074",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVConcatAVLatent",
|
||||
"_meta": {
|
||||
"title": "LTXVConcatAVLatent"
|
||||
}
|
||||
},
|
||||
"6061:6072": {
|
||||
"inputs": {
|
||||
"value": 0,
|
||||
"width": [
|
||||
"6061:6073",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"6061:6073",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SolidMask",
|
||||
"_meta": {
|
||||
"title": "Create Solid Mask"
|
||||
}
|
||||
},
|
||||
"6061:4975": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"6061:6070",
|
||||
0
|
||||
],
|
||||
"upscale_model": [
|
||||
"6062:5577",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"6062:5571",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVLatentUpsampler",
|
||||
"_meta": {
|
||||
"title": "LTXVLatentUpsampler"
|
||||
}
|
||||
},
|
||||
"6061:6070": {
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"6059",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"6062:5571",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEEncode",
|
||||
"_meta": {
|
||||
"title": "VAE Encode"
|
||||
}
|
||||
},
|
||||
"6061:4970": {
|
||||
"inputs": {
|
||||
"strength": 1,
|
||||
"bypass": true,
|
||||
"vae": [
|
||||
"6062:5571",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"6059",
|
||||
0
|
||||
],
|
||||
"latent": [
|
||||
"6061:4975",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVImgToVideoInplace",
|
||||
"_meta": {
|
||||
"title": "LTXVImgToVideoInplace"
|
||||
}
|
||||
},
|
||||
"6061:6074": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"6061:6075",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"6061:6072",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"_meta": {
|
||||
"title": "Set Latent Noise Mask"
|
||||
}
|
||||
},
|
||||
"6061:6073": {
|
||||
"inputs": {
|
||||
"image": [
|
||||
"6059",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "GetImageSize",
|
||||
"_meta": {
|
||||
"title": "Get Image Size"
|
||||
}
|
||||
},
|
||||
"6061:6075": {
|
||||
"inputs": {
|
||||
"audio": [
|
||||
"209",
|
||||
0
|
||||
],
|
||||
"audio_vae": [
|
||||
"6062:5570",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVAudioVAEEncode",
|
||||
"_meta": {
|
||||
"title": "LTXV Audio VAE Encode"
|
||||
}
|
||||
},
|
||||
"6063:5538": {
|
||||
"inputs": {
|
||||
"tile_size": 512,
|
||||
"overlap": 64,
|
||||
"temporal_size": 128,
|
||||
"temporal_overlap": 32,
|
||||
"samples": [
|
||||
"6061:4973",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"6062:5571",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeTiled",
|
||||
"_meta": {
|
||||
"title": "VAE Decode (Tiled)"
|
||||
}
|
||||
},
|
||||
"6063:4848": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"6061:4973",
|
||||
1
|
||||
],
|
||||
"audio_vae": [
|
||||
"6062:5570",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "LTXVAudioVAEDecode",
|
||||
"_meta": {
|
||||
"title": "LTXV Audio VAE Decode"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"UNKNOWN": "qwen-image-2.1-Q4_K_M.gguf"
|
||||
},
|
||||
"_meta": {
|
||||
"title": "Unet Loader (GGUF)"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "加载CLIP"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "加载VAE"
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"inputs": {
|
||||
"prompt": "参考角色五官,重新生成图片:高细节皮肤质感",
|
||||
"negative_prompt": "模糊,低质量,文字乱码,变形",
|
||||
"resolution": 1024,
|
||||
"clip": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"images.image_1": [
|
||||
"10",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"_meta": {
|
||||
"title": "Text Encode Qwen Image 2.1"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": {
|
||||
"title": "空Latent图像"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"seed": 278835418249884,
|
||||
"steps": 25,
|
||||
"cfg": 1,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"positive": [
|
||||
"4",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"4",
|
||||
1
|
||||
],
|
||||
"latent_image": [
|
||||
"4",
|
||||
2
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "K采样器"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"6",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"3",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE解码"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"filename_prefix": "qwen_image",
|
||||
"images": [
|
||||
"7",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {
|
||||
"title": "保存图像"
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"inputs": {
|
||||
"UNKNOWN": "auto"
|
||||
},
|
||||
"_meta": {
|
||||
"title": "Patch Sage Attention KJ"
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"inputs": {
|
||||
"image": "3f24a075cd7b4118829be1b222ca1ab9.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": {
|
||||
"title": "加载图像"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 761215360232465,
|
||||
"steps": 20,
|
||||
"cfg": 2.5,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": ["75", 0],
|
||||
"positive": ["76", 0],
|
||||
"negative": ["77", 0],
|
||||
"latent_image": ["90", 0]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": ["3", 0],
|
||||
"vae": ["39", 0]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": { "title": "VAE Decode" }
|
||||
},
|
||||
"37": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": { "title": "Load Diffusion Model" }
|
||||
},
|
||||
"38": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": { "title": "Load CLIP" }
|
||||
},
|
||||
"39": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_vae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": { "title": "Load VAE" }
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI",
|
||||
"images": ["8", 0]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": { "title": "Save Image" }
|
||||
},
|
||||
"75": {
|
||||
"inputs": {
|
||||
"shift": 3,
|
||||
"model": ["37", 0]
|
||||
},
|
||||
"class_type": "ModelSamplingAuraFlow",
|
||||
"_meta": { "title": "ModelSamplingAuraFlow" }
|
||||
},
|
||||
"76": {
|
||||
"inputs": {
|
||||
"prompt": "把背景换成夜晚的城市街道",
|
||||
"clip": ["38", 0],
|
||||
"vae": ["39", 0],
|
||||
"image1": ["93", 0]
|
||||
},
|
||||
"class_type": "TextEncodeQwenImageEditPlus",
|
||||
"_meta": { "title": "TextEncodeQwenImageEditPlus" }
|
||||
},
|
||||
"77": {
|
||||
"inputs": {
|
||||
"prompt": "",
|
||||
"clip": ["38", 0],
|
||||
"vae": ["39", 0],
|
||||
"image1": ["93", 0]
|
||||
},
|
||||
"class_type": "TextEncodeQwenImageEditPlus",
|
||||
"_meta": { "title": "TextEncodeQwenImageEditPlus" }
|
||||
},
|
||||
"78": {
|
||||
"inputs": {
|
||||
"image": "example.png"
|
||||
},
|
||||
"class_type": "LoadImage",
|
||||
"_meta": { "title": "Load Image" }
|
||||
},
|
||||
"88": {
|
||||
"inputs": {
|
||||
"pixels": ["93", 0],
|
||||
"vae": ["39", 0]
|
||||
},
|
||||
"class_type": "VAEEncode",
|
||||
"_meta": { "title": "VAE Encode" }
|
||||
},
|
||||
"90": {
|
||||
"inputs": {
|
||||
"samples": ["88", 0],
|
||||
"mask": ["95", 0]
|
||||
},
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"_meta": { "title": "Set Latent Noise Mask" }
|
||||
},
|
||||
"93": {
|
||||
"inputs": {
|
||||
"upscale_method": "lanczos",
|
||||
"megapixels": 1,
|
||||
"image": ["78", 0]
|
||||
},
|
||||
"class_type": "ImageScaleToTotalPixels",
|
||||
"_meta": { "title": "Scale Image to Total Pixels" }
|
||||
},
|
||||
"95": {
|
||||
"inputs": {
|
||||
"image": "mask.png",
|
||||
"channel": "alpha"
|
||||
},
|
||||
"class_type": "LoadImageMask",
|
||||
"_meta": { "title": "Load Image (as Mask)" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 1125488487853216,
|
||||
"steps": 20,
|
||||
"cfg": 2.5,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": ["66", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["58", 0]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": { "title": "KSampler" }
|
||||
},
|
||||
"6": {
|
||||
"inputs": {
|
||||
"text": "一只橘猫坐在窗台上,午后阳光,电影感",
|
||||
"clip": ["38", 0]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": { "title": "CLIP Text Encode (Positive Prompt)" }
|
||||
},
|
||||
"7": {
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"clip": ["38", 0]
|
||||
},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": { "title": "CLIP Text Encode (Negative Prompt)" }
|
||||
},
|
||||
"8": {
|
||||
"inputs": {
|
||||
"samples": ["3", 0],
|
||||
"vae": ["39", 0]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": { "title": "VAE Decode" }
|
||||
},
|
||||
"37": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_fp8_e4m3fn.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": { "title": "Load Diffusion Model" }
|
||||
},
|
||||
"38": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": { "title": "Load CLIP" }
|
||||
},
|
||||
"39": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_vae.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": { "title": "Load VAE" }
|
||||
},
|
||||
"58": {
|
||||
"inputs": {
|
||||
"width": 1328,
|
||||
"height": 1328,
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"_meta": { "title": "EmptySD3LatentImage" }
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"filename_prefix": "ComfyUI",
|
||||
"images": ["8", 0]
|
||||
},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": { "title": "Save Image" }
|
||||
},
|
||||
"66": {
|
||||
"inputs": {
|
||||
"shift": 3.1,
|
||||
"model": ["37", 0]
|
||||
},
|
||||
"class_type": "ModelSamplingAuraFlow",
|
||||
"_meta": { "title": "ModelSamplingAuraFlow" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
{
|
||||
"1": {
|
||||
"inputs": {
|
||||
"value": "PASTE YOUR PROMPT HERE. Use the MiniMax-H3 prompt contract (integrated_multimodal_description / overall_soundscape / non_diegetic_music). The previews in this repo were made with a ten-shot subway prompt posted by a community member; it is not redistributed here."
|
||||
},
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"_meta": {
|
||||
"title": "PROMPT (feeds every stage)"
|
||||
}
|
||||
},
|
||||
"92": {
|
||||
"inputs": {
|
||||
"filename_prefix": "video/01_reference_4step_sla",
|
||||
"format": "auto",
|
||||
"format.codec": "auto",
|
||||
"codec": "auto",
|
||||
"video": [
|
||||
"130",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveVideo",
|
||||
"_meta": {
|
||||
"title": "REFERENCE 2026-08-29: fused ref-delta bake + SLA 0.9/64, res_multistep/simple/4, shift 12/3, 1152x640, 243 f, seed 42 -> subway_bake_sla_00001_.mp4"
|
||||
}
|
||||
},
|
||||
"115": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "16:9 (Widescreen)",
|
||||
"megapixels": 0.7,
|
||||
"multiple": 32
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "ResolutionSelector"
|
||||
}
|
||||
},
|
||||
"116": {
|
||||
"inputs": {
|
||||
"expression": "a"
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"119": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_video_vae_int8_convrot.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"120": {
|
||||
"inputs": {
|
||||
"vae_name": "minimax_h3_audio_vae_fp32.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "VAELoader"
|
||||
}
|
||||
},
|
||||
"121": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"120",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecodeAudio",
|
||||
"_meta": {
|
||||
"title": "VAEDecodeAudio"
|
||||
}
|
||||
},
|
||||
"122": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"125",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAEDecode"
|
||||
}
|
||||
},
|
||||
"124": {
|
||||
"inputs": {
|
||||
"scheduler": "simple",
|
||||
"steps": 4,
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicScheduler",
|
||||
"_meta": {
|
||||
"title": "BasicScheduler"
|
||||
}
|
||||
},
|
||||
"125": {
|
||||
"inputs": {
|
||||
"noise": [
|
||||
"129",
|
||||
0
|
||||
],
|
||||
"guider": [
|
||||
"126",
|
||||
0
|
||||
],
|
||||
"sampler": [
|
||||
"135",
|
||||
0
|
||||
],
|
||||
"sigmas": [
|
||||
"124",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"131",
|
||||
1
|
||||
]
|
||||
},
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"_meta": {
|
||||
"title": "SamplerCustomAdvanced"
|
||||
}
|
||||
},
|
||||
"126": {
|
||||
"inputs": {
|
||||
"model": [
|
||||
"142",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"131",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "BasicGuider",
|
||||
"_meta": {
|
||||
"title": "BasicGuider"
|
||||
}
|
||||
},
|
||||
"127": {
|
||||
"inputs": {
|
||||
"unet_name": "minimax_h3_fused_refdelta_r1024_turbo8_mystic07_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNETLoader"
|
||||
}
|
||||
},
|
||||
"128": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_32b_heretic_minimax_h3_nvfp4.safetensors",
|
||||
"type": "minimax",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "CLIPLoader"
|
||||
}
|
||||
},
|
||||
"129": {
|
||||
"inputs": {
|
||||
"noise_seed": 42
|
||||
},
|
||||
"class_type": "RandomNoise",
|
||||
"_meta": {
|
||||
"title": "RandomNoise"
|
||||
}
|
||||
},
|
||||
"130": {
|
||||
"inputs": {
|
||||
"fps": 24,
|
||||
"bit_depth": 8,
|
||||
"color_space": "sRGB",
|
||||
"codec": "none",
|
||||
"images": [
|
||||
"122",
|
||||
0
|
||||
],
|
||||
"audio": [
|
||||
"121",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "CreateVideo",
|
||||
"_meta": {
|
||||
"title": "CreateVideo"
|
||||
}
|
||||
},
|
||||
"131": {
|
||||
"inputs": {
|
||||
"prompt": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"width": [
|
||||
"115",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"115",
|
||||
1
|
||||
],
|
||||
"length": [
|
||||
"132",
|
||||
1
|
||||
],
|
||||
"clip": [
|
||||
"128",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"119",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3ImageToVideo",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3ImageToVideo"
|
||||
}
|
||||
},
|
||||
"132": {
|
||||
"inputs": {
|
||||
"expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17",
|
||||
"values.a": [
|
||||
"133",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "ComfyMathExpression",
|
||||
"_meta": {
|
||||
"title": "ComfyMathExpression"
|
||||
}
|
||||
},
|
||||
"133": {
|
||||
"inputs": {
|
||||
"value": 10
|
||||
},
|
||||
"class_type": "PrimitiveFloat",
|
||||
"_meta": {
|
||||
"title": "PrimitiveFloat"
|
||||
}
|
||||
},
|
||||
"135": {
|
||||
"inputs": {
|
||||
"sampler_name": "res_multistep"
|
||||
},
|
||||
"class_type": "KSamplerSelect",
|
||||
"_meta": {
|
||||
"title": "KSamplerSelect"
|
||||
}
|
||||
},
|
||||
"142": {
|
||||
"inputs": {
|
||||
"shift_video": 12,
|
||||
"shift_audio": 3,
|
||||
"model": [
|
||||
"900",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "MiniMaxH3SigmaShift",
|
||||
"_meta": {
|
||||
"title": "MiniMaxH3SigmaShift"
|
||||
}
|
||||
},
|
||||
"900": {
|
||||
"inputs": {
|
||||
"sparsity_ratio": 0.9,
|
||||
"block_size": "64",
|
||||
"min_seq_len": 8192,
|
||||
"dense_last_steps": 0,
|
||||
"protect_audio": true,
|
||||
"enabled": true,
|
||||
"model": [
|
||||
"127",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "H3SLAAttention",
|
||||
"_meta": {
|
||||
"title": "H3 SLA Attention 0.9/64 (their graph)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"13": {
|
||||
"inputs": {
|
||||
"aspect_ratio": "1:1 (Square)",
|
||||
"megapixels": 1,
|
||||
"multiple": 8
|
||||
},
|
||||
"class_type": "ResolutionSelector",
|
||||
"_meta": {
|
||||
"title": "分辨率选择器"
|
||||
}
|
||||
},
|
||||
"461": {
|
||||
"inputs": {
|
||||
"filename_prefix": "Qwen_image_2.1",
|
||||
"format": "png",
|
||||
"format.bit_depth": "8-bit",
|
||||
"format.input_color_space": "sRGB",
|
||||
"images": [
|
||||
"459:457",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "SaveImageAdvanced",
|
||||
"_meta": {
|
||||
"title": "保存图像(高级)"
|
||||
}
|
||||
},
|
||||
"459:451": {
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_2.1_int8_convrot.safetensors",
|
||||
"weight_dtype": "default"
|
||||
},
|
||||
"class_type": "UNETLoader",
|
||||
"_meta": {
|
||||
"title": "UNet加载器"
|
||||
}
|
||||
},
|
||||
"459:452": {
|
||||
"inputs": {
|
||||
"prompt": "Greyscale fashion editorial portrait of an avant-garde woman tilted upwards in profile, captured with striking high-contrast chiaroscuro lighting. She wears oversized, thick-rimmed circular black sunglasses with matte dark lenses, concealing her upward gaze. Her attire merges high couture with tactical abstraction: a high-necked structural dress tailored from heavyweight fabric featuring a disrupted optical camouflage pattern—interlocking oversized polka dots, pixelated stippling, and organic contour blotches in stark monochrome that mimic military disruptive patterns in a refined runway style. Around her shoulders, asymmetric pleated webbing and tailored cargo straps are integrated seamlessly into the garment’s architecture. In her gloved hand, she clutches a structured matte-leather geometric handbag accented with subtle tactical buckles, utility stitching, and dark brushed-metal hardware. Her skin texture is rendered with hyper-detailed clarity, sculpted by intense, razor-sharp studio key light and deep graphite shadows. The striking background is a vivid mixed-media graphic collage, composed of flat vibrant lime green planes, stark black and white overlapping circles, segmented semi-circles with bold zebra striping, dense micro-halftone dot matrices, and vector topographic contour lines. The clash between monochromatic disruptive camo elements on her clothing and the saturated, retro-futuristic pop-art backdrop creates an intense visual dissonance, emphasizing sharp silhouettes, paper cut-out edges, dynamic compositional balance, and clean graphic novel textures. Absolutely no text, no typography, no letters, no words, no numbers, no logos, no watermark, no captions, pure imagery only.",
|
||||
"negative_prompt": "",
|
||||
"resolution": 1024,
|
||||
"clip": [
|
||||
"459:453",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "TextEncodeQwenImage21",
|
||||
"_meta": {
|
||||
"title": "Text Encode Qwen Image 2.1"
|
||||
}
|
||||
},
|
||||
"459:453": {
|
||||
"inputs": {
|
||||
"clip_name": "qwen3vl_8b_int8_convrot.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
},
|
||||
"class_type": "CLIPLoader",
|
||||
"_meta": {
|
||||
"title": "加载CLIP"
|
||||
}
|
||||
},
|
||||
"459:454": {
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_2.1_vae_bf16.safetensors"
|
||||
},
|
||||
"class_type": "VAELoader",
|
||||
"_meta": {
|
||||
"title": "加载VAE"
|
||||
}
|
||||
},
|
||||
"459:456": {
|
||||
"inputs": {
|
||||
"width": [
|
||||
"13",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"13",
|
||||
1
|
||||
],
|
||||
"batch_size": 1
|
||||
},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": {
|
||||
"title": "空Latent图像"
|
||||
}
|
||||
},
|
||||
"459:457": {
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"459:458",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"459:454",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {
|
||||
"title": "VAE解码"
|
||||
}
|
||||
},
|
||||
"459:458": {
|
||||
"inputs": {
|
||||
"seed": 593103825222985,
|
||||
"steps": 25,
|
||||
"cfg": 1,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": [
|
||||
"459:451",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"459:452",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"459:452",
|
||||
1
|
||||
],
|
||||
"latent_image": [
|
||||
"459:456",
|
||||
0
|
||||
]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {
|
||||
"title": "K采样器"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,6 +374,61 @@ def test_comfy_test_requires_url(client):
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_schemeless_url_is_format_error_everywhere(client):
|
||||
"""HEL-619: a schemeless/garbled address is a 400 format error on every
|
||||
entry point (probe + save), not a silent http:// prefix that later
|
||||
surfaces as a DNS-classified probe failure."""
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
for url in ("192.168.0.1:8188", "http:192.168.0.1", "ht!tp://x", "http://a b.test/"):
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints/test", json={"base_url": url, "timeout_sec": 2}
|
||||
)
|
||||
assert r.status_code == 400, url
|
||||
assert "http://" in r.json()["detail"] or "https://" in r.json()["detail"]
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints", json={"name": "无协议", "base_url": url}
|
||||
)
|
||||
assert r.status_code == 400, url
|
||||
assert "无协议" not in [ep.get("name") for ep in settings.comfyui_endpoints or []]
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_save_endpoint_runs_ssrf_guard(client):
|
||||
"""HEL-619: the SSRF guard applies on save (create + edit), not only on
|
||||
the connectivity probe; loopback stays allowed."""
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
_clear_endpoints()
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "元数据", "base_url": "http://169.254.169.254/latest"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "禁止" in r.json()["detail"]
|
||||
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "本机", "base_url": "http://127.0.0.1:8188"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
eid = r.json()["endpoints"][-1]["id"]
|
||||
|
||||
r = client.patch(
|
||||
f"/api/comfyui/endpoints/{eid}",
|
||||
json={"base_url": "http://169.254.169.254/latest"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
# the stored endpoint is untouched
|
||||
stored = next(ep for ep in settings.comfyui_endpoints if ep["id"] == eid)
|
||||
assert stored["base_url"] == "http://127.0.0.1:8188"
|
||||
finally:
|
||||
for key, value in prev.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# workflow binding + job snapshots
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -381,10 +436,21 @@ def test_comfy_test_requires_url(client):
|
||||
|
||||
def _import_workflow(client, name="H3 视频", kind="video", endpoint_id=None):
|
||||
wf_json = {
|
||||
"4": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "model.safetensors"},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "placeholder prompt", "clip": ["4", 1]},
|
||||
},
|
||||
# HEL-636: a workflow needs a recognizable output node to classify a
|
||||
# usage and stay enabled; without it generation is refused.
|
||||
"9": {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {"images": ["6", 0]},
|
||||
"_meta": {"title": "Video Output (Output:video)"},
|
||||
},
|
||||
}
|
||||
payload = {"name": name, "kind": kind, "workflow_json": wf_json}
|
||||
if endpoint_id is not None:
|
||||
@@ -531,6 +597,41 @@ def test_queue_enqueue_stamps_all_paths(client):
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def test_workflow_delete_blocked_while_jobs_pending(client):
|
||||
"""HEL-619: deleting a workflow with pending/running jobs is refused
|
||||
with 409 instead of silently nulling the jobs' workflow_id."""
|
||||
from calliope.db import get_db
|
||||
from calliope.queue.manager import queue_manager
|
||||
|
||||
wf = _import_workflow(client).json()
|
||||
project = client.post(
|
||||
"/api/projects", json={"title": "删除保护测试", "idea": "x"}
|
||||
).json()
|
||||
# Pause the lifespan-started worker so the pending job STAYS pending.
|
||||
queue_manager.paused = True
|
||||
try:
|
||||
queue_manager.enqueue(
|
||||
project_id=project["id"], kind="video", workflow_id=wf["id"], payload={}
|
||||
)
|
||||
|
||||
r = client.delete(f"/api/workflows/{wf['id']}")
|
||||
assert r.status_code == 409
|
||||
assert "排队" in r.json()["detail"]
|
||||
finally:
|
||||
queue_manager.paused = False
|
||||
|
||||
# job finished → deletion allowed again
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute("UPDATE jobs SET status = 'done' WHERE project_id = ?", (project["id"],))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
r = client.delete(f"/api/workflows/{wf['id']}")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_settings_public_dict_masks_endpoint_tokens(client):
|
||||
prev = _snapshot_settings()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
"""HEL-633: the three image-input workflows the user supplied — Qwen Image 2.1
|
||||
图片编辑, MiniMax H3 首帧图生视频 (04) and H3 参考图生视频 (05, 1–9 refs).
|
||||
|
||||
Slots come from real wiring, every picked image is uploaded to ComfyUI and
|
||||
lands in the node input it belongs to (in order, nothing stale), and a
|
||||
broken export is refused before anything is queued."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.parser import detect_untagged_inputs, parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.smart_fill import smart_fill_inputs
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import (
|
||||
PNG_1PX,
|
||||
_FakeComfyHandler,
|
||||
_import_workflow,
|
||||
_real_run,
|
||||
_wait_done,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
I2V = "h3_i2v_04_API.json" # 04_i2v_fl2v_4step_sla (1).json
|
||||
REF = "h3_ref2va_05_API.json" # 05_ref2va_4step_sla (1).json
|
||||
QWEN_EDIT = "qwen21_edit_sage_API.json" # qwen-image-2.1-图片编辑-sage加速.json
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
REF_KEY = "131@ref_images"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _by_node(inputs: list[dict]) -> dict[str, dict]:
|
||||
return {i["nodeId"]: i for i in inputs}
|
||||
|
||||
|
||||
def _ref_links(wf: dict, node_id: str = "131") -> list[tuple[str, str]]:
|
||||
"""[(input name, LoadImage image value), …] in slot order."""
|
||||
inputs = wf[node_id]["inputs"]
|
||||
names = sorted(
|
||||
(k for k in inputs if k.startswith("ref_images.ref_image_")),
|
||||
key=lambda k: int(k.rsplit("_", 1)[1]),
|
||||
)
|
||||
return [(k, wf[inputs[k][0]]["inputs"]["image"]) for k in names]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slot detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_i2v_exposes_one_required_first_frame_and_no_last_frame():
|
||||
inputs = parse_dynamic_inputs(_load(I2V))
|
||||
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
|
||||
assert media == [
|
||||
{
|
||||
"nodeId": "901",
|
||||
"label": "首帧",
|
||||
"role": "first_frame",
|
||||
"kind": "image",
|
||||
"defaultValue": None,
|
||||
"required": True,
|
||||
"auto": True,
|
||||
}
|
||||
]
|
||||
# the title comment about FL2V is not a slot
|
||||
assert not any(i.get("role") == "last_frame" for i in inputs)
|
||||
|
||||
|
||||
def test_ref2v_exposes_one_group_of_one_to_nine_reference_images():
|
||||
inputs = parse_dynamic_inputs(_load(REF))
|
||||
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
|
||||
assert len(media) == 1
|
||||
group = media[0]
|
||||
assert group["nodeId"] == REF_KEY
|
||||
assert group["kind"] == "image_list"
|
||||
assert group["role"] == "reference_image"
|
||||
assert group["label"] == "参考图"
|
||||
assert (group["min"], group["max"], group["required"]) == (1, 9, True)
|
||||
# the parameters HEL-627 wired stay exposed
|
||||
by_node = _by_node(inputs)
|
||||
for key in ("1", "115@aspect_ratio", "115@megapixels", "124@steps", "124@denoise",
|
||||
"129@noise_seed", "133"):
|
||||
assert key in by_node, key
|
||||
assert not any(i["kind"] == "video" for i in inputs)
|
||||
|
||||
|
||||
def test_example_five_ref_workflow_folds_into_the_same_group():
|
||||
# The repo example is (Input:…)-tagged and keeps its tagged slots; stripped
|
||||
# of tags (as a plain ComfyUI export) its five loaders become one group.
|
||||
wf = json.loads((EXAMPLES / "video_minimax_h3_r2v_5ref_API.json").read_text(encoding="utf-8"))
|
||||
inputs = detect_untagged_inputs(wf)
|
||||
groups = [i for i in inputs if i["kind"] == "image_list"]
|
||||
assert [g["nodeId"] for g in groups] == ["178@ref_images"]
|
||||
# its five LoadImages are the group, not five separate slots
|
||||
assert not any(i["kind"] == "image" for i in inputs if i.get("role") != "mask")
|
||||
|
||||
|
||||
def test_qwen_edit_exposes_input_image_real_resolution_and_hides_orphan_latent():
|
||||
inputs = parse_dynamic_inputs(_load(QWEN_EDIT))
|
||||
by_node = _by_node(inputs)
|
||||
img = by_node["10"]
|
||||
assert (img["kind"], img["role"], img["label"]) == ("image", "image", "输入图")
|
||||
assert img["required"] is True
|
||||
res = by_node["4@resolution"]
|
||||
assert (res["role"], res["defaultValue"], res["min"], res["max"], res["step"]) == (
|
||||
"resolution", 1024, 0, 4096, 32,
|
||||
)
|
||||
assert by_node["4@prompt"]["role"] == "prompt"
|
||||
assert by_node["4@negative_prompt"]["role"] == "negative"
|
||||
for key in ("6@seed", "6@steps", "6@cfg", "6@denoise"):
|
||||
assert key in by_node
|
||||
# EmptyLatentImage #5 feeds nothing — its width/height would do nothing
|
||||
assert not any(i["nodeId"].startswith("5@") for i in inputs)
|
||||
|
||||
|
||||
def test_unwired_load_image_gets_no_slot():
|
||||
wf = _load(I2V)
|
||||
wf["950"] = {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "last.png"},
|
||||
"_meta": {"title": "LAST"},
|
||||
}
|
||||
assert "950" not in _by_node(parse_dynamic_inputs(wf))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Broken exports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_broken_qwen_export_is_flagged_and_blocked():
|
||||
warnings = workflow_warnings(_load(QWEN_EDIT))
|
||||
errors = {(w["code"], w["node_id"]) for w in warnings if w["level"] == "error"}
|
||||
assert errors == {
|
||||
("missing_class_type", "1"),
|
||||
("missing_class_type", "9"),
|
||||
("missing_model_link", "6"),
|
||||
}
|
||||
assert ("orphan_latent", "5") in {(w["code"], w["node_id"]) for w in warnings}
|
||||
msg = blocking_problem(_load(QWEN_EDIT))
|
||||
assert msg and "Unet Loader (GGUF)" in msg and "重新导出" in msg
|
||||
|
||||
|
||||
def test_h3_workflows_are_clean():
|
||||
assert workflow_warnings(_load(I2V)) == []
|
||||
assert workflow_warnings(_load(REF)) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patching 1 / 4 / 9 reference images
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 4, 9])
|
||||
def test_patch_wires_each_reference_image_in_order(count):
|
||||
base = _load(REF)
|
||||
paths = [f"/assets/uploads/ref-{i}.png" for i in range(1, count + 1)]
|
||||
wf = patch_workflow(base, {REF_KEY: paths})
|
||||
links = _ref_links(wf)
|
||||
assert links == [(f"ref_images.ref_image_{i}", p) for i, p in enumerate(paths)]
|
||||
# every loader is a real, distinct LoadImage feeding node 131
|
||||
loader_ids = [wf["131"]["inputs"][name][0] for name, _ in links]
|
||||
assert len(set(loader_ids)) == count
|
||||
assert all(wf[n]["class_type"] == "LoadImage" for n in loader_ids)
|
||||
assert loader_ids[0] == "901"
|
||||
# the base workflow is untouched
|
||||
assert _ref_links(base) == [("ref_images.ref_image_0", "reference_1.png")]
|
||||
|
||||
|
||||
def test_patch_shrinking_removes_stale_slots_and_loaders():
|
||||
nine = patch_workflow(_load(REF), {REF_KEY: [f"/u/{i}.png" for i in range(9)]})
|
||||
two = patch_workflow(nine, {REF_KEY: ["/u/b.png", "/u/a.png"]})
|
||||
assert _ref_links(two) == [
|
||||
("ref_images.ref_image_0", "/u/b.png"),
|
||||
("ref_images.ref_image_1", "/u/a.png"),
|
||||
]
|
||||
load_images = [n for n, v in two.items() if v.get("class_type") == "LoadImage"]
|
||||
assert len(load_images) == 2
|
||||
assert not any("/u/8.png" in json.dumps(v) for v in two.values())
|
||||
|
||||
|
||||
def test_single_slot_values_patch_first_frame_and_qwen_input():
|
||||
i2v = patch_workflow(_load(I2V), {"901": "/assets/uploads/first.png"})
|
||||
assert i2v["901"]["inputs"]["image"] == "/assets/uploads/first.png"
|
||||
assert i2v["131"]["inputs"]["first_frame"] == ["901", 0]
|
||||
qwen = patch_workflow(_load(QWEN_EDIT), {"10": "/assets/uploads/in.png", "4@resolution": 1536})
|
||||
assert qwen["10"]["inputs"]["image"] == "/assets/uploads/in.png"
|
||||
assert qwen["4"]["inputs"]["resolution"] == 1536
|
||||
|
||||
|
||||
def test_legacy_job_values_keyed_by_load_image_still_patch():
|
||||
"""Jobs queued before HEL-633 carry the LoadImage id, not the group key."""
|
||||
wf = patch_workflow(_load(REF), {"901": "/assets/uploads/old.png"})
|
||||
assert _ref_links(wf) == [("ref_images.ref_image_0", "/assets/uploads/old.png")]
|
||||
|
||||
|
||||
def test_smart_fill_hands_reference_list_to_the_group():
|
||||
inputs = parse_dynamic_inputs(_load(REF))
|
||||
refs = [f"/r/{i}.png" for i in range(12)]
|
||||
values = smart_fill_inputs(inputs, prompt="p", ref_images=refs)
|
||||
assert values[REF_KEY] == refs[:9]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uploads_dir(client):
|
||||
d = settings.assets_dir / "uploads"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _pngs(d: Path, n: int) -> list[str]:
|
||||
out = []
|
||||
for i in range(n):
|
||||
p = d / f"ref{i + 1}.png"
|
||||
p.write_bytes(PNG_1PX)
|
||||
out.append(str(p))
|
||||
return out
|
||||
|
||||
|
||||
def test_validate_reference_count_limits(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_load(REF))
|
||||
base = {"1": "prompt"}
|
||||
for n in (1, 4, 9):
|
||||
clean, errs = validate_input_values(schema, {**base, REF_KEY: _pngs(uploads_dir, n)})
|
||||
assert errs == [] and len(clean[REF_KEY]) == n
|
||||
_, errs = validate_input_values(schema, {**base, REF_KEY: _pngs(uploads_dir, 10)})
|
||||
assert errs == ["参考图最多 9 张(当前 10 张),请删除多余的图片"]
|
||||
_, errs = validate_input_values(schema, base)
|
||||
assert errs == ["需要至少 1 张参考图"]
|
||||
_, errs = validate_input_values(schema, {**base, REF_KEY: []})
|
||||
assert errs == ["需要至少 1 张参考图"]
|
||||
|
||||
|
||||
def test_validate_rejects_bad_reference_files(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_load(REF))
|
||||
gif = uploads_dir / "anim.gif"
|
||||
gif.write_bytes(b"GIF89a")
|
||||
ok = _pngs(uploads_dir, 1)[0]
|
||||
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: [ok, str(gif)]})
|
||||
assert errs == ["参考图 2:格式不支持,请使用 PNG / JPG / WebP"]
|
||||
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: ["/etc/passwd.png"]})
|
||||
assert errs == ["参考图 1:只能使用在本页上传或从生成结果选入的图片"]
|
||||
_, errs = validate_input_values(schema, {"1": "p", REF_KEY: [str(uploads_dir / "gone.png")]})
|
||||
assert errs == ["参考图 1:文件已不存在,请重新上传"]
|
||||
|
||||
|
||||
def test_validate_requires_first_frame_and_input_image():
|
||||
_, errs = validate_input_values(parse_dynamic_inputs(_load(I2V)), {"1": "p"})
|
||||
assert errs == ["请上传首帧"]
|
||||
_, errs = validate_input_values(parse_dynamic_inputs(_load(QWEN_EDIT)), {"4@prompt": "p"})
|
||||
assert errs == ["请上传输入图"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full chain against a fake ComfyUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _NamedUploadHandler(_FakeComfyHandler):
|
||||
"""/upload/image answers with the uploaded file's own name, like ComfyUI."""
|
||||
|
||||
def do_POST(self): # noqa: N802 — BaseHTTPRequestHandler hook name
|
||||
if self.path != "/upload/image":
|
||||
super().do_POST()
|
||||
return
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else b""
|
||||
self.server.requests.append(self.path)
|
||||
self.server.uploads.append(body)
|
||||
m = re.search(rb'filename="([^"]+)"', body)
|
||||
name = m.group(1).decode() if m else "x.png"
|
||||
self.server.upload_names.append(name)
|
||||
self._json(200, {"name": name, "subfolder": "calliope", "type": "input"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def named_comfy():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _NamedUploadHandler)
|
||||
server.requests = []
|
||||
server.prompts = []
|
||||
server.uploads = []
|
||||
server.upload_names = []
|
||||
server.history = {}
|
||||
server.fail_prompt = False
|
||||
server.out_name = "ref2v_00001_.mp4"
|
||||
server.outputs_key = "images"
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _upload(client, name: str) -> str:
|
||||
r = client.post("/api/playground/uploads", files={"file": (name, PNG_1PX, "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["path"]
|
||||
|
||||
|
||||
def _generate(client, wf_id: int, values: dict, source: str = "studio-video"):
|
||||
return client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": values,
|
||||
"source": source,
|
||||
"endpoint_id": settings.comfyui_active_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 4, 9])
|
||||
def test_ref2v_chain_uploads_every_image_and_wires_it_in_order(client, named_comfy, count):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
|
||||
paths = [_upload(client, f"角色{i}.png") for i in range(1, count + 1)]
|
||||
r = _generate(
|
||||
client,
|
||||
wf_id,
|
||||
{"1": "<Subject 1> 走进雨夜码头", REF_KEY: paths, "124@steps": 4, "133": 2},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["payload"]["input_values"][REF_KEY] == paths
|
||||
|
||||
assert len(named_comfy.prompts) == 1
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
expected = [f"calliope/{Path(p).name}" for p in paths]
|
||||
assert [img for _k, img in _ref_links(prompt)] == expected
|
||||
assert named_comfy.upload_names == [Path(p).name for p in paths]
|
||||
assert prompt["1"]["inputs"]["value"] == "<Subject 1> 走进雨夜码头"
|
||||
assert prompt["124"]["inputs"]["steps"] == 4
|
||||
assert prompt["133"]["inputs"]["value"] == 2
|
||||
# no leftover default reference and no unconsumed LoadImage
|
||||
assert "reference_1.png" not in json.dumps(prompt)
|
||||
loaders = {n for n, v in prompt.items() if v["class_type"] == "LoadImage"}
|
||||
wired = {v[0] for k, v in prompt["131"]["inputs"].items() if k.startswith("ref_images.")}
|
||||
assert loaders == wired
|
||||
|
||||
|
||||
def test_ref2v_replace_and_delete_leave_no_stale_image(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
|
||||
a, b, c = (_upload(client, n) for n in ("a.png", "b.png", "c.png"))
|
||||
r = _generate(client, wf_id, {"1": "x", REF_KEY: [a, b, c]})
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
# user replaced #2 with d and deleted #3
|
||||
d = _upload(client, "d.png")
|
||||
r = _generate(client, wf_id, {"1": "x", REF_KEY: [a, d]})
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
second = named_comfy.prompts[1]["prompt"]
|
||||
assert [img for _k, img in _ref_links(second)] == [
|
||||
f"calliope/{Path(a).name}",
|
||||
f"calliope/{Path(d).name}",
|
||||
]
|
||||
dumped = json.dumps(second)
|
||||
assert Path(b).name not in dumped and Path(c).name not in dumped
|
||||
|
||||
|
||||
def test_ref2v_ten_images_rejected_before_queue(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 参考生视频", "video", _load(REF))
|
||||
paths = [_upload(client, f"r{i}.png") for i in range(10)]
|
||||
r = _generate(client, wf_id, {"1": "x", REF_KEY: paths})
|
||||
assert r.status_code == 400
|
||||
assert "参考图最多 9 张" in r.json()["detail"]
|
||||
r = _generate(client, wf_id, {"1": "x"})
|
||||
assert r.status_code == 400
|
||||
assert "需要至少 1 张参考图" in r.json()["detail"]
|
||||
assert named_comfy.prompts == [] and named_comfy.upload_names == []
|
||||
|
||||
|
||||
def test_i2v_chain_uploads_first_frame(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 图生视频", "video", _load(I2V))
|
||||
r = _generate(client, wf_id, {"1": "镜头缓慢推近"})
|
||||
assert r.status_code == 400 and "请上传首帧" in r.json()["detail"]
|
||||
first = _upload(client, "首帧.png")
|
||||
r = _generate(client, wf_id, {"1": "镜头缓慢推近", "901": first, "124@denoise": 0.8})
|
||||
assert r.status_code == 200, r.text
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
assert prompt["901"]["inputs"]["image"] == f"calliope/{Path(first).name}"
|
||||
assert prompt["131"]["inputs"]["first_frame"] == ["901", 0]
|
||||
assert prompt["124"]["inputs"]["denoise"] == 0.8
|
||||
|
||||
|
||||
def test_broken_qwen_import_warns_and_generation_is_refused(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": _load(QWEN_EDIT)})
|
||||
assert analyzed.status_code == 200
|
||||
codes = {w["code"] for w in analyzed.json()["warnings"]}
|
||||
assert {"missing_class_type", "missing_model_link", "orphan_latent"} <= codes
|
||||
# importing is allowed (the user may fix it later) …
|
||||
wf_id = _import_workflow(client, "Qwen 图片编辑", "image", _load(QWEN_EDIT))
|
||||
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
|
||||
assert any(w["level"] == "error" for w in listed["warnings"])
|
||||
# … but it never reaches ComfyUI and never shows as done
|
||||
img = _upload(client, "门面.png")
|
||||
r = _generate(client, wf_id, {"4@prompt": "换招牌", "10": img}, source="studio-image")
|
||||
assert r.status_code == 400
|
||||
assert "重新导出" in r.json()["detail"]
|
||||
assert client.get("/api/playground/jobs").json() == []
|
||||
assert named_comfy.prompts == [] and named_comfy.upload_names == []
|
||||
|
||||
|
||||
def test_repaired_qwen_export_runs_with_its_input_image(client, named_comfy):
|
||||
wf = _load(QWEN_EDIT)
|
||||
wf["1"] = {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_2.1_int8_convrot.safetensors",
|
||||
"weight_dtype": "default",
|
||||
},
|
||||
}
|
||||
del wf["9"]
|
||||
wf["6"]["inputs"]["model"] = ["1", 0]
|
||||
with _real_run(client, named_comfy):
|
||||
named_comfy.out_name = "qwen_image_00001_.png"
|
||||
wf_id = _import_workflow(client, "Qwen 图片编辑(修复版)", "image", wf)
|
||||
img = _upload(client, "门面.png")
|
||||
r = _generate(
|
||||
client, wf_id, {"4@prompt": "换招牌", "10": img, "4@resolution": 1536, "6@steps": 20},
|
||||
source="studio-image",
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done" and job["output_paths"]
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
assert prompt["10"]["inputs"]["image"] == f"calliope/{Path(img).name}"
|
||||
assert prompt["4"]["inputs"]["images.image_1"] == ["10", 0]
|
||||
assert prompt["4"]["inputs"]["resolution"] == 1536
|
||||
assert prompt["6"]["inputs"]["steps"] == 20
|
||||
|
||||
|
||||
def test_worker_refuses_broken_workflow_from_other_callers(client, named_comfy):
|
||||
"""Workbench/agent jobs skip the playground check — the worker still stops them."""
|
||||
from calliope.db import get_db
|
||||
from calliope.queue.manager import queue_manager
|
||||
from calliope.routers.playground import ensure_playground_project
|
||||
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "Qwen 图片编辑", "image", _load(QWEN_EDIT))
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
job = queue_manager.enqueue(
|
||||
project_id=pid, kind="image", workflow_id=wf_id, scene_id=None,
|
||||
payload={"input_values": {"4@prompt": "x"}, "source": "playground"},
|
||||
)
|
||||
done = _wait_done(client, job["id"])
|
||||
assert done["status"] == "failed"
|
||||
assert "重新导出" in (done["error"] or "")
|
||||
assert named_comfy.prompts == []
|
||||
@@ -216,6 +216,18 @@ def test_settings_accepts_agent_llm_assignments(client):
|
||||
# Unknown role keys are dropped, known roles stored (incl. explicit None)
|
||||
assert body["agent_llm_assignments"] == {"video": a_id, "story": None}
|
||||
|
||||
# HEL-619: updates are per-role incremental — a partial payload must
|
||||
# not wipe roles it does not mention (rapid-switch race).
|
||||
r = client.post(
|
||||
"/api/settings",
|
||||
json={"agent_llm_assignments": {"main": a_id}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["agent_llm_assignments"]["main"] == a_id
|
||||
assert body["agent_llm_assignments"]["video"] == a_id
|
||||
assert body["agent_llm_assignments"]["story"] is None
|
||||
|
||||
r = client.post(
|
||||
"/api/settings",
|
||||
json={"agent_llm_assignments": {"video": "no-such-profile"}},
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""媒体组泛化(HEL-643 批次 3):参考视频/参考音频按 JSON 实际连线进入
|
||||
input_schema 与提交流程;无连线则无入口;05_ref2va 只保留参考图。
|
||||
|
||||
视频/音频参考的真实 H3 JSON 暂无可执行样本(.15 无 ComfyUI),本套用
|
||||
自造 fixture + fake ComfyUI 验证解析/表单/回填/上传全链路(模拟链路)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import PNG_1PX, _real_run
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uploads_dir(client):
|
||||
d = settings.assets_dir / "uploads"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _load_05() -> dict:
|
||||
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _ref2va_workflow(*, images=1, videos=1, audios=1) -> dict:
|
||||
"""H3 Ref2VA 形状的自造 JSON:图/视频/音频各一组 Autogrow 连线。"""
|
||||
wf: dict = {
|
||||
"1": {
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"inputs": {"value": "提示词"},
|
||||
},
|
||||
"92": {
|
||||
"class_type": "SaveVideo",
|
||||
"inputs": {"video": ["131", 0]},
|
||||
},
|
||||
"131": {
|
||||
"class_type": "MiniMaxH3ReferenceToVideo",
|
||||
"inputs": {"prompt": ["1", 0]},
|
||||
},
|
||||
"901": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
|
||||
"902": {"class_type": "LoadVideo", "inputs": {"file": "ref.mp4", "force_rate": 0}},
|
||||
"903": {"class_type": "LoadAudio", "inputs": {"audio": "ref.wav"}},
|
||||
}
|
||||
ins = wf["131"]["inputs"]
|
||||
for i in range(images):
|
||||
ins[f"ref_images.ref_image_{i}"] = ["901", 0]
|
||||
for i in range(videos):
|
||||
ins[f"ref_videos.ref_video_{i}"] = ["902", 0]
|
||||
for i in range(audios):
|
||||
ins[f"ref_audios.ref_audio_{i}"] = ["903", 0]
|
||||
return wf
|
||||
|
||||
|
||||
def _entry(schema: list[dict], key_suffix: str) -> dict | None:
|
||||
for e in schema:
|
||||
if e["nodeId"].endswith("@" + key_suffix):
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema: 入口只由实际连线决定
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ref2va_schema_exposes_three_media_groups():
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow())
|
||||
img = _entry(schema, "ref_images")
|
||||
vid = _entry(schema, "ref_videos")
|
||||
aud = _entry(schema, "ref_audios")
|
||||
assert img and img["kind"] == "image_list" and img["role"] == "reference_image"
|
||||
assert img["max"] == 9 and img["label"] == "参考图"
|
||||
assert vid and vid["kind"] == "video_list" and vid["role"] == "reference_video"
|
||||
assert vid["max"] == 3 and vid["label"] == "参考视频"
|
||||
assert aud and aud["kind"] == "audio_list" and aud["role"] == "reference_audio"
|
||||
assert aud["max"] == 3 and aud["label"] == "参考音频"
|
||||
# 官方限额提示按节点类名驱动(通用检测不携带 hint)
|
||||
assert img["media_hint"] == vid["media_hint"] == aud["media_hint"] == "minimax_h3_ref2va"
|
||||
# 被组吸收的 loader 不再单独出现
|
||||
assert not any(e["nodeId"] == "902" for e in schema)
|
||||
assert not any(e["nodeId"] == "903" for e in schema)
|
||||
|
||||
|
||||
def test_unwired_media_kinds_produce_no_entry():
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow(videos=0, audios=0))
|
||||
assert _entry(schema, "ref_images") is not None
|
||||
assert _entry(schema, "ref_videos") is None
|
||||
assert _entry(schema, "ref_audios") is None
|
||||
|
||||
|
||||
def test_generic_class_gets_group_without_hint():
|
||||
wf = _ref2va_workflow(videos=0, audios=0)
|
||||
wf["20"] = {"class_type": "GenericRefNode", "inputs": {}}
|
||||
wf["701"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "a.mp4"}}
|
||||
wf["702"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "b.mp4"}}
|
||||
wf["20"]["inputs"]["media.clip_0"] = ["701", 0]
|
||||
wf["20"]["inputs"]["media.clip_1"] = ["702", 0]
|
||||
schema = parse_dynamic_inputs(wf)
|
||||
g = _entry(schema, "media")
|
||||
assert g and g["kind"] == "video_list" and g["max"] == 9
|
||||
assert "media_hint" not in g
|
||||
|
||||
|
||||
def test_05_ref2va_fixture_stays_image_only():
|
||||
schema = parse_dynamic_inputs(_load_05())
|
||||
lists = [e for e in schema if e["kind"].endswith("_list")]
|
||||
assert len(lists) == 1
|
||||
assert lists[0]["kind"] == "image_list" and lists[0]["max"] == 9
|
||||
assert lists[0]["nodeId"] == "131@ref_images"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _media_file(path: str, data: bytes) -> str:
|
||||
p = Path(path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(data)
|
||||
return str(p)
|
||||
|
||||
|
||||
def test_validate_video_and_audio_lists(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow())
|
||||
img_key = _entry(schema, "ref_images")["nodeId"]
|
||||
vid_key = _entry(schema, "ref_videos")["nodeId"]
|
||||
aud_key = _entry(schema, "ref_audios")["nodeId"]
|
||||
png = _media_file(str(uploads_dir / "ok.png"), PNG_1PX)
|
||||
mp4 = _media_file(str(uploads_dir / "ok.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "ok.mp3"), b"00")
|
||||
|
||||
clean, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == [] and clean[vid_key] == [mp4] and clean[aud_key] == [mp3]
|
||||
|
||||
# 错误格式(图片文件填视频槽 / 视频文件填音频槽)
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [png], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["参考视频 1:格式不支持,请使用 MP4 / WebM / MOV"]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp4]}
|
||||
)
|
||||
assert errs == ["参考音频 1:格式不支持,请使用 MP3 / WAV / M4A"]
|
||||
|
||||
# 上限(H3 官方 3 段)与最少 1 段
|
||||
four = [_media_file(str(uploads_dir / f"v{i}.mp4"), b"00") for i in range(4)]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: four, aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["参考视频最多 3 段(当前 4 段),请删除多余的视频"]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["需要至少 1 段参考视频"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patcher + full chain against fake ComfyUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patcher_clones_and_backfills_each_media_kind(uploads_dir):
|
||||
wf = _ref2va_workflow()
|
||||
schema = parse_dynamic_inputs(wf)
|
||||
pngs = [_media_file(str(uploads_dir / "r1.png"), PNG_1PX),
|
||||
_media_file(str(uploads_dir / "r2.png"), PNG_1PX)]
|
||||
mp4 = _media_file(str(uploads_dir / "v1.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "a1.mp3"), b"00")
|
||||
patched = patch_workflow(
|
||||
wf,
|
||||
{
|
||||
_entry(schema, "ref_images")["nodeId"]: pngs,
|
||||
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
||||
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
||||
},
|
||||
)
|
||||
ins = patched["131"]["inputs"]
|
||||
image_links = [v for k, v in ins.items() if k.startswith("ref_images.")]
|
||||
video_links = [v for k, v in ins.items() if k.startswith("ref_videos.")]
|
||||
audio_links = [v for k, v in ins.items() if k.startswith("ref_audios.")]
|
||||
assert len(image_links) == 2 and len(video_links) == 1 and len(audio_links) == 1
|
||||
loaders = {nid: n for nid, n in patched.items()
|
||||
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")}
|
||||
imgs = [n for n in loaders.values() if n["class_type"] == "LoadImage"]
|
||||
vids = [n for n in loaders.values() if n["class_type"] == "LoadVideo"]
|
||||
auds = [n for n in loaders.values() if n["class_type"] == "LoadAudio"]
|
||||
assert len(imgs) == 2 and all(n["inputs"]["image"] in pngs for n in imgs)
|
||||
assert vids and vids[0]["inputs"]["file"] == mp4
|
||||
assert auds and auds[0]["inputs"]["audio"] == mp3
|
||||
# 克隆出的节点带中文槽位标题
|
||||
assert {n["_meta"]["title"] for n in imgs} == {"参考图 1", "参考图 2"}
|
||||
assert vids[0]["_meta"]["title"] == "参考视频 1"
|
||||
assert auds[0]["_meta"]["title"] == "参考音频 1"
|
||||
|
||||
|
||||
def test_full_chain_uploads_and_backfills_media_groups(client, fake_comfy, uploads_dir):
|
||||
"""提交 → 上传 → 回填:fake ComfyUI 收到的 prompt 里每类 loader 都
|
||||
指向已上传文件(模拟链路:fake 端点,非真机 H3)。"""
|
||||
wf = _ref2va_workflow()
|
||||
png = _media_file(str(uploads_dir / "ref.png"), PNG_1PX)
|
||||
mp4 = _media_file(str(uploads_dir / "ref.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "ref.mp3"), b"00")
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "媒体组链路", "workflow_json": wf, "usage": "ref2video"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf_id = r.json()["id"]
|
||||
schema = r.json()["input_schema"]
|
||||
|
||||
with _real_run(client, fake_comfy):
|
||||
fake_comfy.out_name = "media_out.mp4"
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test",
|
||||
json={
|
||||
"input_values": {
|
||||
_entry(schema, "ref_images")["nodeId"]: [png],
|
||||
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
||||
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job_id = r.json()["job"]["id"]
|
||||
|
||||
deadline = time.time() + 25
|
||||
job = {}
|
||||
while time.time() < deadline:
|
||||
job = client.get(f"/api/jobs/{job_id}").json()
|
||||
if job["status"] not in ("pending", "running"):
|
||||
break
|
||||
time.sleep(0.4)
|
||||
assert job["status"] == "done", job.get("error")
|
||||
|
||||
assert len(fake_comfy.uploads) >= 3 # 图、视频、音频各一次
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
loaders = [n for n in queued.values()
|
||||
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")]
|
||||
by_field = {
|
||||
(n["class_type"], k): v
|
||||
for n in loaders
|
||||
for k, v in n["inputs"].items()
|
||||
if isinstance(v, str) and "uploaded-input" in v
|
||||
}
|
||||
assert ("LoadImage", "image") in by_field
|
||||
assert ("LoadVideo", "file") in by_field
|
||||
assert ("LoadAudio", "audio") in by_field
|
||||
# 组连线数与提交数一致
|
||||
ins = queued["131"]["inputs"]
|
||||
assert len([k for k in ins if k.startswith("ref_images.")]) == 1
|
||||
assert len([k for k in ins if k.startswith("ref_videos.")]) == 1
|
||||
assert len([k for k in ins if k.startswith("ref_audios.")]) == 1
|
||||
@@ -114,7 +114,8 @@ def test_queue_prompt_surfaces_comfy_error_body(monkeypatch):
|
||||
|
||||
msg = asyncio.run(run())
|
||||
assert "prompt_outputs_failed_validation" in msg
|
||||
assert "node 12" in msg
|
||||
assert "节点 12" in msg
|
||||
assert "HTTP 400" in msg
|
||||
assert "Invalid audio file" in msg
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
"""HEL-627: the two workflows the user runs (Qwen Image 2.1 文生图 and
|
||||
MiniMax H3 reference 文生视频) expose every widget they really have, the
|
||||
values land in the /prompt payload, and a job is only "done" once ComfyUI
|
||||
returned real files — empty, failed, lost and placeholder runs never are."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from http.server import ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.parser import (
|
||||
RESOLUTION_ASPECT_RATIOS,
|
||||
effective_input_schema,
|
||||
parse_dynamic_inputs,
|
||||
resolution_selector_size,
|
||||
)
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db
|
||||
from tests.test_studio_generation import (
|
||||
_FakeComfyHandler,
|
||||
_import_workflow,
|
||||
_real_run,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
T2I = "wen_sheng_tu_API.json" # image_qwen_image_2_1_t2i (2).json
|
||||
T2V = "wen_sheng_shipin_API.json" # 01_reference_4step_sla (1).json
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _by_role(inputs: list[dict]) -> dict[str, dict]:
|
||||
return {i["role"]: i for i in inputs if i.get("role")}
|
||||
|
||||
|
||||
class _StatusHandler(_FakeComfyHandler):
|
||||
"""Fake ComfyUI with /queue and scriptable history outcomes."""
|
||||
|
||||
def do_GET(self):
|
||||
srv = self.server
|
||||
if self.path == "/queue":
|
||||
if srv.queue_mode is None:
|
||||
self._json(404, {"error": "not found"})
|
||||
return
|
||||
self.server.requests.append(self.path)
|
||||
items = [[i, pid, {}, {}, []] for i, pid in enumerate(srv.history)]
|
||||
if srv.queue_mode == "running":
|
||||
self._json(200, {"queue_running": items, "queue_pending": []})
|
||||
elif srv.queue_mode == "pending":
|
||||
blocker = [[-1, "someone-else", {}, {}, []]]
|
||||
self._json(200, {"queue_running": blocker, "queue_pending": items})
|
||||
else:
|
||||
self._json(200, {"queue_running": [], "queue_pending": []})
|
||||
return
|
||||
if self.path.startswith("/history/"):
|
||||
if srv.lose_history or srv.hidden_history_polls > 0:
|
||||
srv.hidden_history_polls = max(0, srv.hidden_history_polls - 1)
|
||||
self.server.requests.append(self.path)
|
||||
self._json(200, {})
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
super().do_POST()
|
||||
if self.path == "/prompt" and self.server.history_patch:
|
||||
for entry in self.server.history.values():
|
||||
entry.update(self.server.history_patch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def status_comfy():
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _StatusHandler)
|
||||
server.requests = []
|
||||
server.prompts = []
|
||||
server.uploads = []
|
||||
server.history = {}
|
||||
server.fail_prompt = False
|
||||
server.out_name = "out-1.png"
|
||||
server.outputs_key = "images"
|
||||
server.queue_mode = None
|
||||
server.lose_history = False
|
||||
server.hidden_history_polls = 0
|
||||
server.history_patch = None
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def _wait_final(client, job_id: int, timeout: float = 25.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
for j in client.get("/api/playground/jobs").json():
|
||||
if j["id"] == job_id and j["status"] in ("done", "failed"):
|
||||
return j
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"job {job_id} did not finish")
|
||||
|
||||
|
||||
def _generate(client, wf_id: int, values: dict, source: str = "studio-image"):
|
||||
return client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": values,
|
||||
"source": source,
|
||||
"endpoint_id": settings.comfyui_active_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parameter detection on the two real workflows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_real_t2i_exposes_ratio_megapixels_steps_denoise():
|
||||
roles = _by_role(parse_dynamic_inputs(_load(T2I)))
|
||||
assert set(roles) == {
|
||||
"prompt", "negative", "aspect_ratio", "megapixels", "batch",
|
||||
"seed", "steps", "cfg", "denoise",
|
||||
}
|
||||
ratio = roles["aspect_ratio"]
|
||||
assert ratio["nodeId"] == "13@aspect_ratio"
|
||||
assert ratio["kind"] == "select"
|
||||
assert ratio["defaultValue"] == "1:1 (Square)"
|
||||
assert ratio["options"] == list(RESOLUTION_ASPECT_RATIOS)
|
||||
assert ratio["resolution"] == {"megapixels": "13@megapixels", "multiple": 8}
|
||||
assert roles["megapixels"]["defaultValue"] == 1
|
||||
assert roles["steps"]["nodeId"] == "459:458@steps"
|
||||
assert roles["steps"]["defaultValue"] == 25 and roles["steps"]["integer"] is True
|
||||
assert roles["denoise"]["nodeId"] == "459:458@denoise"
|
||||
assert (roles["denoise"]["min"], roles["denoise"]["max"]) == (0.0, 1.0)
|
||||
# Width/height are wired from ResolutionSelector; they are not faked as
|
||||
# free inputs, and the encoder's reference-image `resolution` is not exposed.
|
||||
assert "width" not in roles and "height" not in roles
|
||||
assert all("resolution" not in i["nodeId"] for i in parse_dynamic_inputs(_load(T2I)))
|
||||
|
||||
|
||||
def test_real_t2v_exposes_ratio_steps_denoise_seed_duration():
|
||||
inputs = parse_dynamic_inputs(_load(T2V))
|
||||
roles = _by_role(inputs)
|
||||
assert set(roles) == {
|
||||
"prompt", "aspect_ratio", "megapixels", "steps", "denoise", "seed", "duration",
|
||||
}
|
||||
assert roles["aspect_ratio"]["defaultValue"] == "16:9 (Widescreen)"
|
||||
assert roles["aspect_ratio"]["resolution"]["multiple"] == 32
|
||||
assert roles["megapixels"]["defaultValue"] == 0.7
|
||||
assert roles["steps"]["nodeId"] == "124@steps" and roles["steps"]["defaultValue"] == 4
|
||||
assert roles["seed"]["nodeId"] == "129@noise_seed"
|
||||
assert roles["duration"]["nodeId"] == "133" and roles["duration"]["defaultValue"] == 10
|
||||
# BasicGuider has no CFG; the frame expression hard-codes 24 fps, so no fps field.
|
||||
assert "cfg" not in roles and "fps" not in roles
|
||||
|
||||
|
||||
def test_resolution_matches_comfyui_resolution_selector():
|
||||
# The H3 workflow's own title documents 1152x640 for 16:9 · 0.7 MP · ×32.
|
||||
assert resolution_selector_size("16:9 (Widescreen)", 0.7, 32) == (1152, 640)
|
||||
assert resolution_selector_size("1:1 (Square)", 1, 8) == (1024, 1024)
|
||||
assert resolution_selector_size("9:16 (Portrait Widescreen)", 1, 8) == (768, 1368)
|
||||
|
||||
|
||||
def test_validation_rejects_bad_values_in_chinese():
|
||||
schema = parse_dynamic_inputs(_load(T2I))
|
||||
clean, errors = validate_input_values(
|
||||
schema,
|
||||
{
|
||||
"459:452@prompt": "猫",
|
||||
"13@aspect_ratio": "5:4",
|
||||
"459:458@steps": 12.5,
|
||||
"459:458@denoise": 1.5,
|
||||
"13@megapixels": "abc",
|
||||
},
|
||||
)
|
||||
assert any("画面比例" in e and "5:4" in e for e in errors)
|
||||
assert any("采样步数" in e and "整数" in e for e in errors)
|
||||
assert any("降噪强度" in e and "不能大于 1" in e for e in errors)
|
||||
assert any("总像素" in e and "数字" in e for e in errors)
|
||||
clean, errors = validate_input_values(schema, {"459:458@steps": "30", "13@megapixels": 2})
|
||||
assert errors == [] # an omitted prompt falls back to the workflow's own text
|
||||
assert clean["459:458@steps"] == 30 and isinstance(clean["459:458@steps"], int)
|
||||
|
||||
blank = [dict(e, defaultValue="") if e.get("role") == "prompt" else e for e in schema]
|
||||
_, errors = validate_input_values(blank, {"459:452@prompt": " "})
|
||||
assert errors == ["请填写「正向提示词」"]
|
||||
|
||||
|
||||
def test_stale_auto_schema_is_refreshed_without_reimport(client):
|
||||
wf = _load(T2I)
|
||||
stale = [
|
||||
{"nodeId": "459:452@prompt", "label": "正向提示词", "role": "prompt",
|
||||
"kind": "textarea", "defaultValue": "x", "required": True, "auto": True},
|
||||
]
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO workflows"
|
||||
" (name, kind, workflow_json, input_schema, output_schema, is_enabled)"
|
||||
" VALUES (?, 'image', ?, ?, '[]', 1)",
|
||||
("旧导入·Qwen2.1", json.dumps(wf), json.dumps(stale)),
|
||||
)
|
||||
conn.commit()
|
||||
wf_id = cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
roles = _by_role(client.get(f"/api/workflows/{wf_id}").json()["input_schema"])
|
||||
assert {"aspect_ratio", "megapixels", "steps", "denoise"} <= set(roles)
|
||||
|
||||
|
||||
def test_tagged_schema_is_kept_as_stored():
|
||||
stored = [{"nodeId": "6", "label": "P", "role": "prompt", "kind": "textarea",
|
||||
"required": True}]
|
||||
assert effective_input_schema(stored, _load(T2I)) == stored
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Submit payload + job status against a fake ComfyUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_t2i_values_reach_the_prompt_payload(client, status_comfy):
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(参数)", "image", _load(T2I))
|
||||
r = _generate(
|
||||
client,
|
||||
wf_id,
|
||||
{
|
||||
"459:452@prompt": "雨夜霓虹街道",
|
||||
"459:452@negative_prompt": "模糊",
|
||||
"13@aspect_ratio": "16:9 (Widescreen)",
|
||||
"13@megapixels": 2,
|
||||
"459:458@steps": "30",
|
||||
"459:458@cfg": 2.5,
|
||||
"459:458@denoise": 0.8,
|
||||
"459:458@seed": 7,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_final(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
queued = status_comfy.prompts[-1]["prompt"]
|
||||
assert queued["13"]["inputs"]["aspect_ratio"] == "16:9 (Widescreen)"
|
||||
assert queued["13"]["inputs"]["megapixels"] == 2
|
||||
assert queued["459:458"]["inputs"]["steps"] == 30
|
||||
assert queued["459:458"]["inputs"]["cfg"] == 2.5
|
||||
assert queued["459:458"]["inputs"]["denoise"] == 0.8
|
||||
assert queued["459:458"]["inputs"]["seed"] == 7
|
||||
assert queued["459:452"]["inputs"]["prompt"] == "雨夜霓虹街道"
|
||||
# Wiring intact: the latent still takes its size from the selector.
|
||||
assert queued["459:456"]["inputs"]["width"] == ["13", 0]
|
||||
assert queued["459:456"]["inputs"]["height"] == ["13", 1]
|
||||
assert job["comfy_prompt_id"] in status_comfy.history
|
||||
assert job["phase"] == "finished"
|
||||
assert job["output_paths"] and Path(job["output_paths"][0]).stat().st_size > 0
|
||||
|
||||
|
||||
def test_t2v_values_reach_the_prompt_payload(client, status_comfy):
|
||||
status_comfy.outputs_key = "images" # SaveVideo reports under "images" + animated
|
||||
status_comfy.out_name = "clip.mp4"
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·H3 参考生视频", "video", _load(T2V))
|
||||
r = _generate(
|
||||
client,
|
||||
wf_id,
|
||||
{
|
||||
"1": "地铁车厢里的长镜头",
|
||||
"115@aspect_ratio": "9:16 (Portrait Widescreen)",
|
||||
"115@megapixels": 0.5,
|
||||
"124@steps": 6,
|
||||
"124@denoise": 1,
|
||||
"129@noise_seed": 1234,
|
||||
"133": 5,
|
||||
},
|
||||
source="studio-video",
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_final(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
queued = status_comfy.prompts[-1]["prompt"]
|
||||
assert queued["1"]["inputs"]["value"] == "地铁车厢里的长镜头"
|
||||
assert queued["115"]["inputs"]["aspect_ratio"] == "9:16 (Portrait Widescreen)"
|
||||
assert queued["115"]["inputs"]["megapixels"] == 0.5
|
||||
assert queued["124"]["inputs"]["steps"] == 6
|
||||
assert queued["129"]["inputs"]["noise_seed"] == 1234
|
||||
assert queued["133"]["inputs"]["value"] == 5
|
||||
assert queued["132"]["inputs"]["values.a"] == ["133", 0]
|
||||
|
||||
|
||||
def test_invalid_values_are_rejected_before_queueing(client, status_comfy):
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(校验)", "image", _load(T2I))
|
||||
r = _generate(client, wf_id, {"459:452@prompt": "猫", "459:458@steps": 0})
|
||||
assert r.status_code == 400
|
||||
assert "采样步数" in r.json()["detail"] and "不能小于 1" in r.json()["detail"]
|
||||
r = _generate(client, wf_id, {"459:452@prompt": "猫", "13@megapixels": 99})
|
||||
assert r.status_code == 400 and "总像素" in r.json()["detail"]
|
||||
assert "/prompt" not in status_comfy.requests
|
||||
|
||||
|
||||
def test_empty_outputs_never_count_as_done(client, status_comfy):
|
||||
status_comfy.history_patch = {"outputs": {}}
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(空结果)", "image", _load(T2I))
|
||||
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "没有返回任何图片或视频" in job["error"]
|
||||
assert job["comfy_prompt_id"] and job["comfy_prompt_id"] in job["error"]
|
||||
assert job["output_paths"] == []
|
||||
|
||||
|
||||
def test_comfy_execution_error_is_failed_with_reason(client, status_comfy):
|
||||
status_comfy.history_patch = {
|
||||
"status": {
|
||||
"status_str": "error",
|
||||
"completed": False,
|
||||
"messages": [
|
||||
["execution_start", {}],
|
||||
["execution_error", {"node_type": "UNETLoader",
|
||||
"exception_message": "模型文件不存在"}],
|
||||
],
|
||||
},
|
||||
"outputs": {},
|
||||
}
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(执行错)", "image", _load(T2I))
|
||||
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "ComfyUI 执行出错" in job["error"] and "UNETLoader" in job["error"]
|
||||
assert "模型文件不存在" in job["error"]
|
||||
|
||||
|
||||
def test_prompt_lost_from_queue_and_history_fails(client, status_comfy):
|
||||
status_comfy.lose_history = True
|
||||
status_comfy.queue_mode = "missing"
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(丢失)", "image", _load(T2I))
|
||||
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "队列和历史里都找不到该任务" in job["error"]
|
||||
|
||||
|
||||
def test_running_then_finished_reports_real_phase(client, status_comfy):
|
||||
status_comfy.queue_mode = "running"
|
||||
status_comfy.hidden_history_polls = 3
|
||||
with _real_run(client, status_comfy):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(执行中)", "image", _load(T2I))
|
||||
job_id = _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"]
|
||||
seen: set[str] = set()
|
||||
deadline = time.time() + 20
|
||||
while time.time() < deadline:
|
||||
row = next(j for j in client.get("/api/playground/jobs").json() if j["id"] == job_id)
|
||||
if row.get("phase"):
|
||||
seen.add(row["phase"])
|
||||
if row["status"] in ("done", "failed"):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
assert row["status"] == "done", row.get("error")
|
||||
assert "comfy_running" in seen
|
||||
assert "/queue" in status_comfy.requests
|
||||
|
||||
|
||||
def test_missing_model_rejection_names_the_file():
|
||||
"""Body captured from the user's ComfyUI 0.37.4 when Qwen 2.1 models are absent."""
|
||||
import httpx
|
||||
|
||||
from calliope.comfyui.client import _surface_error
|
||||
|
||||
body = {
|
||||
"error": {"type": "prompt_outputs_failed_validation"},
|
||||
"node_errors": {
|
||||
"459:451": {
|
||||
"errors": [{
|
||||
"type": "value_not_in_list",
|
||||
"message": "Value not in list",
|
||||
"details": "unet_name: 'qwen_image_2.1_int8_convrot.safetensors' not in [...]",
|
||||
"extra_info": {
|
||||
"input_name": "unet_name",
|
||||
"received_value": "qwen_image_2.1_int8_convrot.safetensors",
|
||||
},
|
||||
}],
|
||||
"class_type": "UNETLoader",
|
||||
}
|
||||
},
|
||||
}
|
||||
msg = str(_surface_error("ComfyUI 拒绝执行该工作流", httpx.Response(400, json=body)))
|
||||
assert "节点 459:451(UNETLoader)" in msg
|
||||
assert "qwen_image_2.1_int8_convrot.safetensors" in msg and "模型文件未安装" in msg
|
||||
|
||||
|
||||
def test_dry_run_result_is_marked_placeholder(client):
|
||||
wf_id = _import_workflow(client, "真实·Qwen2.1(dry)", "image", _load(T2I))
|
||||
prev = settings.dry_run
|
||||
settings.dry_run = True
|
||||
try:
|
||||
job = _wait_final(client, _generate(client, wf_id, {"459:452@prompt": "猫"}).json()["job"]["id"])
|
||||
finally:
|
||||
settings.dry_run = prev
|
||||
assert job["status"] == "done"
|
||||
assert job["phase"] == "dry_run"
|
||||
assert job["comfy_prompt_id"] is None
|
||||
@@ -13,6 +13,7 @@ The shell is default-OFF; every guard branch here is a security contract:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -25,6 +26,23 @@ from calliope.agent.harness.registry import ToolContext
|
||||
PYTHON = Path(sys.executable).stem # bare name ("python") — the tool refuses paths
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def _interpreter_on_path():
|
||||
"""Make the bare interpreter name resolvable via PATH (HEL-619).
|
||||
|
||||
The guard resolves the command with ``shutil.which`` against PATH —
|
||||
running pytest as ``path/to/.venv/bin/python -m pytest`` (or on a box
|
||||
with no ``python`` alias) leaves the stem unresolvable and every
|
||||
exec-path test fails for environmental reasons only. Prepending the
|
||||
interpreter's own bin dir (restored afterwards) pins the lookup to the
|
||||
interpreter actually running the suite, on every OS.
|
||||
"""
|
||||
prev = os.environ.get("PATH", "")
|
||||
os.environ["PATH"] = f"{Path(sys.executable).parent}{os.pathsep}{prev}"
|
||||
yield
|
||||
os.environ["PATH"] = prev
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def _never_touch_real_db():
|
||||
"""Redirect data_dir before ANY write in this module (repo test rule)."""
|
||||
@@ -210,12 +228,19 @@ def test_structured_shell_approval_allows(enabled):
|
||||
|
||||
|
||||
def test_cwd_outside_workspace_denied(enabled):
|
||||
"""Guard denial tests use PYTHON + a structured approval card (not
|
||||
ffprobe): the interpreter resolves on every machine, and the card
|
||||
clears the approval gate so the LATER containment check is what fires
|
||||
(HEL-619 — ffprobe-absent boxes used to fail these for environmental
|
||||
reasons)."""
|
||||
registry, _ = build_harness()
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "cwd": str(Path(settings_data_dir_home()))},
|
||||
{"command": PYTHON, "cwd": str(settings_data_dir_home())},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_outside_workspace"
|
||||
@@ -230,11 +255,13 @@ def test_sensitive_arg_denied(enabled, tmp_path):
|
||||
registry, _ = build_harness()
|
||||
secret = tmp_path / "calliope_config.json"
|
||||
secret.write_text("{}", encoding="utf-8")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": [str(secret)]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", str(secret)]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_denied_path"
|
||||
@@ -244,11 +271,13 @@ def test_db_arg_denied(enabled, tmp_path):
|
||||
registry, _ = build_harness()
|
||||
db = tmp_path / "calliope.db"
|
||||
db.write_bytes(b"")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": [str(db)]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", str(db)]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_denied_path"
|
||||
@@ -256,11 +285,13 @@ def test_db_arg_denied(enabled, tmp_path):
|
||||
|
||||
def test_env_file_arg_denied(enabled):
|
||||
registry, _ = build_harness()
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": ["--", ".env"]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", "--", ".env"]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_denied_path"
|
||||
@@ -270,11 +301,13 @@ def test_outside_absolute_arg_denied(enabled, tmp_path):
|
||||
registry, _ = build_harness()
|
||||
outside = tmp_path / "movie.mp4"
|
||||
outside.write_bytes(b"00")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": [str(outside)]},
|
||||
{"command": PYTHON, "args": ["-c", "print('x')", str(outside)]},
|
||||
)
|
||||
)
|
||||
assert out["reason_code"] == "guard_shell_outside_workspace"
|
||||
@@ -287,16 +320,22 @@ def test_inside_asset_arg_allowed(enabled):
|
||||
registry, _ = build_harness()
|
||||
ref = settings.assets_dir / "ref.mp4"
|
||||
ref.write_bytes(b"00")
|
||||
sid = _session()
|
||||
_answer_card(sid, "Yes, run it")
|
||||
out = asyncio.run(
|
||||
registry.execute(
|
||||
_ctx(),
|
||||
ToolContext(session_id=sid, project_id=None),
|
||||
"run_command",
|
||||
{"command": "ffprobe", "args": ["-v", "error", str(ref)]},
|
||||
{
|
||||
# The asset path is a real argv entry the guard must allow
|
||||
# (and the interpreter actually reads it).
|
||||
"command": PYTHON,
|
||||
"args": ["-c", "import sys; print(open(sys.argv[1]).read())", str(ref)],
|
||||
},
|
||||
)
|
||||
)
|
||||
# Not a real mp4 — ffprobe fails, but the GUARD must not have denied it.
|
||||
assert out.get("reason_code") is None
|
||||
assert out.get("exit_code") != 0 or out.get("ok") is False or out["ok"] is True
|
||||
assert out["ok"] is True, out
|
||||
assert "00" in out["stdout"]
|
||||
|
||||
|
||||
def test_workspace_relative_cwd_allowed(enabled, tmp_path):
|
||||
|
||||
@@ -25,9 +25,19 @@ PNG_1PX = bytes.fromhex(
|
||||
|
||||
|
||||
class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
"""Minimal ComfyUI: /system_stats, /prompt, /history/{id}, /view, /upload/image."""
|
||||
"""Minimal ComfyUI: /system_stats, /prompt, /history/{id}, /view, /upload/image.
|
||||
|
||||
History outputs are keyed by the prompt's real output node id (what
|
||||
ComfyUI does); a known output class that is absent falls back to
|
||||
``server.forced_output_node`` (default "9"). Scripted tests can pin
|
||||
``server.forced_output_node`` or drop entries via ``server.drop_outputs``.
|
||||
"""
|
||||
|
||||
server_version = "FakeComfy/1.0"
|
||||
KNOWN_OUTPUT_CLASSES = {
|
||||
"SaveImage", "PreviewImage", "SaveImageAdvanced", "SaveImageWebsocket",
|
||||
"SaveVideo", "VideoOutput", "VHS_VideoCombine", "AnimateDiffCombine",
|
||||
}
|
||||
|
||||
def do_GET(self):
|
||||
self.server.requests.append(self.path)
|
||||
@@ -40,6 +50,9 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
},
|
||||
)
|
||||
return
|
||||
if self.path == "/object_info":
|
||||
self._json(200, dict(getattr(self.server, "object_info", None) or {}))
|
||||
return
|
||||
if self.path.startswith("/history/"):
|
||||
pid = self.path[len("/history/") :]
|
||||
entry = self.server.history.get(pid)
|
||||
@@ -64,11 +77,17 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
prompt_id = uuid.uuid4().hex
|
||||
outputs_key = self.server.outputs_key
|
||||
out_entry = {
|
||||
outputs_key: [{"filename": self.server.out_name, "subfolder": "", "type": "output"}]
|
||||
outputs_key: [
|
||||
{"filename": self.server.out_name, "subfolder": "", "type": "output"}
|
||||
]
|
||||
}
|
||||
outputs = (
|
||||
{} if getattr(self.server, "drop_outputs", False) else
|
||||
{self._output_node(body): out_entry}
|
||||
)
|
||||
self.server.history[prompt_id] = {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": {"9": out_entry},
|
||||
"outputs": outputs,
|
||||
"prompt": [],
|
||||
}
|
||||
self.server.prompts.append(json.loads(body.decode("utf-8") or "{}"))
|
||||
@@ -80,6 +99,19 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def _output_node(self, body: bytes) -> str:
|
||||
forced = getattr(self.server, "forced_output_node", None)
|
||||
if forced:
|
||||
return forced
|
||||
try:
|
||||
prompt = json.loads(body.decode("utf-8") or "{}").get("prompt") or {}
|
||||
except json.JSONDecodeError:
|
||||
prompt = {}
|
||||
for nid, node in prompt.items():
|
||||
if isinstance(node, dict) and node.get("class_type") in self.KNOWN_OUTPUT_CLASSES:
|
||||
return str(nid)
|
||||
return "9"
|
||||
|
||||
def _json(self, status: int, payload: dict) -> None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
@@ -109,6 +141,9 @@ def fake_comfy():
|
||||
server.fail_prompt = False
|
||||
server.out_name = "out-1.png"
|
||||
server.outputs_key = "images"
|
||||
server.forced_output_node = None
|
||||
server.object_info = {}
|
||||
server.drop_outputs = False
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""HEL-638: unknown custom output nodes close the loop — no name-based
|
||||
auto-typing, feature-less sinks become mappable candidates, a usage choice
|
||||
alone never bypasses the output designation, and generation harvests (and
|
||||
checks) exactly the nodes the user mapped.
|
||||
|
||||
Two brand-new class names drive the cases:
|
||||
|
||||
* ``NebulaGlaxVideoVault`` — screams "video" in its name and carries a
|
||||
filename_prefix; before HEL-638 the fuzzy name match auto-typed it video
|
||||
and enabled the workflow.
|
||||
* ``QuarkMuffinDisplay`` — no save widget, no media link name; before
|
||||
HEL-638 such a graph only reported ``no_output`` with no way to map.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, migrate_db
|
||||
from tests.test_studio_generation import _real_run, _wait_done
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _vault_workflow() -> dict:
|
||||
return {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "GlaxSamplerNode",
|
||||
"inputs": {"prompt": ["1", 0], "length": 121},
|
||||
},
|
||||
"12": {
|
||||
"class_type": "NebulaGlaxVideoVault",
|
||||
"inputs": {"filename_prefix": "anim", "frames": ["7", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _muffin_workflow() -> dict:
|
||||
return {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "GlaxSamplerNode",
|
||||
"inputs": {"prompt": ["1", 0], "length": 121},
|
||||
},
|
||||
"15": {"class_type": "QuarkMuffinDisplay", "inputs": {"payload": ["7", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def _analyze(client, wf: dict) -> dict:
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": wf})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Unknown outputs stay pending — names never decide (gap 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_video_semantic_unknown_node_is_never_auto_typed(client):
|
||||
body = _analyze(client, _vault_workflow())
|
||||
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "12")
|
||||
assert entry["evidence"] == "unknown_node"
|
||||
assert entry["kind"] is None # the user picks the media type
|
||||
assert entry["suggested_kind"] == "video" # only a form default
|
||||
assert body["needs_mapping"] is True
|
||||
assert body["usage"] is None and body["usage_ambiguous"] is True
|
||||
assert "指定实际输出节点" in body["usage_problem"]
|
||||
codes = {(w["level"], w["code"]) for w in body["warnings"]}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_choosing_a_usage_alone_does_not_bypass_output_mapping(client):
|
||||
"""create/PATCH with a usage but no output map keeps the row disabled."""
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "未知·视频语义", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video", "usage_source": "user"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
|
||||
assert wf["is_enabled"] is False # usage set, outputs still unconfirmed
|
||||
assert wf["needs_mapping"] is True and not wf["output_map"]
|
||||
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
|
||||
# forcing the switch on is refused with the mapping reason
|
||||
r = client.patch(f"/api/workflows/{wf['id']}", json={"is_enabled": True})
|
||||
assert r.status_code == 400
|
||||
assert "指定" in r.json()["detail"] and "输出节点" in r.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Feature-less unknown sinks become mappable candidates (gap 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_featureless_unknown_node_is_a_candidate_not_a_hard_error(client):
|
||||
body = _analyze(client, _muffin_workflow())
|
||||
entry = next(o for o in body["usage_evidence"]["outputs"] if o["nodeId"] == "15")
|
||||
assert entry["evidence"] == "unknown_node" and entry["kind"] is None
|
||||
assert body["needs_mapping"] is True
|
||||
codes = {(w["level"], w["code"]) for w in body["warnings"]}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_truly_outputless_graph_still_reports_the_chinese_error(client):
|
||||
wf = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "p"}},
|
||||
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
||||
}
|
||||
body = _analyze(client, wf)
|
||||
assert body["usage"] is None and body["needs_mapping"] is False
|
||||
assert "未在图中找到输出节点" in body["usage_problem"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Manual mapping → enable → scoped generation (fake ComfyUI round trip)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manual_mapping_enables_and_extracts_from_the_mapped_node(client, fake_comfy):
|
||||
fake_comfy.outputs_key = "gifs"
|
||||
fake_comfy.out_name = "clip.mp4"
|
||||
fake_comfy.forced_output_node = "12" # the mapped node reports results
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·视频库",
|
||||
"workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}],
|
||||
"output_map_source": "user",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["is_enabled"] is True
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user"
|
||||
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "星空延时"},
|
||||
"source": "studio-video"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["output_paths"][0].endswith(".mp4")
|
||||
|
||||
|
||||
def test_mapped_node_returning_nothing_fails_for_real(client, fake_comfy):
|
||||
"""Results that only land on an unmapped node are NOT a completion."""
|
||||
fake_comfy.forced_output_node = "7" # something else reports results
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·空手而归",
|
||||
"workflow_json": _muffin_workflow(),
|
||||
"usage": "text2image",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "15", "kind": "image"}],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["is_enabled"] is True
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": r.json()["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "没有返回任何图片或视频" in job["error"]
|
||||
assert "15" in job["error"] # names the node it waited on
|
||||
assert job["output_paths"] == []
|
||||
|
||||
|
||||
def test_media_type_mismatch_fails_for_real(client, fake_comfy):
|
||||
"""A video mapping that only yields a png is a real failure."""
|
||||
fake_comfy.outputs_key = "images"
|
||||
fake_comfy.out_name = "still.png"
|
||||
fake_comfy.forced_output_node = "12"
|
||||
with _real_run(client, fake_comfy):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "映射·类型不符",
|
||||
"workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"usage_source": "user",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}],
|
||||
},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "媒体类型与映射不符" in job["error"]
|
||||
assert job["output_paths"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Bad / cancelled mappings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invalid_mappings_are_rejected_in_chinese(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "999", "kind": "video"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "不存在" in r.json()["detail"]
|
||||
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射2", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "audio"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "媒体类型" in r.json()["detail"]
|
||||
|
||||
# a prompt/parameter node can never be an output
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "坏映射3", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "1", "kind": "video"}]},
|
||||
)
|
||||
assert r.status_code == 422 and "不能作为输出节点" in r.json()["detail"]
|
||||
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "先好后坏", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
).json()["id"]
|
||||
r = client.patch(
|
||||
f"/api/workflows/{wid}", json={"output_map": [{"nodeId": "42", "kind": "image"}]}
|
||||
)
|
||||
assert r.status_code == 422 and "不存在" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_clearing_the_mapping_resets_to_pending(client):
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "映射后清空", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
).json()["id"]
|
||||
wf = client.get(f"/api/workflows/{wid}").json()
|
||||
assert wf["is_enabled"] is True
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"output_map": []})
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["is_enabled"] is False and not wf["output_map"]
|
||||
assert wf["usage"] == "text2video" # the usage decision survives
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Saved mappings survive reanalyze / field edits / migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_saved_mapping_survives_reanalyze_and_field_edits(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "重分析·保映射", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video",
|
||||
"output_map": [{"nodeId": "12", "kind": "video"}]},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"name": "改名不影响映射",
|
||||
"description": "编辑其他字段"})
|
||||
wf = r.json()
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user" and wf["is_enabled"] is True
|
||||
|
||||
r = client.post(f"/api/workflows/{wid}/reanalyze")
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["output_map"] == [{"nodeId": "12", "kind": "video"}]
|
||||
assert wf["output_map_source"] == "user"
|
||||
assert wf["usage"] == "text2video" and wf["usage_source"] == "user"
|
||||
assert wf["is_enabled"] is True
|
||||
|
||||
|
||||
def test_sql_forced_enabled_row_without_mapping_is_blocked(client):
|
||||
"""Backend-direct generation on an unconfirmed row is refused."""
|
||||
wid = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "强开", "workflow_json": _vault_workflow(),
|
||||
"usage": "text2video"},
|
||||
).json()["id"]
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute("UPDATE workflows SET is_enabled = 1 WHERE id = ?", (wid,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wid, "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
assert "输出节点" in gen.json()["detail"]
|
||||
|
||||
|
||||
def test_migration_parks_unknown_output_rows_and_keeps_trusted_ones(client):
|
||||
import asyncio
|
||||
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
# Pre-HEL-638 shape: user confirmed a usage on an unknown-output
|
||||
# graph and the old PATCH re-enabled it.
|
||||
conn.execute(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
||||
" output_schema, is_enabled, usage, usage_source)"
|
||||
" VALUES (?, 'video', ?, '[]', '[]', 1, 'text2video', 'user')",
|
||||
("存量·未知输出", json.dumps(_vault_workflow())),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
asyncio.run(migrate_db(settings.db_path)) # idempotent re-run
|
||||
|
||||
wfs = {w["name"]: w for w in client.get("/api/workflows").json()}
|
||||
parked = wfs["存量·未知输出"]
|
||||
assert parked["is_enabled"] is False
|
||||
assert parked["usage"] == "text2video" # decision preserved
|
||||
assert not parked["output_map"] and parked["needs_mapping"] is True
|
||||
|
||||
# A trusted five-type import keeps working untouched.
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "存量·可信", "kind": "image",
|
||||
"workflow_json": json.loads(
|
||||
(FIXTURES / "qwen_image_t2i_API.json").read_text(encoding="utf-8")
|
||||
)},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
trusted = next(w for w in client.get("/api/workflows").json() if w["id"] == r.json()["id"])
|
||||
assert trusted["is_enabled"] is True
|
||||
assert trusted["output_map_source"] == "auto"
|
||||
assert any(e["nodeId"] == "60" and e["kind"] == "image" for e in trusted["output_map"])
|
||||
@@ -0,0 +1,289 @@
|
||||
"""HEL-623: workflows exported straight from ComfyUI (no (Input:…) title tags)
|
||||
still expose a prompt and their fillable widgets, patch back onto the right
|
||||
widget, and run the full 导入→填参→生成→历史 chain against a fake ComfyUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.smart_fill import ref_image_slots
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db
|
||||
from tests.test_studio_generation import ( # noqa: F401 — fixture re-export
|
||||
_import_workflow,
|
||||
_real_run,
|
||||
_wait_done,
|
||||
fake_comfy,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _by_role(inputs: list[dict]) -> dict[str, dict]:
|
||||
return {i["role"]: i for i in inputs if i.get("role")}
|
||||
|
||||
|
||||
def test_native_qwen_t2i_exposes_prompt_and_sampler_widgets():
|
||||
inputs = parse_dynamic_inputs(_load("qwen_image_t2i_API.json"))
|
||||
roles = _by_role(inputs)
|
||||
assert roles["prompt"]["nodeId"] == "6"
|
||||
assert roles["prompt"]["required"] is True
|
||||
assert roles["prompt"]["defaultValue"].startswith("一只橘猫")
|
||||
assert roles["negative"]["nodeId"] == "7"
|
||||
assert roles["seed"]["nodeId"] == "3@seed"
|
||||
assert roles["steps"]["nodeId"] == "3@steps"
|
||||
assert roles["cfg"]["nodeId"] == "3@cfg"
|
||||
assert roles["width"]["nodeId"] == "58@width"
|
||||
assert roles["height"]["nodeId"] == "58@height"
|
||||
assert roles["batch"]["nodeId"] == "58@batch_size"
|
||||
assert all(i["auto"] for i in inputs)
|
||||
assert [i for i in inputs if i["required"]] == [roles["prompt"]]
|
||||
|
||||
|
||||
def test_native_qwen_edit_detects_image_and_mask_slots():
|
||||
inputs = parse_dynamic_inputs(_load("qwen_image_edit_mask_API.json"))
|
||||
roles = _by_role(inputs)
|
||||
# TextEncodeQwenImageEditPlus writes `prompt`, not the class-derived field
|
||||
assert roles["prompt"]["nodeId"] == "76@prompt"
|
||||
assert roles["negative"]["nodeId"] == "77@prompt"
|
||||
media = {i["nodeId"]: i for i in inputs if i["kind"] == "image"}
|
||||
assert set(media) == {"78", "95"}
|
||||
assert media["95"]["role"] == "mask"
|
||||
assert "mask" in media["95"]["label"].lower() or "遮罩" in media["95"]["label"]
|
||||
# HEL-633: a wired input image must be uploaded (the export's own file name
|
||||
# rarely exists on the target ComfyUI); the mask stays optional
|
||||
assert media["78"]["required"] and not media["95"]["required"]
|
||||
|
||||
|
||||
def test_untagged_h3_video_detects_prompt_duration_seed():
|
||||
inputs = parse_dynamic_inputs(_load("minimax_h3_t2v_untagged_API.json"))
|
||||
roles = _by_role(inputs)
|
||||
assert roles["prompt"]["nodeId"] == "6005"
|
||||
assert roles["duration"]["nodeId"] == "221"
|
||||
assert roles["seed"]["nodeId"] == "217@noise_seed"
|
||||
|
||||
|
||||
def test_real_qwen21_t2i_splits_single_encoder_into_both_prompts():
|
||||
"""白栖知的 wen-sheng-tu.json:一个 TextEncodeQwenImage21 同时喂 positive/negative."""
|
||||
wf = _load("wen_sheng_tu_API.json")
|
||||
inputs = parse_dynamic_inputs(wf)
|
||||
roles = _by_role(inputs)
|
||||
assert roles["prompt"]["nodeId"] == "459:452@prompt"
|
||||
assert roles["prompt"]["label"] == "正向提示词"
|
||||
assert roles["prompt"]["required"] is True
|
||||
assert roles["prompt"]["defaultValue"].startswith("Greyscale fashion editorial")
|
||||
assert roles["negative"]["nodeId"] == "459:452@negative_prompt"
|
||||
assert roles["negative"]["label"] == "反向提示词"
|
||||
assert roles["negative"]["required"] is False
|
||||
assert roles["seed"]["nodeId"] == "459:458@seed"
|
||||
assert roles["steps"]["nodeId"] == "459:458@steps"
|
||||
assert roles["cfg"]["nodeId"] == "459:458@cfg"
|
||||
assert roles["batch"]["nodeId"] == "459:456@batch_size"
|
||||
# width/height are wired from the resolution selector — not exposed
|
||||
assert "width" not in roles and "height" not in roles
|
||||
assert [i for i in inputs if i["required"]] == [roles["prompt"]]
|
||||
|
||||
patched = patch_workflow(
|
||||
wf, {roles["prompt"]["nodeId"]: "新正向", roles["negative"]["nodeId"]: "模糊"}
|
||||
)
|
||||
enc = patched["459:452"]["inputs"]
|
||||
assert enc["prompt"] == "新正向"
|
||||
assert enc["negative_prompt"] == "模糊"
|
||||
assert enc["clip"] == ["459:453", 0]
|
||||
|
||||
|
||||
def test_real_h3_t2v_duration_from_expression_primitive():
|
||||
"""wen-sheng-shipin.json:无标题 PrimitiveFloat 经表达式换算帧数 → 时长(秒)."""
|
||||
inputs = parse_dynamic_inputs(_load("wen_sheng_shipin_API.json"))
|
||||
roles = _by_role(inputs)
|
||||
assert roles["prompt"]["nodeId"] == "1"
|
||||
assert roles["prompt"]["required"] is True
|
||||
assert roles["duration"]["nodeId"] == "133"
|
||||
assert roles["duration"]["label"] == "时长(秒)"
|
||||
assert roles["duration"]["defaultValue"] == 10
|
||||
assert roles["seed"]["nodeId"] == "129@noise_seed"
|
||||
assert roles["steps"]["nodeId"] == "124@steps"
|
||||
assert "negative" not in roles
|
||||
|
||||
|
||||
def _encoder_graph(encoder: dict, positive_slot: list, negative_slot: list) -> dict:
|
||||
return {
|
||||
"1": encoder,
|
||||
"2": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"seed": 1, "steps": 20, "cfg": 4, "positive": positive_slot,
|
||||
"negative": negative_slot},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_custom_encoder_with_text_and_negative_prompt_is_split():
|
||||
wf = _encoder_graph(
|
||||
{"class_type": "MyTextEncodeDual", "inputs": {"text": "猫", "negative_prompt": "糊"},
|
||||
"_meta": {"title": "MyTextEncodeDual"}},
|
||||
["1", 0],
|
||||
["1", 1],
|
||||
)
|
||||
roles = _by_role(parse_dynamic_inputs(wf))
|
||||
assert roles["prompt"]["nodeId"] == "1"
|
||||
assert roles["prompt"]["defaultValue"] == "猫"
|
||||
assert roles["negative"]["nodeId"] == "1@negative_prompt"
|
||||
|
||||
|
||||
def test_clip_encoder_wired_to_both_positive_and_negative_stays_positive():
|
||||
wf = _encoder_graph(
|
||||
{"class_type": "CLIPTextEncode", "inputs": {"text": "猫"},
|
||||
"_meta": {"title": "CLIP Text Encode"}},
|
||||
["1", 0],
|
||||
["1", 0],
|
||||
)
|
||||
roles = _by_role(parse_dynamic_inputs(wf))
|
||||
assert roles["prompt"]["nodeId"] == "1"
|
||||
assert "negative" not in roles
|
||||
|
||||
|
||||
def test_tagged_workflows_are_unchanged():
|
||||
for path in sorted(EXAMPLES.glob("*.json")):
|
||||
inputs = parse_dynamic_inputs(json.loads(path.read_text(encoding="utf-8")))
|
||||
assert inputs, path.name
|
||||
assert not any(i.get("auto") for i in inputs), path.name
|
||||
assert all("@" not in i["nodeId"] for i in inputs), path.name
|
||||
|
||||
|
||||
def test_patch_field_keys_write_named_widget_and_keep_links():
|
||||
wf = _load("qwen_image_t2i_API.json")
|
||||
patched = patch_workflow(
|
||||
wf,
|
||||
{"6": "新的提示词", "3@seed": 7, "3@steps": 12, "58@width": 1024, "3@model": "x"},
|
||||
)
|
||||
assert patched["6"]["inputs"]["text"] == "新的提示词"
|
||||
assert patched["3"]["inputs"]["seed"] == 7
|
||||
assert patched["3"]["inputs"]["steps"] == 12
|
||||
assert patched["58"]["inputs"]["width"] == 1024
|
||||
# linked input stays wired; the base workflow is untouched
|
||||
assert patched["3"]["inputs"]["model"] == ["66", 0]
|
||||
assert wf["3"]["inputs"]["seed"] == 1125488487853216
|
||||
|
||||
|
||||
def test_smart_fill_sort_accepts_subgraph_and_field_ids():
|
||||
slots = ref_image_slots(
|
||||
[
|
||||
{"nodeId": "171:12", "role": "image", "kind": "image"},
|
||||
{"nodeId": "9", "role": "image", "kind": "image"},
|
||||
{"nodeId": "3@image", "role": "image", "kind": "image"},
|
||||
]
|
||||
)
|
||||
assert [s["nodeId"] for s in slots] == ["3@image", "9", "171:12"]
|
||||
|
||||
|
||||
def test_list_backfills_schema_for_workflows_imported_before_detection(client):
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO workflows"
|
||||
" (name, kind, workflow_json, input_schema, output_schema, is_enabled)"
|
||||
" VALUES (?, 'image', ?, '[]', '[]', 1)",
|
||||
("旧导入·Qwen", json.dumps(_load("qwen_image_t2i_API.json"))),
|
||||
)
|
||||
conn.commit()
|
||||
wf_id = cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
wf = client.get(f"/api/workflows/{wf_id}").json()
|
||||
assert _by_role(wf["input_schema"])["prompt"]["nodeId"] == "6"
|
||||
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
|
||||
assert len(listed["input_schema"]) == len(wf["input_schema"])
|
||||
|
||||
|
||||
def test_native_workflow_full_chain(client, fake_comfy): # noqa: F811
|
||||
"""导入原生 Qwen 工作流 → 填参 → 生成 → ComfyUI 收到改写后的节点 → 历史可见."""
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _import_workflow(
|
||||
client, "原生·Qwen 文生图", "image", _load("qwen_image_t2i_API.json")
|
||||
)
|
||||
wf = client.get(f"/api/workflows/{wf_id}").json()
|
||||
roles = _by_role(wf["input_schema"])
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": {
|
||||
roles["prompt"]["nodeId"]: "雨夜霓虹街道",
|
||||
roles["seed"]["nodeId"]: 99,
|
||||
roles["width"]["nodeId"]: 1024,
|
||||
roles["height"]["nodeId"]: 768,
|
||||
},
|
||||
"source": "studio-image",
|
||||
"endpoint_id": settings.comfyui_active_id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
assert queued["6"]["inputs"]["text"] == "雨夜霓虹街道"
|
||||
assert queued["3"]["inputs"]["seed"] == 99
|
||||
assert queued["58"]["inputs"]["width"] == 1024
|
||||
assert queued["58"]["inputs"]["height"] == 768
|
||||
assert queued["3"]["inputs"]["positive"] == ["6", 0]
|
||||
history = client.get("/api/playground/jobs").json()
|
||||
assert any(j["id"] == job["id"] and j["status"] == "done" for j in history)
|
||||
|
||||
|
||||
def test_real_qwen21_t2i_full_chain(client, fake_comfy): # noqa: F811
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _import_workflow(
|
||||
client, "真实·Qwen2.1 文生图", "image", _load("wen_sheng_tu_API.json")
|
||||
)
|
||||
roles = _by_role(client.get(f"/api/workflows/{wf_id}").json()["input_schema"])
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={
|
||||
"workflow_id": wf_id,
|
||||
"input_values": {
|
||||
roles["prompt"]["nodeId"]: "雨夜霓虹街道",
|
||||
roles["negative"]["nodeId"]: "低清晰度",
|
||||
roles["seed"]["nodeId"]: 7,
|
||||
},
|
||||
"source": "studio-image",
|
||||
"endpoint_id": settings.comfyui_active_id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
assert queued["459:452"]["inputs"]["prompt"] == "雨夜霓虹街道"
|
||||
assert queued["459:452"]["inputs"]["negative_prompt"] == "低清晰度"
|
||||
assert queued["459:458"]["inputs"]["seed"] == 7
|
||||
assert queued["459:458"]["inputs"]["negative"] == ["459:452", 1]
|
||||
|
||||
|
||||
def test_comfy_rejection_is_reported_in_chinese(client, fake_comfy): # noqa: F811
|
||||
fake_comfy.fail_prompt = True
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _import_workflow(
|
||||
client, "原生·Qwen(拒绝)", "image", _load("qwen_image_t2i_API.json")
|
||||
)
|
||||
r = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf_id, "input_values": {"6": "x"}, "source": "studio-image"},
|
||||
)
|
||||
job = _wait_done(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "ComfyUI 拒绝执行该工作流" in job["error"]
|
||||
assert "HTTP 500" in job["error"]
|
||||
|
||||
|
||||
def test_generate_unknown_workflow_has_chinese_detail(client):
|
||||
r = client.post("/api/playground/generate", json={"workflow_id": 987654, "input_values": {}})
|
||||
assert r.status_code == 404
|
||||
assert "工作流不存在" in r.json()["detail"]
|
||||
@@ -0,0 +1,308 @@
|
||||
"""HEL-636 A1–A6: the five-usage classifier judges from graph evidence only.
|
||||
|
||||
Every case feeds real (or minimally mutated) ComfyUI API exports — never a
|
||||
file name, model name or fixed node id — and asserts the usage, the media
|
||||
slots it implies, and the failure paths for ambiguous / unknown graphs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.parser import (
|
||||
classify_usage,
|
||||
detect_untagged_inputs,
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
|
||||
def _load(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _rename_everything(wf: dict, id_shift: int = 7) -> dict:
|
||||
"""Change every node id, title and widget value the classifier must not
|
||||
depend on (model file names / prompt texts)."""
|
||||
def new_id(nid: str) -> str:
|
||||
return f"x{int(nid) * id_shift + 3}" if nid.isdigit() else "x" + nid.replace(":", "_")
|
||||
|
||||
out: dict = {}
|
||||
for nid, node in wf.items():
|
||||
out[new_id(nid)] = json.loads(json.dumps(node))
|
||||
for node in out.values():
|
||||
for val in (node.get("inputs") or {}).values():
|
||||
if isinstance(val, list) and len(val) == 2 and str(val[0]).isdigit():
|
||||
val[0] = new_id(str(val[0]))
|
||||
if node.get("_meta", {}).get("title"):
|
||||
node["_meta"]["title"] = f"改·{node['_meta']['title']}"
|
||||
for key, val in list((node.get("inputs") or {}).items()):
|
||||
if isinstance(val, str) and val.endswith((".safetensors", ".gguf")):
|
||||
node["inputs"][key] = "另一个模型_" + val
|
||||
return out
|
||||
|
||||
|
||||
def _media(inputs: list[dict]) -> list[dict]:
|
||||
return [
|
||||
i
|
||||
for i in inputs
|
||||
if i["kind"] in ("image", "image_list", "video", "audio")
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A1 / A2 / A3: the three real shapes, plus id/title/model renames
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a1_native_t2i_is_text2image_and_form_has_no_image_slot():
|
||||
wf = _load(FIXTURES / "qwen_image_t2i_API.json")
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "text2image"
|
||||
assert c["ambiguous"] is False and c["needs_mapping"] is False
|
||||
assert _media(parse_dynamic_inputs(wf)) == []
|
||||
outs = parse_dynamic_outputs(wf)
|
||||
assert [(o["nodeId"], o["kind"]) for o in outs] == [("60", "image")]
|
||||
|
||||
|
||||
def test_a2_qwen_edit_variant_with_new_ids_titles_and_model_stays_image2image():
|
||||
wf = _rename_everything(_load(FIXTURES / "qwen_image_edit_mask_API.json"))
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "image2image"
|
||||
inputs = parse_dynamic_inputs(wf)
|
||||
img = [i for i in _media(inputs) if i.get("role") == "image"]
|
||||
assert len(img) == 1 and img[0]["required"] is True
|
||||
mask = [i for i in _media(inputs) if i.get("role") == "mask"]
|
||||
assert len(mask) == 1 # mask never counts as an input image
|
||||
|
||||
|
||||
def test_a3_i2v_variant_renamed_still_image2video_first_frame_only():
|
||||
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
|
||||
assert blocking_problem(wf) is None
|
||||
|
||||
|
||||
def test_a4_ref2va_variant_with_renamed_class_and_prefix_still_groups():
|
||||
wf = _rename_everything(_load(FIXTURES / "h3_ref2va_05_API.json"))
|
||||
for node in wf.values():
|
||||
if node.get("class_type") == "MiniMaxH3ReferenceToVideo":
|
||||
node["class_type"] = "MyFancyRefToVideo"
|
||||
inputs = node["inputs"]
|
||||
for k in list(inputs):
|
||||
if k.startswith("ref_images.ref_image_"):
|
||||
inputs[k.replace("ref_images.ref_image_", "refs.pic_")] = inputs.pop(k)
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "ref2video"
|
||||
groups = [i for i in detect_untagged_inputs(wf) if i["kind"] == "image_list"]
|
||||
assert len(groups) == 1
|
||||
g = groups[0]
|
||||
assert g["role"] == "reference_image" and (g["min"], g["max"]) == (1, 9)
|
||||
# patch rewires the renamed group in order and refuses a 10th image
|
||||
key = g["nodeId"]
|
||||
node_id = key.split("@")[0]
|
||||
patched = patch_workflow(wf, {key: [f"/u/{i}.png" for i in range(4)]})
|
||||
names = sorted(
|
||||
(k for k in patched[node_id]["inputs"] if k.startswith("refs.pic_")),
|
||||
key=lambda k: int(k.rsplit("_", 1)[-1]) if k.rsplit("_", 1)[-1].isdigit() else int(k[-1]),
|
||||
)
|
||||
assert names == [f"refs.pic_{i}" for i in range(4)]
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
|
||||
_c, errs = validate_input_values(
|
||||
detect_untagged_inputs(wf), {key: [f"/u/{i}.png" for i in range(10)]}
|
||||
)
|
||||
assert any("最多 9 张" in e for e in errs)
|
||||
|
||||
|
||||
def test_qwen_single_numbered_encoder_input_is_not_mistaken_for_a_group():
|
||||
"""TextEncodeQwenImage21's ``images.image_1`` stays an ordinary input slot."""
|
||||
inputs = parse_dynamic_inputs(_load(FIXTURES / "qwen21_edit_sage_API.json"))
|
||||
assert not any(i["kind"] == "image_list" for i in inputs)
|
||||
assert any(i.get("role") == "image" for i in inputs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Judgment table rows from the real example library
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_all_real_exports_classify():
|
||||
expect = {
|
||||
"qwen_image_t2i_API.json": "text2image",
|
||||
"qwen_image_edit_mask_API.json": "image2image",
|
||||
"qwen21_edit_sage_API.json": "image2image",
|
||||
"h3_i2v_04_API.json": "image2video",
|
||||
"h3_ref2va_05_API.json": "ref2video",
|
||||
"minimax_h3_t2v_untagged_API.json": "text2video",
|
||||
"wen_sheng_tu_API.json": "text2image",
|
||||
"wen_sheng_shipin_API.json": "text2video",
|
||||
}
|
||||
for name, usage in expect.items():
|
||||
assert classify_usage(_load(FIXTURES / name))["usage"] == usage, name
|
||||
examples = {
|
||||
"Krea2_t2i_20260818_API.json": "text2image",
|
||||
"krea2_T2I_CharSheetV1_API.json": "text2image",
|
||||
"minimax_h3-Turbo_Text2Video_20260813_API.json": "text2video",
|
||||
"minimax_h3-Turbo_Image2Video_20260813_API.json": "text2video",
|
||||
"MiniMax-H3-Turbo-R2V-Extend-2Refs1Vid_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_2ref_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_3ref_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_4ref_API.json": "ref2video",
|
||||
"video_minimax_h3_r2v_5ref_API.json": "ref2video",
|
||||
}
|
||||
for name, usage in examples.items():
|
||||
assert classify_usage(_load(EXAMPLES / name))["usage"] == usage, name
|
||||
|
||||
|
||||
def test_r2v_extend_reports_its_video_input_slot():
|
||||
wf = _load(EXAMPLES / "MiniMax-H3-Turbo-R2V-Extend-2Refs1Vid_API.json")
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "ref2video"
|
||||
assert c["evidence"]["video_slots"], "the reference-video slot must be reported"
|
||||
assert any("视频输入槽" in line for line in c["evidence"]["summary"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A5: ambiguity — single loose image into a video graph; multi-output graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_image(nid: str) -> dict:
|
||||
return {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {"image": "x.png"},
|
||||
"_meta": {"title": f"图 {nid}"},
|
||||
}
|
||||
|
||||
|
||||
def test_single_loose_image_with_video_output_is_ambiguous():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] is None and c["ambiguous"] is True
|
||||
assert "首帧还是参考图" in c["problem"]
|
||||
|
||||
|
||||
def test_first_frame_role_decides_image2video_without_ambiguity():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "first_frame": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
assert classify_usage(wf)["usage"] == "image2video"
|
||||
|
||||
|
||||
def test_same_graph_both_image_and_video_outputs_is_ambiguous():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
"10": {"class_type": "SaveImage", "inputs": {"images": ["7", 1]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] is None and c["ambiguous"] is True
|
||||
assert "同时输出图片和视频" in c["problem"]
|
||||
|
||||
|
||||
def test_two_reference_images_into_a_custom_node_become_a_group_ref2video():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "a.png"}},
|
||||
"6": {"class_type": "LoadImage", "inputs": {"image": "b.png"}},
|
||||
"7": {
|
||||
"class_type": "GenericRefToVideo",
|
||||
"inputs": {
|
||||
"prompt": ["1", 0],
|
||||
"refs.pic_1": ["5", 0],
|
||||
"refs.pic_2": ["6", 0],
|
||||
},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "ref2video"
|
||||
groups = [i for i in detect_untagged_inputs(wf) if i["kind"] == "image_list"]
|
||||
assert [g["nodeId"] for g in groups] == ["7@refs"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A6: failure paths — no output, unknown custom output nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_output_graph_reports_chinese_error_and_blocks():
|
||||
wf = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "p"}},
|
||||
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] is None and c["ambiguous"] is False
|
||||
assert "未在图中找到输出节点" in c["problem"]
|
||||
assert blocking_problem(wf) and "未在图中找到输出节点" in blocking_problem(wf)
|
||||
|
||||
|
||||
def test_unknown_custom_save_node_needs_mapping_and_keeps_a_runnable_path():
|
||||
wf = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"7": {
|
||||
"class_type": "MyCloudExporter",
|
||||
"inputs": {"filename_prefix": "out", "render": ["8", 0]},
|
||||
},
|
||||
"8": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["needs_mapping"] is True
|
||||
assert c["usage"] is None and "无法识别" in (c["problem"] or "")
|
||||
# unknown savers are a warning (user maps them at import), not a block —
|
||||
# once the user confirms the usage the job can still run and results are
|
||||
# pulled from ComfyUI history's images/gifs/videos keys.
|
||||
codes = {(w["level"], w["code"]) for w in workflow_warnings(wf)}
|
||||
assert ("warning", "unknown_output_node") in codes
|
||||
assert ("error", "no_output") not in codes
|
||||
|
||||
|
||||
def test_utility_sink_tail_is_not_an_output_and_does_not_break_detection():
|
||||
"""VideoCombine feeding a cache-cleanup tail still counts as the output."""
|
||||
wf = _load(FIXTURES / "minimax_h3_t2v_untagged_API.json")
|
||||
wf["9999"] = {
|
||||
"class_type": "easy cleanGpuUsed",
|
||||
"inputs": {"anything": ["5978", 0]},
|
||||
}
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "text2video"
|
||||
assert c["needs_mapping"] is False
|
||||
assert workflow_warnings(wf) == []
|
||||
|
||||
|
||||
def test_evidence_lists_output_nodes_and_slots():
|
||||
wf = _load(FIXTURES / "h3_ref2va_05_API.json")
|
||||
c = classify_usage(wf)
|
||||
ev = c["evidence"]
|
||||
assert any(o["nodeId"] == "92" and o["kind"] == "video" for o in ev["outputs"])
|
||||
assert any(s["kind"] == "image_list" for s in ev["image_slots"])
|
||||
assert any("输出" in line for line in ev["summary"])
|
||||
@@ -26,6 +26,15 @@ VIDEO_WORKFLOW = {
|
||||
"inputs": {"file": ""},
|
||||
"_meta": {"title": "Previous Clip (Input:video)"},
|
||||
},
|
||||
"30": {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {"images": ["40", 0]},
|
||||
"_meta": {"title": "Video Output (Output:video)"},
|
||||
},
|
||||
"40": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["10", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
# Same but the video slot is missing entirely — must be rejected at enqueue.
|
||||
@@ -35,6 +44,15 @@ NO_VIDEO_WORKFLOW = {
|
||||
"inputs": {"text": ""},
|
||||
"_meta": {"title": "Main Prompt (Input:prompt)"},
|
||||
},
|
||||
"30": {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {"images": ["40", 0]},
|
||||
"_meta": {"title": "Video Output (Output:video)"},
|
||||
},
|
||||
"40": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["10", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -250,12 +268,14 @@ def _scene_ids(pid: int) -> list[int]:
|
||||
|
||||
|
||||
async def _fake_history(_client, _prompt_id, *_args):
|
||||
"""A successful ComfyUI history with one video output named out.mp4.
|
||||
Extra positional args (job_id) are accepted for the cancel-check wiring."""
|
||||
"""A successful ComfyUI history with one video output named out.mp4,
|
||||
reported under the workflow's real output node (30 = the tagged
|
||||
VHS_VideoCombine). Extra positional args (job_id) are accepted for the
|
||||
cancel-check wiring."""
|
||||
return {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": {
|
||||
"99": {"videos": [{"filename": "out.mp4", "subfolder": "", "type": "output"}]}
|
||||
"30": {"videos": [{"filename": "out.mp4", "subfolder": "", "type": "output"}]}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -269,7 +289,11 @@ def _stub_comfy(monkeypatch, captured: dict) -> list[str]:
|
||||
prepare_media_inputs handed it; download_image writes out.mp4. Returns
|
||||
the list that collects uploaded local paths.
|
||||
"""
|
||||
queue_manager.paused = True
|
||||
# monkeypatch (not bare assignment): queue_manager is a process-level
|
||||
# singleton — a bare `paused = True` leaks into every later test and
|
||||
# stalls e.g. test_workbench's worker (HEL-619). monkeypatch restores
|
||||
# the previous value at teardown.
|
||||
monkeypatch.setattr(queue_manager, "paused", True)
|
||||
uploads: list[str] = []
|
||||
|
||||
async def fake_upload_video(self, path, subfolder="calliope"):
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""HEL-636: the two-step import closes the loop — analyze → confirm → save,
|
||||
user corrections stick (reanalyze never overwrites them), pending workflows
|
||||
cannot generate, and legacy rows migrate losslessly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, migrate_db
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
AMBIGUOUS = {
|
||||
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "p"}},
|
||||
"5": {"class_type": "LoadImage", "inputs": {"image": "x.png"}},
|
||||
"7": {
|
||||
"class_type": "SomeVideoSampler",
|
||||
"inputs": {"prompt": ["1", 0], "image": ["5", 0]},
|
||||
},
|
||||
"9": {"class_type": "SaveVideo", "inputs": {"images": ["7", 0]}},
|
||||
}
|
||||
|
||||
|
||||
def test_analyze_returns_usage_evidence_and_problem(client):
|
||||
wf = _load("qwen_image_t2i_API.json")
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": wf})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["usage"] == "text2image"
|
||||
assert body["usage_ambiguous"] is False and body["needs_mapping"] is False
|
||||
assert any(o["kind"] == "image" for o in body["usage_evidence"]["outputs"])
|
||||
assert body["usable_usage"] == [
|
||||
"text2image", "image2image", "text2video", "image2video", "ref2video",
|
||||
]
|
||||
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": AMBIGUOUS})
|
||||
body = r.json()
|
||||
assert body["usage"] is None and body["usage_ambiguous"] is True
|
||||
assert "首帧还是参考图" in body["usage_problem"]
|
||||
assert body["usage_evidence"]["image_slots"][0]["nodeId"] == "5"
|
||||
|
||||
bare = {"1": {"class_type": "Note", "inputs": {}}}
|
||||
r = client.post("/api/workflows/analyze", json={"workflow_json": bare})
|
||||
body = r.json()
|
||||
assert "未在图中找到输出节点" in body["usage_problem"]
|
||||
|
||||
|
||||
def test_create_stores_auto_usage_and_kind(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "自动·文生图", "workflow_json": _load("qwen_image_t2i_API.json")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "text2image" and wf["usage_source"] == "auto"
|
||||
assert wf["kind"] == "image" and wf["is_enabled"] is True
|
||||
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "自动·参考生视频", "workflow_json": _load("h3_ref2va_05_API.json")},
|
||||
)
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "ref2video" and wf["kind"] == "video"
|
||||
|
||||
|
||||
def test_ambiguous_import_stays_pending_and_cannot_generate(client):
|
||||
r = client.post("/api/workflows", json={"name": "歧义", "workflow_json": AMBIGUOUS})
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] is None and wf["is_enabled"] is False
|
||||
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"1": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400
|
||||
assert "已停用" in gen.json()["detail"]
|
||||
assert client.get("/api/playground/jobs").json() == []
|
||||
|
||||
|
||||
def test_confirm_page_correction_roundtrip(client):
|
||||
"""analyze → user picks usage + fixes the slot role → create carries both."""
|
||||
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": AMBIGUOUS}).json()
|
||||
assert analyzed["usage"] is None
|
||||
|
||||
slot = analyzed["usage_evidence"]["image_slots"][0]
|
||||
schema = [
|
||||
{**i, "role": "first_frame", "manual": True} if i["nodeId"] == slot["nodeId"] else i
|
||||
for i in analyzed["inputs"]
|
||||
]
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "确认·图生视频",
|
||||
"workflow_json": AMBIGUOUS,
|
||||
"usage": "image2video",
|
||||
"usage_source": "user",
|
||||
"input_schema": schema,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "image2video" and wf["usage_source"] == "user"
|
||||
assert wf["kind"] == "video" and wf["is_enabled"] is True
|
||||
# the manual role sticks on read (and through re-derivation)
|
||||
entry = next(i for i in wf["input_schema"] if i["nodeId"] == slot["nodeId"])
|
||||
assert entry["role"] == "first_frame"
|
||||
|
||||
# reanalyze refreshes the auto schema but never the user's usage
|
||||
r = client.post(f"/api/workflows/{wf['id']}/reanalyze")
|
||||
assert r.status_code == 200
|
||||
fresh = r.json()
|
||||
assert fresh["usage"] == "image2video" and fresh["usage_source"] == "user"
|
||||
entry = next(i for i in fresh["input_schema"] if i["nodeId"] == slot["nodeId"])
|
||||
assert entry["role"] == "first_frame" and entry.get("manual") is True
|
||||
|
||||
|
||||
def test_patch_usage_confirms_pending_workflow(client):
|
||||
r = client.post("/api/workflows", json={"name": "待确认", "workflow_json": AMBIGUOUS})
|
||||
wid = r.json()["id"]
|
||||
assert r.json()["is_enabled"] is False
|
||||
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"usage": "ref2video"})
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "ref2video" and wf["usage_source"] == "user"
|
||||
assert wf["kind"] == "video" and wf["is_enabled"] is True
|
||||
|
||||
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wid)
|
||||
assert listed["usage"] == "ref2video"
|
||||
|
||||
|
||||
def test_reanalyze_upgrades_auto_usage_only(client):
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "重分析", "workflow_json": _load("qwen_image_t2i_API.json")},
|
||||
)
|
||||
wid = r.json()["id"]
|
||||
r = client.patch(f"/api/workflows/{wid}", json={"usage": "image2image", "usage_source": "user"})
|
||||
assert r.json()["usage"] == "image2image"
|
||||
|
||||
r = client.post(f"/api/workflows/{wid}/reanalyze")
|
||||
assert r.json()["usage"] == "image2image" # user decision survives
|
||||
assert r.json()["usage_source"] == "user"
|
||||
|
||||
|
||||
def test_legacy_kind_only_callers_still_work(client):
|
||||
"""Old clients post kind without usage — determinate graphs classify, the
|
||||
row is enabled and generation proceeds as before."""
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "旧调用", "kind": "image", "workflow_json": _load("qwen_image_t2i_API.json")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "text2image" and wf["is_enabled"] is True
|
||||
|
||||
|
||||
def test_migration_backfills_usage_and_keeps_history(client):
|
||||
"""A8: pre-HEL-636 rows (direct SQL, no usage) settle on re-migration;
|
||||
determinate ones gain usage, ambiguous ones are parked disabled, and
|
||||
existing jobs/history stay readable with their original kind."""
|
||||
pid = client.post("/api/projects", json={"title": "存量项目", "idea": "x"}).json()["id"]
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
||||
" output_schema, is_enabled) VALUES (?, 'image', ?, '[]', '[]', 1)",
|
||||
("存量·文生图", json.dumps(_load("qwen_image_t2i_API.json"))),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, input_schema,"
|
||||
" output_schema, is_enabled) VALUES (?, 'video', ?, '[]', '[]', 1)",
|
||||
("存量·歧义", json.dumps(AMBIGUOUS)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO jobs (project_id, kind, workflow_id, status, payload_json,"
|
||||
" output_paths_json) VALUES (?, 'image', NULL, 'done', '{}', ?)",
|
||||
(pid, json.dumps(["/old/result.png"])),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(migrate_db(settings.db_path)) # idempotent re-run
|
||||
|
||||
wfs = {w["name"]: w for w in client.get("/api/workflows").json()}
|
||||
assert wfs["存量·文生图"]["usage"] == "text2image"
|
||||
assert wfs["存量·文生图"]["is_enabled"] is True
|
||||
assert wfs["存量·歧义"]["usage"] is None
|
||||
assert wfs["存量·歧义"]["is_enabled"] is False
|
||||
assert wfs["存量·歧义"]["kind"] == "video" # old kind semantics untouched
|
||||
|
||||
jobs = client.get(f"/api/jobs?project_id={pid}").json()
|
||||
assert any(
|
||||
j["status"] == "done" and j["kind"] == "image" and j["output_paths"] == ["/old/result.png"]
|
||||
for j in jobs
|
||||
)
|
||||
|
||||
# a third run changes nothing (idempotent)
|
||||
asyncio.run(migrate_db(settings.db_path))
|
||||
wfs2 = {w["name"]: w for w in client.get("/api/workflows").json()}
|
||||
assert wfs2["存量·文生图"]["usage"] == "text2image"
|
||||
assert wfs2["存量·歧义"]["usage"] is None
|
||||
|
||||
|
||||
def test_broken_qwen_import_stays_blocked_but_recorded(client):
|
||||
"""The damaged Qwen export classifies fine (image2image); its structural
|
||||
lint errors still refuse generation — a broken file case, not a usage case."""
|
||||
wf_json = _load("qwen21_edit_sage_API.json")
|
||||
r = client.post(
|
||||
"/api/workflows/analyze", json={"workflow_json": wf_json}
|
||||
)
|
||||
assert r.json()["usage"] == "image2image"
|
||||
r = client.post(
|
||||
"/api/workflows", json={"name": "损坏·Qwen", "kind": "image", "workflow_json": wf_json}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
wf = r.json()
|
||||
assert wf["usage"] == "image2image" and wf["is_enabled"] is True
|
||||
assert any(w["level"] == "error" for w in wf["warnings"])
|
||||
gen = client.post(
|
||||
"/api/playground/generate",
|
||||
json={"workflow_id": wf["id"], "input_values": {"4@prompt": "x"}},
|
||||
)
|
||||
assert gen.status_code == 400 and "重新导出" in gen.json()["detail"]
|
||||
@@ -0,0 +1,272 @@
|
||||
"""L2 连接核查(HEL-643 批次 1):导入/复查时用目标 ComfyUI 的 /object_info
|
||||
预检节点与模型可用性。全部基于 fake ComfyUI 的脚本化 /object_info,
|
||||
不针对任何固定 JSON 或模型名写死判定。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.client import clear_object_info_cache
|
||||
from calliope.comfyui.preflight import check_workflow_against
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import _url
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _load_05() -> dict:
|
||||
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def build_object_info(workflow: dict) -> dict:
|
||||
"""A catalogue where every widget value the JSON carries is legal.
|
||||
|
||||
String widgets become combos whose candidates include the workflow's own
|
||||
value (merged across nodes of the same class); numbers/booleans are typed
|
||||
inputs; wired inputs are link types — none of those can mismatch.
|
||||
"""
|
||||
info: dict = {}
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
ct = str(node.get("class_type") or "")
|
||||
if not ct:
|
||||
continue
|
||||
spec = info.setdefault(ct, {"input": {"required": {}}, "output": ["*"]})
|
||||
required = spec["input"]["required"]
|
||||
for name, val in (node.get("inputs") or {}).items():
|
||||
if isinstance(val, str):
|
||||
# Merge: two nodes of one class with different values → both legal.
|
||||
if name in required and isinstance(required[name][0], list):
|
||||
if val not in required[name][0]:
|
||||
required[name][0] = [*required[name][0], val]
|
||||
continue
|
||||
required[name] = [[val], {}]
|
||||
elif name in required:
|
||||
continue
|
||||
elif isinstance(val, list):
|
||||
required[name] = ["*", {}]
|
||||
elif isinstance(val, bool):
|
||||
required[name] = ["BOOLEAN", {}]
|
||||
elif isinstance(val, (int, float)):
|
||||
required[name] = ["NUMBER", {}]
|
||||
return info
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_cache():
|
||||
clear_object_info_cache()
|
||||
yield
|
||||
clear_object_info_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def endpoint_at(client, fake_comfy): # noqa: F811
|
||||
"""Register a connection pointing at the fake server; yields its id."""
|
||||
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
||||
prev_active = settings.comfyui_active_id
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "预检桩", "base_url": _url(fake_comfy), "auth_type": "none"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
endpoints = r.json()["endpoints"]
|
||||
ep = next(e for e in endpoints if e["base_url"] == _url(fake_comfy))
|
||||
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["base_url"] == _url(fake_comfy)]
|
||||
settings.comfyui_active_id = ep["id"]
|
||||
settings.apply_active_comfyui()
|
||||
try:
|
||||
yield ep["id"]
|
||||
finally:
|
||||
settings.comfyui_endpoints = prev_eps
|
||||
settings.comfyui_active_id = prev_active
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unit_missing_node_reported_with_node_context():
|
||||
wf = {"900": {"class_type": "H3SLAAttention", "inputs": {"enabled": True},
|
||||
"_meta": {"title": "H3 SLA Attention"}}}
|
||||
findings = check_workflow_against({"OtherNode": {}}, wf)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f["code"] == "missing_node" and f["level"] == "error"
|
||||
msg = f["message"]
|
||||
assert "H3SLAAttention" in msg and "900" in msg and "H3 SLA Attention" in msg
|
||||
assert "重新检查" in f["hint"]
|
||||
|
||||
|
||||
def test_unit_combo_value_not_in_list():
|
||||
wf = {"127": {"class_type": "UNETLoader",
|
||||
"inputs": {"unet_name": "missing_model.safetensors", "weight_dtype": "default"}}}
|
||||
info = {"UNETLoader": {"input": {"required": {
|
||||
"unet_name": [["installed_model.safetensors"], {}],
|
||||
"weight_dtype": [["default", "fp8"], {"upload": False}],
|
||||
}}, "output": ["MODEL"]}}
|
||||
findings = check_workflow_against(info, wf)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f["code"] == "value_not_in_list" and f["node_id"] == "127"
|
||||
assert "missing_model.safetensors" in f["message"] and "unet_name" in f["message"]
|
||||
assert "模型文件" in f["message"]
|
||||
assert "installed_model.safetensors" in f["hint"]
|
||||
|
||||
|
||||
def test_unit_upload_backed_and_wired_inputs_skipped():
|
||||
wf = {
|
||||
"1": {"class_type": "LoadImage", "inputs": {"image": "placeholder.png"}},
|
||||
"2": {"class_type": "LoadVideo", "inputs": {"file": "clip.mp4"}},
|
||||
"3": {"class_type": "LoadAudio", "inputs": {"audio": "voice.wav"}},
|
||||
"4": {"class_type": "KSampler", "inputs": {"seed": 7, "model": ["9", 0]}},
|
||||
}
|
||||
info = {
|
||||
"LoadImage": {"input": {"required": {"image": [["other.png"], {"image_upload": True}]}},
|
||||
"output": ["IMAGE"]},
|
||||
"LoadVideo": {"input": {"required": {"file": [[], {}]}}, "output": ["IMAGE"]},
|
||||
"LoadAudio": {"input": {"required": {"audio": [[], {}]}}, "output": ["AUDIO"]},
|
||||
"KSampler": {"input": {"required": {"seed": ["INT", {}], "model": ["MODEL", {}]}},
|
||||
"output": ["IMAGE"]},
|
||||
}
|
||||
assert check_workflow_against(info, wf) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analyze with a target connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_analyze_all_green_against_fake_object_info(client, endpoint_at, fake_comfy):
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
check = r.json()["connection_check"]
|
||||
assert check["reachable"] is True and check["ok"] is True
|
||||
assert check["findings"] == []
|
||||
|
||||
|
||||
def test_analyze_reports_missing_custom_node(client, endpoint_at, fake_comfy):
|
||||
info = build_object_info(_load_05())
|
||||
info.pop("H3SLAAttention")
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["ok"] is False
|
||||
missing = [f for f in check["findings"] if f["code"] == "missing_node"]
|
||||
assert missing and "H3SLAAttention" in missing[0]["message"]
|
||||
assert "900" in missing[0]["message"]
|
||||
|
||||
|
||||
def test_analyze_reports_missing_model_value(client, endpoint_at, fake_comfy):
|
||||
info = build_object_info(_load_05())
|
||||
# The server has a DIFFERENT unet installed than the one the JSON names.
|
||||
info["UNETLoader"]["input"]["required"]["unet_name"] = [["another_unet.safetensors"], {}]
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["ok"] is False
|
||||
bad = [f for f in check["findings"] if f["code"] == "value_not_in_list"]
|
||||
assert bad and bad[0]["node_id"] == "127"
|
||||
assert "minimax_h3_fused_refdelta" in bad[0]["message"]
|
||||
|
||||
|
||||
def test_unreachable_connection_degrades_to_unchecked(client):
|
||||
# A port that nothing listens on: the endpoint resolves, the server doesn't.
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
||||
prev_active = settings.comfyui_active_id
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "断连桩", "base_url": f"http://127.0.0.1:{port}", "auth_type": "none"},
|
||||
)
|
||||
endpoints = r.json()["endpoints"]
|
||||
ep = next(e for e in endpoints if e["base_url"] == f"http://127.0.0.1:{port}")
|
||||
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["id"] == ep["id"]]
|
||||
settings.comfyui_active_id = ep["id"]
|
||||
settings.apply_active_comfyui()
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": ep["id"]},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["reachable"] is False and check["ok"] is False
|
||||
assert "无法核查" in check["message"]
|
||||
assert check["findings"] == []
|
||||
finally:
|
||||
settings.comfyui_endpoints = prev_eps
|
||||
settings.comfyui_active_id = prev_active
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Saved-workflow re-check + cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_workflow(client, workflow: dict, endpoint_id: str | None = None) -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "预检工作流",
|
||||
"workflow_json": workflow,
|
||||
"usage": "ref2video",
|
||||
**({"endpoint_id": endpoint_id} if endpoint_id else {}),
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_saved_workflow_recheck_and_refresh(client, endpoint_at, fake_comfy):
|
||||
wf_id = _create_workflow(client, _load_05(), endpoint_at)
|
||||
info = build_object_info(_load_05())
|
||||
info.pop("UNETLoader")
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(f"/api/workflows/{wf_id}/check", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is False and body["endpoint_id"] == endpoint_at
|
||||
assert any(f["code"] == "missing_node" for f in body["findings"])
|
||||
|
||||
# User installs the node package, comes back and re-checks with refresh.
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
r = client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
|
||||
body = r.json()
|
||||
assert body["ok"] is True and body["findings"] == []
|
||||
|
||||
|
||||
def test_object_info_cached_within_ttl(client, endpoint_at, fake_comfy):
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
for _ in range(2):
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
assert r.json()["connection_check"]["ok"] is True
|
||||
hits = [p for p in fake_comfy.requests if p == "/object_info"]
|
||||
assert len(hits) == 1 # second call served from the 5-minute cache
|
||||
|
||||
wf_id = _create_workflow(client, _load_05())
|
||||
client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
|
||||
hits = [p for p in fake_comfy.requests if p == "/object_info"]
|
||||
assert len(hits) == 2 # manual refresh bypasses the TTL
|
||||
@@ -0,0 +1,197 @@
|
||||
"""「测试工作流」(HEL-643 批次 2)端到端:真实队列 → fake ComfyUI → 产物隔离。
|
||||
|
||||
测试任务与普通任务区分(jobs.origin)、dry-run 拒绝、幂等复用、
|
||||
保留策略与级联清理,全部对着 fake ComfyUI 跑真实链路。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.queue.workflow_test import workflow_test_dir
|
||||
from tests.test_studio_generation import (
|
||||
IMAGE_WORKFLOW,
|
||||
PNG_1PX,
|
||||
_real_run,
|
||||
)
|
||||
|
||||
|
||||
def _wait_terminal(client, job_id: int, timeout: float = 25.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
r = client.get(f"/api/jobs/{job_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
if job["status"] not in ("pending", "running"):
|
||||
return job
|
||||
time.sleep(0.4)
|
||||
raise AssertionError(f"test job {job_id} did not finish in {timeout}s")
|
||||
|
||||
|
||||
def _create_workflow(client, workflow: dict, name: str = "可测工作流") -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": name, "workflow_json": workflow, "usage": "text2image"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_passing_test_downloads_real_output_to_scratch_dir(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="通过用例")
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "测试提示词"}}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["reused"] is False
|
||||
job = _wait_terminal(client, body["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["origin"] == "workflow_test"
|
||||
assert job["payload"]["source"] == "workflow_test"
|
||||
assert job["phase"] == "finished"
|
||||
# 真实拿到可下载输出才算通过:产物落在独立 scratch 目录
|
||||
assert len(job["output_paths"]) >= 1
|
||||
out = Path(job["output_paths"][0])
|
||||
assert out.is_file() and out.read_bytes() == PNG_1PX
|
||||
assert workflow_test_dir(wf_id) in out.parents
|
||||
# 提交给 fake ComfyUI 的是补丁后的真实提示词
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
assert queued["1"]["inputs"]["text"] == "测试提示词"
|
||||
|
||||
|
||||
def test_tasks_list_shows_origin_but_playground_history_hides_it(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="可见性用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job_id = r.json()["job"]["id"]
|
||||
_wait_terminal(client, job_id)
|
||||
|
||||
listed = client.get("/api/jobs?limit=50").json()
|
||||
mine = [j for j in listed if j["id"] == job_id]
|
||||
assert mine and mine[0]["origin"] == "workflow_test"
|
||||
|
||||
pg = client.get("/api/playground/jobs").json()
|
||||
assert all(j["id"] != job_id for j in pg), "test jobs must stay out of studio history"
|
||||
|
||||
|
||||
def test_failed_prompt_reports_chinese_reason(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="排队被拒用例")
|
||||
fake_comfy.fail_prompt = True
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job = _wait_terminal(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "拒绝执行" in (job["error"] or "")
|
||||
|
||||
|
||||
def test_no_output_does_not_pass(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="无输出用例")
|
||||
fake_comfy.drop_outputs = True
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job = _wait_terminal(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "没有返回任何图片或视频" in (job["error"] or "")
|
||||
|
||||
|
||||
def test_dry_run_setting_refuses_real_test(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
settings.dry_run = True # conftest default; _real_run clears it — set again
|
||||
try:
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="dry 拒绝用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
assert r.status_code == 400
|
||||
assert "Dry-run" in r.json()["detail"]
|
||||
finally:
|
||||
settings.dry_run = False
|
||||
|
||||
|
||||
def test_repeat_click_reuses_inflight_job(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="幂等用例")
|
||||
assert client.post("/api/jobs/pause").status_code == 200
|
||||
try:
|
||||
r1 = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "一次就好"}}
|
||||
)
|
||||
assert r1.status_code == 200 and r1.json()["reused"] is False
|
||||
r2 = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "重复点击"}}
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["reused"] is True
|
||||
assert r2.json()["job"]["id"] == r1.json()["job"]["id"]
|
||||
finally:
|
||||
client.post("/api/jobs/resume")
|
||||
client.post(f"/api/jobs/{r1.json()['job']['id']}/cancel")
|
||||
_wait_terminal(client, r1.json()["job"]["id"])
|
||||
|
||||
|
||||
def test_retention_keeps_newest_records_and_prunes_artifacts(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="保留策略用例")
|
||||
for i in range(7):
|
||||
fake_comfy.out_name = f"t{i}.png"
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test",
|
||||
json={"input_values": {"1": f"第{i}次"}},
|
||||
)
|
||||
_wait_terminal(client, r.json()["job"]["id"])
|
||||
rows = client.get(f"/api/workflows/{wf_id}/tests").json()
|
||||
assert len(rows) <= 5
|
||||
kept_ids = {row["id"] for row in rows}
|
||||
# 被清掉的旧记录其产物文件也一并删除
|
||||
leftovers = [p for p in workflow_test_dir(wf_id).iterdir() if p.suffix == ".png"]
|
||||
assert all(p.stem.lstrip("t").isdigit() and int(p.stem[1:]) >= 2 for p in leftovers)
|
||||
assert len(kept_ids) == 5
|
||||
|
||||
|
||||
def test_clear_tests_and_workflow_delete_cascade(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="清理用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
_wait_terminal(client, r.json()["job"]["id"])
|
||||
assert workflow_test_dir(wf_id).is_dir()
|
||||
|
||||
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
|
||||
assert r.status_code == 200 and r.json()["removed"] >= 1
|
||||
assert not workflow_test_dir(wf_id).exists()
|
||||
assert client.get(f"/api/workflows/{wf_id}/tests").json() == []
|
||||
|
||||
# 进行中的测试:先拒绝清除,取消后删除工作流级联清理
|
||||
assert client.post("/api/jobs/pause").status_code == 200
|
||||
try:
|
||||
job_id = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}}
|
||||
).json()["job"]["id"]
|
||||
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
|
||||
assert r.status_code == 409
|
||||
finally:
|
||||
client.post("/api/jobs/resume")
|
||||
client.post(f"/api/jobs/{job_id}/cancel")
|
||||
_wait_terminal(client, job_id)
|
||||
|
||||
# 工作流列表带出「上次测试」实时状态
|
||||
listed = client.get("/api/workflows").json()
|
||||
mine = [w for w in listed if w["id"] == wf_id][0]
|
||||
assert mine["last_test"]["id"] == job_id
|
||||
|
||||
assert client.delete(f"/api/workflows/{wf_id}").status_code == 200
|
||||
assert not workflow_test_dir(wf_id).exists()
|
||||
assert client.get(f"/api/workflows/{wf_id}/tests").status_code == 404
|
||||
|
||||
|
||||
def test_last_test_summary_live_on_list(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_a = _create_workflow(client, IMAGE_WORKFLOW, name="A")
|
||||
wf_b = _create_workflow(client, IMAGE_WORKFLOW, name="B")
|
||||
r = client.post(f"/api/workflows/{wf_a}/test", json={"input_values": {"1": "p"}})
|
||||
done = _wait_terminal(client, r.json()["job"]["id"])
|
||||
listed = {w["id"]: w for w in client.get("/api/workflows").json()}
|
||||
assert listed[wf_a]["last_test"]["status"] == "done"
|
||||
assert listed[wf_a]["last_test"]["output_count"] == len(done["output_paths"])
|
||||
assert listed[wf_b]["last_test"] is None
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Calliope Lab</title>
|
||||
<title>影工坊</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
|
||||
@@ -133,8 +133,13 @@ import type {
|
||||
SceneVideoSettings,
|
||||
Clip,
|
||||
Workflow,
|
||||
WorkflowUsage,
|
||||
WorkflowAnalyzeResult,
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
} from "./comfy/types";
|
||||
export type {
|
||||
Job,
|
||||
@@ -142,8 +147,13 @@ export type {
|
||||
SceneVideoSettings,
|
||||
Clip,
|
||||
Workflow,
|
||||
WorkflowUsage,
|
||||
WorkflowAnalyzeResult,
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
};
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -161,6 +171,28 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Readable text for an api() failure: FastAPI `detail`, not the raw "400: {json}". */
|
||||
export function apiErrorMessage(err: unknown): string {
|
||||
if (err instanceof TypeError) return t("api.networkError");
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
const m = /^(\d{3}): ([\s\S]*)$/.exec(raw);
|
||||
if (!m) return raw;
|
||||
try {
|
||||
const detail = (JSON.parse(m[2]) as { detail?: unknown }).detail;
|
||||
if (typeof detail === "string" && detail) return detail;
|
||||
if (Array.isArray(detail) && detail.length) {
|
||||
return detail
|
||||
.map((d) =>
|
||||
d && typeof d === "object" && "msg" in d ? String(d.msg) : String(d),
|
||||
)
|
||||
.join(";");
|
||||
}
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
return t("api.httpError", { status: m[1], detail: m[2].slice(0, 200) });
|
||||
}
|
||||
|
||||
/** Multipart variant of api() — FormData sets its own Content-Type boundary. */
|
||||
async function apiUpload<T>(path: string, form: FormData): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, { method: "POST", body: form });
|
||||
@@ -430,21 +462,59 @@ export const comfy = {
|
||||
|
||||
export const workflows = {
|
||||
list: () => api<Workflow[]>("/api/workflows"),
|
||||
analyze: (workflow_json: Record<string, unknown>) =>
|
||||
api<{
|
||||
inputs: ComfyDynamicInput[];
|
||||
outputs: ComfyDynamicOutput[];
|
||||
suggested_profile?: string;
|
||||
}>("/api/workflows/analyze", {
|
||||
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;
|
||||
kind: "image" | "video";
|
||||
workflow_json: Record<string, unknown>;
|
||||
kind?: "image" | "video";
|
||||
usage?: WorkflowUsage;
|
||||
usage_source?: "auto" | "user";
|
||||
input_schema?: ComfyDynamicInput[];
|
||||
output_map?: WorkflowOutputMapEntry[];
|
||||
output_map_source?: "auto" | "user";
|
||||
description?: string;
|
||||
prompt_profile?: string;
|
||||
endpoint_id?: string | null;
|
||||
@@ -462,6 +532,11 @@ export const workflows = {
|
||||
description: string;
|
||||
prompt_profile: string;
|
||||
endpoint_id: string | null;
|
||||
usage: WorkflowUsage;
|
||||
usage_source: "auto" | "user";
|
||||
input_schema: ComfyDynamicInput[];
|
||||
output_map: WorkflowOutputMapEntry[];
|
||||
output_map_source: "auto" | "user";
|
||||
}>,
|
||||
) =>
|
||||
api<Workflow>(`/api/workflows/${id}`, {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
/** Studio image slots (ref-upload-spec §3): PNG / JPG / WebP, ≤ 20MB each. */
|
||||
export const IMAGE_SLOT_ACCEPT =
|
||||
".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp";
|
||||
export const IMAGE_SLOT_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp"];
|
||||
|
||||
/** Reference video slots (HEL-643): MP4 / WebM / MOV, ≤ 500MB (upload cap). */
|
||||
export const VIDEO_SLOT_ACCEPT =
|
||||
"video/mp4,video/webm,video/quicktime,.mp4,.webm,.mov";
|
||||
export const VIDEO_SLOT_MAX_BYTES = 500 * 1024 * 1024;
|
||||
const VIDEO_EXTS = [".mp4", ".webm", ".mov"];
|
||||
|
||||
/** Reference audio slots (HEL-643): MP3 / WAV / M4A, ≤ 100MB. */
|
||||
export const AUDIO_SLOT_ACCEPT =
|
||||
"audio/mpeg,audio/wav,audio/mp4,.mp3,.wav,.m4a";
|
||||
export const AUDIO_SLOT_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const AUDIO_EXTS = [".mp3", ".wav", ".m4a"];
|
||||
|
||||
export type MediaSlotKind = "image" | "video" | "audio";
|
||||
|
||||
export function mediaSlotAccept(kind: MediaSlotKind): string {
|
||||
if (kind === "video") return VIDEO_SLOT_ACCEPT;
|
||||
if (kind === "audio") return AUDIO_SLOT_ACCEPT;
|
||||
return IMAGE_SLOT_ACCEPT;
|
||||
}
|
||||
|
||||
/** Why this file cannot fill a media slot of this kind (null = fine). */
|
||||
export function mediaFileProblem(
|
||||
kind: MediaSlotKind,
|
||||
file: { name: string; size: number },
|
||||
): string | null {
|
||||
const dot = file.name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : "";
|
||||
const max =
|
||||
kind === "video"
|
||||
? VIDEO_SLOT_MAX_BYTES
|
||||
: kind === "audio"
|
||||
? AUDIO_SLOT_MAX_BYTES
|
||||
: IMAGE_SLOT_MAX_BYTES;
|
||||
if (kind === "video" && !VIDEO_EXTS.includes(ext))
|
||||
return t("studio.errVideoFormat", { name: file.name });
|
||||
if (kind === "audio" && !AUDIO_EXTS.includes(ext))
|
||||
return t("studio.errAudioFormat", { name: file.name });
|
||||
if (kind === "image" && !IMAGE_EXTS.includes(ext))
|
||||
return t("studio.errImageFormat", { name: file.name });
|
||||
if (file.size > max)
|
||||
return kind === "image"
|
||||
? t("studio.errImageSize", { name: file.name })
|
||||
: t("studio.errMediaSize", { name: file.name });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Why this file cannot fill an image slot (null = fine). */
|
||||
export function imageFileProblem(file: {
|
||||
name: string;
|
||||
size: number;
|
||||
}): string | null {
|
||||
return mediaFileProblem("image", file);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
/** Display name of an uploaded path (strips the uuid8- prefix the upload endpoint writes). */
|
||||
export function uploadBaseName(p: string): string {
|
||||
const raw = p.split(/[/\\]/).pop() ?? p;
|
||||
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const TEXT_AREA = new Set([
|
||||
'PrimitiveString',
|
||||
]);
|
||||
const NUMBER = new Set(['INT', 'FLOAT', 'PrimitiveInt', 'PrimitiveFloat', 'KSampler', 'KSamplerAdvanced']);
|
||||
const IMAGE = new Set(['LoadImage', 'ImageLoader', 'ETN_LoadImageBase64']);
|
||||
const IMAGE = new Set(['LoadImage', 'LoadImageMask', 'ImageLoader', 'ETN_LoadImageBase64']);
|
||||
const IMAGE_URL = new Set(['Load Image From Url (mtb)']);
|
||||
const AUDIO = new Set(['LoadAudio', 'VHS_LoadAudio']);
|
||||
const VIDEO = new Set(['LoadVideo', 'VHS_LoadVideo', 'VHS_LoadVideoPath']);
|
||||
|
||||
@@ -44,6 +44,7 @@ export function compactInputValues(
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
if (v === null || v === undefined) continue;
|
||||
if (typeof v === 'string' && !v.trim()) continue;
|
||||
if (Array.isArray(v) && !v.length) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* ComfyUI ResolutionSelector (comfy_extras/nodes_resolution.py) — same
|
||||
* ratios and rounding, so the size shown is the size ComfyUI renders.
|
||||
*/
|
||||
const RATIOS: Record<string, [number, number]> = {
|
||||
'1:1 (Square)': [1, 1],
|
||||
'2:3 (Portrait Photo)': [2, 3],
|
||||
'3:2 (Photo)': [3, 2],
|
||||
'3:4 (Portrait Standard)': [3, 4],
|
||||
'4:3 (Standard)': [4, 3],
|
||||
'9:16 (Portrait Widescreen)': [9, 16],
|
||||
'16:9 (Widescreen)': [16, 9],
|
||||
'21:9 (Ultrawide)': [21, 9],
|
||||
};
|
||||
|
||||
/** Python's round(): halves go to the even neighbour. */
|
||||
function roundHalfEven(x: number): number {
|
||||
const f = Math.floor(x);
|
||||
const diff = x - f;
|
||||
if (diff > 0.5) return f + 1;
|
||||
if (diff < 0.5) return f;
|
||||
return f % 2 === 0 ? f : f + 1;
|
||||
}
|
||||
|
||||
export function resolutionSelectorSize(
|
||||
aspectRatio: string,
|
||||
megapixels: number,
|
||||
multiple: number,
|
||||
): { width: number; height: number } | null {
|
||||
const ratio = RATIOS[aspectRatio];
|
||||
if (!ratio || !Number.isFinite(megapixels) || megapixels <= 0) return null;
|
||||
const [w, h] = ratio;
|
||||
const m = Math.max(1, Math.floor(multiple) || 8);
|
||||
const scale = Math.sqrt((megapixels * 1024 * 1024) / (w * h));
|
||||
return { width: roundHalfEven((w * scale) / m) * m, height: roundHalfEven((h * scale) / m) * m };
|
||||
}
|
||||
|
||||
/** "16:9 (Widescreen)" → "16:9". */
|
||||
export function ratioShort(option: string): string {
|
||||
return option.split(' ')[0] ?? option;
|
||||
}
|
||||
@@ -1,6 +1,27 @@
|
||||
export type ComfyInputKind = 'text' | 'textarea' | 'number' | 'image' | 'image_url' | 'audio' | 'video';
|
||||
export type ComfyInputKind =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'number'
|
||||
| 'select'
|
||||
| 'image'
|
||||
| 'image_url'
|
||||
| 'audio'
|
||||
| 'video'
|
||||
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
|
||||
| 'image_list'
|
||||
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
|
||||
| 'video_list'
|
||||
| 'audio_list';
|
||||
export type ComfyOutputKind = 'image' | 'video' | 'other';
|
||||
|
||||
/** Structural finding on an imported workflow; `error` blocks generation. */
|
||||
export interface WorkflowWarning {
|
||||
level: 'error' | 'warning';
|
||||
code: string;
|
||||
node_id: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ComfyDynamicInput {
|
||||
nodeId: string;
|
||||
label: string;
|
||||
@@ -9,14 +30,118 @@ export interface ComfyDynamicInput {
|
||||
kind: ComfyInputKind;
|
||||
defaultValue?: string | number;
|
||||
required: boolean;
|
||||
/** Inferred from node classes/wiring — the workflow carries no (Input) tags. */
|
||||
auto?: boolean;
|
||||
/** ComfyUI widget limits (number kind). */
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
integer?: boolean;
|
||||
/** Allowed values (select kind). */
|
||||
options?: string[];
|
||||
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
|
||||
resolution?: { megapixels: string; multiple: number };
|
||||
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
|
||||
media_hint?: string;
|
||||
}
|
||||
|
||||
export interface ComfyDynamicOutput {
|
||||
nodeId: string;
|
||||
label: string;
|
||||
/** Role from title tag, e.g. (Output:image) → "image". */
|
||||
/** Role from title tag, e.g. (Output:image). */
|
||||
role?: string | null;
|
||||
kind: ComfyOutputKind;
|
||||
/** Null while an unknown custom node awaits the user's media-type pick. */
|
||||
kind: ComfyOutputKind | null;
|
||||
/** Untagged detection: node class + which rule typed it. */
|
||||
class_type?: string;
|
||||
evidence?: 'registry' | 'class_name_match' | 'unknown_node';
|
||||
/** Form default for unknown candidates — never auto-applied. */
|
||||
suggested_kind?: 'image' | 'video' | null;
|
||||
}
|
||||
|
||||
/** One entry of a workflow's confirmed output mapping (HEL-638). */
|
||||
export interface WorkflowOutputMapEntry {
|
||||
nodeId: string;
|
||||
kind: 'image' | 'video';
|
||||
label?: string;
|
||||
class_type?: string;
|
||||
}
|
||||
|
||||
/** The five workflow usages the studio distinguishes (HEL-636). */
|
||||
export type WorkflowUsage =
|
||||
| 'text2image'
|
||||
| 'image2image'
|
||||
| 'text2video'
|
||||
| 'image2video'
|
||||
| 'ref2video';
|
||||
|
||||
export const WORKFLOW_USAGES: WorkflowUsage[] = [
|
||||
'text2image',
|
||||
'image2image',
|
||||
'text2video',
|
||||
'image2video',
|
||||
'ref2video',
|
||||
];
|
||||
|
||||
/** Which usages each generate page serves. */
|
||||
export const USAGE_BY_KIND: Record<'image' | 'video', WorkflowUsage[]> = {
|
||||
image: ['text2image', 'image2image'],
|
||||
video: ['text2video', 'image2video', 'ref2video'],
|
||||
};
|
||||
|
||||
/** One L2 connection-check finding against a live ComfyUI (HEL-643). */
|
||||
export interface PreflightFinding {
|
||||
level: 'error' | 'warn' | 'info' | 'ok';
|
||||
code: string;
|
||||
node_id?: string;
|
||||
node_title?: string | null;
|
||||
message: string;
|
||||
hint?: string | null;
|
||||
}
|
||||
|
||||
/** Result of checking a workflow against a target connection's /object_info. */
|
||||
export interface WorkflowConnectionCheck {
|
||||
endpoint_id: string | null;
|
||||
endpoint_name: string | null;
|
||||
base_url?: string | null;
|
||||
/** False when any error-level finding exists (or the server was unreachable). */
|
||||
ok: boolean;
|
||||
/** False → 无法核查 (server down): honest, non-blocking. */
|
||||
reachable: boolean;
|
||||
findings: PreflightFinding[];
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
/** Slim live record of a workflow's latest test run (HEL-643). */
|
||||
export interface WorkflowLastTest {
|
||||
id: number;
|
||||
status: string;
|
||||
phase: string | null;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
output_count: number;
|
||||
}
|
||||
|
||||
/** Result of POST /api/workflows/analyze (import confirm step 1). */
|
||||
export interface WorkflowAnalyzeResult {
|
||||
inputs: ComfyDynamicInput[];
|
||||
outputs: ComfyDynamicOutput[];
|
||||
suggested_profile?: string;
|
||||
warnings?: WorkflowWarning[];
|
||||
usage: WorkflowUsage | null;
|
||||
usage_ambiguous: boolean;
|
||||
needs_mapping: boolean;
|
||||
usage_problem: string | null;
|
||||
usage_evidence: {
|
||||
outputs: ComfyDynamicOutput[];
|
||||
image_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
|
||||
video_slots: Array<{ nodeId: string; label: string; kind: string; role: string | null }>;
|
||||
summary: string[];
|
||||
};
|
||||
usable_usage: WorkflowUsage[];
|
||||
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
|
||||
connection_check?: WorkflowConnectionCheck;
|
||||
}
|
||||
|
||||
export interface WorkflowNode {
|
||||
@@ -29,6 +154,9 @@ export interface Workflow {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: 'image' | 'video';
|
||||
/** Five-way usage (HEL-636); null = pending human confirmation. */
|
||||
usage?: WorkflowUsage | null;
|
||||
usage_source?: 'auto' | 'user' | null;
|
||||
workflow_json: Record<string, WorkflowNode>;
|
||||
input_schema: ComfyDynamicInput[];
|
||||
output_schema: ComfyDynamicOutput[];
|
||||
@@ -37,6 +165,14 @@ export interface Workflow {
|
||||
is_enabled: boolean;
|
||||
/** Bound ComfyUI connection id (settings.comfyui_endpoints); null = active. */
|
||||
endpoint_id?: string | null;
|
||||
warnings?: WorkflowWarning[];
|
||||
/** HEL-638: confirmed output mapping ({nodeId, kind}); null/empty = pending. */
|
||||
output_map?: WorkflowOutputMapEntry[] | null;
|
||||
output_map_source?: 'auto' | 'user' | null;
|
||||
/** True when the graph has unknown output nodes needing designation. */
|
||||
needs_mapping?: boolean;
|
||||
/** Latest test-run record, queried live (HEL-643); null = never tested. */
|
||||
last_test?: WorkflowLastTest | null;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
@@ -55,6 +191,12 @@ export interface Job {
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
retry_count: number;
|
||||
/** Prompt id ComfyUI accepted this job under (null until /prompt succeeds). */
|
||||
comfy_prompt_id?: string | null;
|
||||
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
|
||||
phase?: string | null;
|
||||
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface SceneVideoSettings {
|
||||
|
||||
@@ -260,6 +260,18 @@
|
||||
oninput={(e) => setNumberValue(inp.nodeId, e.currentTarget.value)}
|
||||
onblur={() => markTouched(inp.nodeId)}
|
||||
/>
|
||||
{:else if inp.kind === 'select'}
|
||||
<select
|
||||
{id}
|
||||
class:invalid={errored}
|
||||
value={current || String(inp.defaultValue ?? '')}
|
||||
onchange={(e) => setValue(inp.nodeId, e.currentTarget.value)}
|
||||
onblur={() => markTouched(inp.nodeId)}
|
||||
>
|
||||
{#each inp.options ?? [] as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if allowUpload && isUploadKind(inp.kind)}
|
||||
{@const uploadingName = uploading[inp.nodeId]}
|
||||
{@const isUpload = !!current && !assetOptions.some((o) => o.path === current)}
|
||||
|
||||
@@ -346,6 +346,16 @@
|
||||
value={values[nodeId] ?? ctrl.input.defaultValue ?? ''}
|
||||
oninput={(e) => setValue(nodeId, Number(e.currentTarget.value) || 0)}
|
||||
/>
|
||||
{:else if ctrl.input.kind === 'select'}
|
||||
<select
|
||||
class="adv-input"
|
||||
value={String(values[nodeId] ?? ctrl.input.defaultValue ?? '')}
|
||||
onchange={(e) => setValue(nodeId, e.currentTarget.value)}
|
||||
>
|
||||
{#each ctrl.input.options ?? [] as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
class="adv-input"
|
||||
|
||||
@@ -52,15 +52,16 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* §四.5 Toast:右下角堆叠,220ms 上移入场;成功绿点 / 失败红点。 */
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 20px;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 1000;
|
||||
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 +70,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,17 +89,17 @@
|
||||
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;
|
||||
@@ -127,10 +115,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 +129,7 @@
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -151,9 +141,11 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
:global(:root[data-reduce='1']) .toast {
|
||||
animation: none;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.toast-host {
|
||||
top: auto;
|
||||
bottom: 20px;
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
<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 Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import { mediaFileProblem, type MediaSlotKind } from '$lib/comfy/imageFile';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 测试工作流 (HEL-643 L3): 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.
|
||||
*/
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
$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 = '';
|
||||
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>
|
||||
|
||||
<Modal bind:open title={t('wft.title')} size="lg">
|
||||
{#if wf}
|
||||
<div class="wft">
|
||||
<p class="wft-sub">
|
||||
{wf.name}
|
||||
<span class="mono wft-id">#{wf.id}</span>
|
||||
</p>
|
||||
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('wft.connection')}</span>
|
||||
<select class="f-input" 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="f-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}
|
||||
{@const info = jobStatusInfo(activeJob)}
|
||||
<div class="wft-active" class:ok={activeJob.status === 'done'} class:bad={activeJob.status === 'failed'}>
|
||||
<div class="wft-active-head">
|
||||
<span class="status-chip">{t('wft.running')}</span>
|
||||
<span class="wft-active-detail">{info.detail}</span>
|
||||
<Button variant="secondary" size="sm" onclick={cancelTest}>{t('wft.cancel')}</Button>
|
||||
</div>
|
||||
</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.slice(0, 4) 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') : finishedJob.error}</p>
|
||||
</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={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="f-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="f-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}
|
||||
</Modal>
|
||||
|
||||
<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;
|
||||
}
|
||||
.wft-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--info, #3b82f6);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wft-note p {
|
||||
margin: 0;
|
||||
}
|
||||
.wft-active,
|
||||
.wft-result {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.wft-active-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wft-active-detail {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-result.ok {
|
||||
border-color: var(--success);
|
||||
}
|
||||
.wft-result.bad {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.wft-verdict {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.wft-result.ok .wft-verdict {
|
||||
color: var(--success);
|
||||
}
|
||||
.wft-error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
.wft-outputs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-out {
|
||||
display: block;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
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(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.wft-history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.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: 6px;
|
||||
}
|
||||
.wft-rows li.active {
|
||||
background: var(--surface-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(--success);
|
||||
}
|
||||
</style>
|
||||
@@ -195,7 +195,6 @@
|
||||
{:else if messages.length === 0 && !running}
|
||||
<div class="empty">
|
||||
<div class="empty-mark"><Icon name="sparkle" size={22} /></div>
|
||||
<p class="empty-eyebrow">Calliope</p>
|
||||
<p class="empty-title">{t('agentChat.emptyTitle')}</p>
|
||||
<p class="empty-hint">{t('agentChat.emptyHint')}</p>
|
||||
{#if suggestions.length > 0}
|
||||
@@ -370,15 +369,6 @@
|
||||
box-shadow: 0 0 32px var(--accent-glow);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.empty-eyebrow {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
.empty-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
|
||||
@@ -39,17 +39,18 @@
|
||||
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 '';
|
||||
}
|
||||
|
||||
// Spec §2.1 allows the five avatar gradients as cover placeholders.
|
||||
const PLACEHOLDER_GRADIENTS = [
|
||||
'linear-gradient(135deg, #e8a33d, #b96a1b)',
|
||||
'linear-gradient(135deg, #5b8def, #3a5fa9)',
|
||||
'linear-gradient(135deg, #3fb97f, #2a7a54)',
|
||||
'linear-gradient(135deg, #b98be0, #7a4fa9)',
|
||||
'linear-gradient(135deg, #d98e6a, #a95535)',
|
||||
'var(--avatar-1)',
|
||||
'var(--avatar-2)',
|
||||
'var(--avatar-3)',
|
||||
'var(--avatar-4)',
|
||||
'var(--avatar-5)',
|
||||
];
|
||||
const placeholder = $derived(PLACEHOLDER_GRADIENTS[project.id % PLACEHOLDER_GRADIENTS.length]);
|
||||
|
||||
@@ -57,20 +58,26 @@
|
||||
|
||||
let coverFailed = $state(false);
|
||||
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
draft: 'st-draft',
|
||||
in_progress: 'st-progress',
|
||||
completed: 'st-done',
|
||||
};
|
||||
const STATUS_KEY: Record<string, string> = {
|
||||
draft: 'home.statusDraft',
|
||||
in_progress: 'home.statusProgress',
|
||||
completed: 'home.statusDone',
|
||||
};
|
||||
const statusClass = $derived(STATUS_CLASS[project.status] ?? 'st-draft');
|
||||
const statusLabel = $derived(
|
||||
project.status in STATUS_KEY ? t(STATUS_KEY[project.status]) : project.status,
|
||||
);
|
||||
// Cover tag reflects the real five-stage state (a1: 「分镜设计 · 进行中」):
|
||||
// all confirmed → 已完成; otherwise the first unconfirmed stage + its state.
|
||||
const cover = $derived.by(() => {
|
||||
if (stats.stage_confirmed >= STAGE_SEGMENTS.length) {
|
||||
return { cls: 'st-done', label: t('home.statusDone') };
|
||||
}
|
||||
const current = STAGE_SEGMENTS.find((k) => stats.stage_status?.[k] !== 'confirmed');
|
||||
const state = current ? stats.stage_status?.[current] : undefined;
|
||||
const stageName = current ? t(`workbench.stage.${current}`) : '';
|
||||
if (state === 'running') {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.running')}` };
|
||||
}
|
||||
if (state === 'awaiting_confirm') {
|
||||
return { cls: 'st-pending', label: `${stageName} · ${t('workbench.state.draft')}` };
|
||||
}
|
||||
if (stats.stage_confirmed > 0) {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('home.statusProgress')}` };
|
||||
}
|
||||
return { cls: 'st-draft', label: t('home.statusDraft') };
|
||||
});
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
@@ -171,7 +178,7 @@
|
||||
<Icon name="film" size={22} />
|
||||
</span>
|
||||
{/if}
|
||||
<span class="status-tag {statusClass}">{statusLabel}</span>
|
||||
<span class="status-tag {cover.cls}">{cover.label}</span>
|
||||
</span>
|
||||
<span class="body">
|
||||
<span class="title-row">
|
||||
@@ -323,7 +330,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 {
|
||||
@@ -334,20 +341,23 @@
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
background: var(--media-scrim);
|
||||
border: 1px solid var(--border-strong);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
/* a1: dark capsule over the cover so it stays legible on any image;
|
||||
the text carries the semantic color (§2.1). */
|
||||
.st-draft {
|
||||
color: var(--text-2);
|
||||
background: rgba(27, 30, 37, 0.85);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
.st-progress {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
color: var(--run);
|
||||
}
|
||||
.st-pending {
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-done {
|
||||
color: var(--success);
|
||||
background: var(--success-dim);
|
||||
}
|
||||
|
||||
.body {
|
||||
@@ -403,8 +413,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);
|
||||
@@ -419,8 +432,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;
|
||||
@@ -456,7 +469,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;
|
||||
@@ -511,14 +524,7 @@
|
||||
}
|
||||
.menu-item.danger:hover {
|
||||
background: var(--error-dim);
|
||||
}
|
||||
|
||||
/* restyle reused ui/Button + form primitives inside the darkroom scope */
|
||||
.a-card :global(.btn-primary) {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
.a-card :global(.field-input) {
|
||||
} .a-card :global(.field-input) {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
|
||||
@@ -53,15 +53,24 @@
|
||||
const projectMemories = $derived(
|
||||
($memoriesQuery.data ?? []).filter((m) => m.scope === 'project'),
|
||||
);
|
||||
|
||||
const KIND_KEYS: Record<string, string> = {
|
||||
preference: 'memory.kindPreference',
|
||||
convention: 'memory.kindConvention',
|
||||
correction: 'memory.kindCorrection',
|
||||
};
|
||||
function kindLabel(kind: string): string {
|
||||
return KIND_KEYS[kind] ? t(KIND_KEYS[kind]) : kind;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>{t('memory.title')}</h1>
|
||||
<p class="lead">{t('memory.lead')}</p>
|
||||
<section class="a-panel">
|
||||
<h2 class="a-panel-title">{t('memory.title')}</h2>
|
||||
<p class="a-panel-lead lead">{t('memory.lead')}</p>
|
||||
|
||||
<div class="add-row">
|
||||
<input
|
||||
class="field-input add-input"
|
||||
class="a-input add-input"
|
||||
placeholder={t('memory.placeholder')}
|
||||
maxlength={500}
|
||||
bind:value={newContent}
|
||||
@@ -69,25 +78,25 @@
|
||||
if (e.key === 'Enter' && canAdd) $addMutation.mutate();
|
||||
}}
|
||||
/>
|
||||
<select class="field-input scope-select" bind:value={newScope}>
|
||||
<select class="a-select scope-select" bind:value={newScope}>
|
||||
<option value="global">{t('memory.scopeGlobal')}</option>
|
||||
<option value="project">{t('memory.scopeProject')}</option>
|
||||
</select>
|
||||
{#if newScope === 'project'}
|
||||
<select class="field-input scope-select" bind:value={newProjectId}>
|
||||
<select class="a-select scope-select" bind:value={newProjectId}>
|
||||
<option value={null}>{t('memory.chooseProject')}</option>
|
||||
{#each ($projectsQuery.data ?? []) as p (p.id)}
|
||||
<option value={p.id}>{p.title}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<select class="field-input scope-select" bind:value={newKind}>
|
||||
<select class="a-select scope-select" bind:value={newKind}>
|
||||
<option value="preference">{t('memory.kindPreference')}</option>
|
||||
<option value="convention">{t('memory.kindConvention')}</option>
|
||||
<option value="correction">{t('memory.kindCorrection')}</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canAdd || $addMutation.isPending}
|
||||
onclick={() => $addMutation.mutate()}
|
||||
@@ -102,11 +111,11 @@
|
||||
<p class="muted">{t('memory.empty')}</p>
|
||||
{:else}
|
||||
{#if globalMemories.length > 0}
|
||||
<h2 class="group-head">{t('memory.groupGlobal')}</h2>
|
||||
<h3 class="group-head">{t('memory.groupGlobal')}</h3>
|
||||
<div class="mem-list">
|
||||
{#each globalMemories as m (m.id)}
|
||||
<div class="mem-row">
|
||||
<span class="mem-kind">{m.kind}</span>
|
||||
<span class="mem-kind">{kindLabel(m.kind)}</span>
|
||||
<span class="mem-content">{m.content}</span>
|
||||
<span class="mem-meta">
|
||||
{m.source === 'user' ? t('memory.sourceYou') : t('memory.sourceAgent')} · {t('memory.usedCount', { count: m.use_count })}
|
||||
@@ -124,11 +133,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if projectMemories.length > 0}
|
||||
<h2 class="group-head">{t('memory.groupProject')}</h2>
|
||||
<h3 class="group-head">{t('memory.groupProject')}</h3>
|
||||
<div class="mem-list">
|
||||
{#each projectMemories as m (m.id)}
|
||||
<div class="mem-row">
|
||||
<span class="mem-kind">{m.kind}</span>
|
||||
<span class="mem-kind">{kindLabel(m.kind)}</span>
|
||||
<span class="mem-project">{m.project_title ?? `#${m.project_id}`}</span>
|
||||
<span class="mem-content">{m.content}</span>
|
||||
<span class="mem-meta">
|
||||
@@ -150,21 +159,8 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
.panel h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.lead {
|
||||
margin: 0 0 var(--space-lg);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.add-row {
|
||||
display: flex;
|
||||
@@ -177,19 +173,6 @@
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
}
|
||||
.field-input {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.scope-select {
|
||||
width: auto;
|
||||
max-width: 160px;
|
||||
@@ -198,9 +181,7 @@
|
||||
margin: var(--space-md) 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.mem-list {
|
||||
display: flex;
|
||||
@@ -213,17 +194,15 @@
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.mem-kind {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--accent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
font-size: 10.5px;
|
||||
color: var(--info);
|
||||
background: var(--info-dim);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
@@ -257,7 +236,7 @@
|
||||
}
|
||||
.mem-del:hover {
|
||||
color: var(--error);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
const items = [
|
||||
{ id: 'llm', href: '/settings?tab=llm', labelKey: 'nav.llm', blurbKey: 'nav.llmBlurb' },
|
||||
{ id: 'comfy', href: '/settings?tab=comfy', labelKey: 'nav.comfy', blurbKey: 'nav.comfyBlurb' },
|
||||
{ id: 'workflows', href: '/settings?tab=workflows', labelKey: 'nav.workflows', blurbKey: 'nav.workflowsBlurb' },
|
||||
{ id: 'agent', href: '/settings?tab=agent', labelKey: 'nav.agent', blurbKey: 'nav.agentBlurb' },
|
||||
{ id: 'skills', href: '/settings?tab=skills', labelKey: 'nav.skills', blurbKey: 'nav.skillsBlurb' },
|
||||
{ id: 'queue', href: '/settings?tab=queue', labelKey: 'nav.queue', blurbKey: 'nav.queueBlurb' },
|
||||
{ id: 'storage', href: '/settings?tab=storage', labelKey: 'nav.storage', blurbKey: 'nav.storageBlurb' },
|
||||
] as const;
|
||||
|
||||
interface Props {
|
||||
/** Section ids with unsaved edits — shown with a small warning dot. */
|
||||
dirty?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
let { dirty }: Props = $props();
|
||||
|
||||
let active = $derived(page.url.searchParams.get('tab') || 'llm');
|
||||
</script>
|
||||
|
||||
<aside class="side">
|
||||
<p class="eyebrow">{t('nav.eyebrow')}</p>
|
||||
{#each items as item (item.id)}
|
||||
<a
|
||||
class="item"
|
||||
class:active={active === item.id}
|
||||
href={item.href}
|
||||
aria-current={active === item.id ? 'page' : undefined}
|
||||
>
|
||||
<span class="label">
|
||||
{t(item.labelKey)}
|
||||
{#if dirty?.has(item.id)}
|
||||
<span class="dirty-dot" title={t('nav.unsaved')} aria-label={t('nav.unsaved')}></span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="blurb">{t(item.blurbKey)}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.side {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 8px 12px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.item:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.item.active {
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
border-color: rgba(139, 92, 246, 0.35);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.dirty-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.blurb {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.item.active .blurb {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,485 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { agentApi } from '$lib/api';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface SkillSummary {
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
tags: string[];
|
||||
dir: string;
|
||||
}
|
||||
|
||||
const skillsQuery = createQuery({
|
||||
queryKey: ['agent-skills'],
|
||||
queryFn: agentApi.listSkills,
|
||||
});
|
||||
const pathQuery = createQuery({
|
||||
queryKey: ['agent-skills-path'],
|
||||
queryFn: agentApi.skillsPath,
|
||||
});
|
||||
|
||||
// Expanded skill (file list open) + the file whose body is being previewed.
|
||||
let expanded = $state<string | null>(null);
|
||||
let filesBySkill = $state<Record<string, string[]>>({});
|
||||
let preview = $state<{
|
||||
skill: string;
|
||||
path: string;
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
} | null>(null);
|
||||
let previewLoading = $state(false);
|
||||
|
||||
async function toggleFiles(name: string) {
|
||||
if (expanded === name) {
|
||||
expanded = null;
|
||||
return;
|
||||
}
|
||||
expanded = name;
|
||||
preview = null;
|
||||
if (!filesBySkill[name]) {
|
||||
try {
|
||||
const res = await agentApi.skillFiles(name);
|
||||
filesBySkill = { ...filesBySkill, [name]: res.files };
|
||||
} catch {
|
||||
filesBySkill = { ...filesBySkill, [name]: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openFile(skill: string, file: string) {
|
||||
previewLoading = true;
|
||||
preview = null;
|
||||
try {
|
||||
const res = await agentApi.readSkillFile(skill, file);
|
||||
preview = { skill, path: file, content: res.content, truncated: res.truncated };
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('skills.couldNotRead'));
|
||||
} finally {
|
||||
previewLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string, quiet = false) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (!quiet) toast.success(t('skills.copied'));
|
||||
} catch {
|
||||
/* clipboard unavailable — text stays selectable */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h1>{t('skills.title')}</h1>
|
||||
<p class="lead">{t('skills.lead')}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
$skillsQuery.refetch();
|
||||
toast.info(t('skills.refreshed'));
|
||||
}}
|
||||
>
|
||||
<Icon name="retry" size={14} />
|
||||
{t('skills.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if $pathQuery.data?.path}
|
||||
<div class="path-banner">
|
||||
<Icon name="folder" size={14} />
|
||||
<span class="mono path-text">{$pathQuery.data.path}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="mini-btn"
|
||||
onclick={() => copyText($pathQuery.data?.path ?? '')}
|
||||
title={t('skills.copyPath')}
|
||||
>
|
||||
{t('skills.copyPathBtn')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="callout info">
|
||||
<Icon name="edit" size={16} />
|
||||
<div class="callout-body">
|
||||
<p class="howto-title"><strong>{t('skills.howtoTitle')}</strong></p>
|
||||
<ol class="howto">
|
||||
<li>{@html t('skills.howto1')}</li>
|
||||
<li>{@html t('skills.howto2')}</li>
|
||||
<li>{@html t('skills.howto3')}</li>
|
||||
<li>{@html t('skills.howto4')}</li>
|
||||
</ol>
|
||||
<p class="note">{@html t('skills.note')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $skillsQuery.isLoading}
|
||||
<div class="stack">
|
||||
<Skeleton height="72px" />
|
||||
<Skeleton height="72px" />
|
||||
</div>
|
||||
{:else if ($skillsQuery.data ?? []).length === 0}
|
||||
<p class="empty">{t('skills.noSkills')}</p>
|
||||
{:else}
|
||||
<div class="stack">
|
||||
{#each $skillsQuery.data ?? [] as skill (skill.dir)}
|
||||
<article class="skill-card">
|
||||
<header class="card-head">
|
||||
<button
|
||||
type="button"
|
||||
class="expand"
|
||||
aria-expanded={expanded === skill.dir}
|
||||
onclick={() => toggleFiles(skill.dir)}
|
||||
title={expanded === skill.dir ? t('skills.hideFiles') : t('skills.showFiles')}
|
||||
>
|
||||
<Icon
|
||||
name={expanded === skill.dir ? 'chevron-down' : 'chevron-right'}
|
||||
size={14}
|
||||
/>
|
||||
</button>
|
||||
<div class="card-text">
|
||||
<div class="name-row">
|
||||
<span class="name">/{skill.name}</span>
|
||||
{#if skill.version}
|
||||
<span class="version">v{skill.version}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if skill.description}
|
||||
<p class="desc">{skill.description}</p>
|
||||
{/if}
|
||||
{#if Array.isArray(skill.tags) && skill.tags.length > 0}
|
||||
<div class="tags">
|
||||
{#each skill.tags as tag (tag)}
|
||||
<span class="tag">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
{#if expanded === skill.dir}
|
||||
<div class="files">
|
||||
<p class="files-label">{t('skills.filesIn')} <span class="mono">{skill.dir}/</span></p>
|
||||
{#each filesBySkill[skill.dir] ?? [] as file (file)}
|
||||
<button
|
||||
type="button"
|
||||
class="file-row"
|
||||
class:active={preview?.skill === skill.dir && preview?.path === file}
|
||||
onclick={() => openFile(skill.dir, file)}
|
||||
>
|
||||
<Icon name="edit" size={12} />
|
||||
<span class="mono">{file}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="muted">{t('skills.noFiles')}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if previewLoading}
|
||||
<p class="muted">{t('skills.loadingFile')}</p>
|
||||
{:else if preview}
|
||||
<div class="preview-panel">
|
||||
<header class="preview-head">
|
||||
<span class="mono preview-path">{preview.skill}/{preview.path}</span>
|
||||
{#if preview.truncated}
|
||||
<span class="truncated-chip">{t('skills.truncated')}</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="mini-btn"
|
||||
onclick={() => copyText(preview?.content ?? '')}
|
||||
>
|
||||
{t('skills.copy')}
|
||||
</button>
|
||||
<button type="button" class="mini-btn" onclick={() => (preview = null)}>{t('skills.close')}</button>
|
||||
</header>
|
||||
<pre class="mono preview-body">{preview.content}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.panel-head h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.panel-head .lead {
|
||||
margin: 0;
|
||||
}
|
||||
.lead {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.path-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-surface));
|
||||
}
|
||||
.path-banner :global(.icon) {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.path-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.mini-btn {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
.callout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.callout.info :global(.icon) {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.callout-body {
|
||||
min-width: 0;
|
||||
}
|
||||
.callout-body p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.howto-title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.howto {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.howto li {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.snippet {
|
||||
margin: 8px 0 4px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
color: var(--text-primary);
|
||||
white-space: pre;
|
||||
}
|
||||
.snippet-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.note {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.skill-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.expand {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.expand:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.card-text {
|
||||
min-width: 0;
|
||||
}
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #7dd3fc;
|
||||
}
|
||||
.version {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.tag {
|
||||
font-size: 10.5px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.files {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.files-label {
|
||||
margin: 0 0 6px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.file-row:hover,
|
||||
.file-row.active {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.file-row.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
.preview-panel {
|
||||
margin-top: var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.preview-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.truncated-chip {
|
||||
font-size: 10.5px;
|
||||
color: var(--warning);
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
.preview-body {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,700 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { workflows, type Workflow, type ComfyDynamicInput, type ComfyDynamicOutput } 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 Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
const client = useQueryClient();
|
||||
const workflowsQuery = createQuery({
|
||||
queryKey: ['workflows'],
|
||||
queryFn: workflows.list,
|
||||
});
|
||||
|
||||
let jsonText = $state('');
|
||||
let uploadedFileName = $state<string | null>(null);
|
||||
let dragging = $state(false);
|
||||
let parseError = $state('');
|
||||
let analyzedInputs = $state<ComfyDynamicInput[]>([]);
|
||||
let analyzedOutputs = $state<ComfyDynamicOutput[]>([]);
|
||||
let analyzed = $state(false);
|
||||
let pendingJson = $state<Record<string, unknown> | null>(null);
|
||||
|
||||
let wfName = $state('');
|
||||
let wfKind = $state<'image' | 'video'>('image');
|
||||
let wfProfile = $state('prose');
|
||||
let wfDescription = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
let editingId = $state<number | null>(null);
|
||||
let editName = $state('');
|
||||
let editDescription = $state('');
|
||||
let editProfile = $state('prose');
|
||||
let detailsId = $state<number | null>(null);
|
||||
|
||||
async function analyzeRaw(text: string) {
|
||||
parseError = '';
|
||||
analyzed = false;
|
||||
analyzedInputs = [];
|
||||
analyzedOutputs = [];
|
||||
pendingJson = null;
|
||||
try {
|
||||
const json = JSON.parse(text) as Record<string, unknown>;
|
||||
pendingJson = json;
|
||||
const result = await workflows.analyze(json);
|
||||
analyzedInputs = result.inputs;
|
||||
analyzedOutputs = result.outputs;
|
||||
analyzed = true;
|
||||
wfProfile = result.suggested_profile ?? 'prose';
|
||||
if (!wfName.trim() && uploadedFileName) {
|
||||
wfName = uploadedFileName.replace(/\.json$/i, '');
|
||||
}
|
||||
} catch (err) {
|
||||
parseError = err instanceof Error ? err.message : t('wf.invalidJson');
|
||||
}
|
||||
}
|
||||
|
||||
async function onFile(file: File) {
|
||||
uploadedFileName = file.name;
|
||||
const text = await file.text();
|
||||
jsonText = text;
|
||||
await analyzeRaw(text);
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragging = false;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) void onFile(file);
|
||||
}
|
||||
|
||||
async function saveToLibrary() {
|
||||
if (!pendingJson || !wfName.trim() || !analyzed) return;
|
||||
saving = true;
|
||||
try {
|
||||
await workflows.create({
|
||||
name: wfName.trim(),
|
||||
kind: wfKind,
|
||||
workflow_json: pendingJson,
|
||||
description: wfDescription.trim() || undefined,
|
||||
prompt_profile: wfProfile,
|
||||
});
|
||||
jsonText = '';
|
||||
uploadedFileName = null;
|
||||
analyzed = false;
|
||||
analyzedInputs = [];
|
||||
analyzedOutputs = [];
|
||||
pendingJson = null;
|
||||
const name = wfName.trim();
|
||||
wfName = '';
|
||||
wfDescription = '';
|
||||
wfKind = 'image';
|
||||
wfProfile = 'prose';
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(t('wf.savedToLibrary', { name }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('wf.saveFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(wf: Workflow) {
|
||||
editingId = wf.id;
|
||||
editName = wf.name;
|
||||
editDescription = wf.description ?? '';
|
||||
editProfile = wf.prompt_profile ?? 'prose';
|
||||
}
|
||||
|
||||
async function saveEdit(id: number) {
|
||||
try {
|
||||
await workflows.update(id, {
|
||||
name: editName.trim(),
|
||||
description: editDescription.trim(),
|
||||
prompt_profile: editProfile,
|
||||
});
|
||||
editingId = null;
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(t('wf.updated'));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('wf.updateFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(wf: Workflow) {
|
||||
try {
|
||||
await workflows.update(wf.id, { is_enabled: !wf.is_enabled });
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(
|
||||
t('wf.enabledState', {
|
||||
name: wf.name,
|
||||
state: wf.is_enabled ? t('wf.stateDisabled') : t('wf.stateEnabled'),
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('wf.updateFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
let deleteTarget = $state<Workflow | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
function askDelete(wf: Workflow) {
|
||||
deleteTarget = wf;
|
||||
deleteOpen = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const wf = deleteTarget;
|
||||
deleteTarget = null;
|
||||
if (!wf) return;
|
||||
try {
|
||||
await workflows.delete(wf.id);
|
||||
if (detailsId === wf.id) detailsId = null;
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(t('wf.deleted', { name: wf.name }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('wf.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
let list = $derived($workflowsQuery.data ?? []);
|
||||
let imageCount = $derived(list.filter((w) => w.kind === 'image').length);
|
||||
let videoCount = $derived(list.filter((w) => w.kind === 'video').length);
|
||||
</script>
|
||||
|
||||
<section class="block">
|
||||
<header class="block-head">
|
||||
<div>
|
||||
<h2>{t('wf.title')}</h2>
|
||||
<p>{@html t('wf.lead')}</p>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="stat">{t('wf.countSaved', { count: list.length })}</span>
|
||||
<span class="stat image">{t('wf.countImage', { count: imageCount })}</span>
|
||||
<span class="stat video">{t('wf.countVideo', { count: videoCount })}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="register panel">
|
||||
<h3>{t('wf.analyzeRegister')}</h3>
|
||||
<div
|
||||
class="drop"
|
||||
class:dragging
|
||||
role="button"
|
||||
tabindex="0"
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
}}
|
||||
ondragleave={() => (dragging = false)}
|
||||
ondrop={onDrop}
|
||||
onclick={() => document.getElementById('wf-file')?.click()}
|
||||
onkeydown={(e) => e.key === 'Enter' && document.getElementById('wf-file')?.click()}
|
||||
>
|
||||
<input
|
||||
id="wf-file"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
onchange={(e) => {
|
||||
const f = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (f) void onFile(f);
|
||||
}}
|
||||
/>
|
||||
<div class="drop-icon">JSON</div>
|
||||
<div>
|
||||
<strong>{t('wf.dropHint')}</strong>
|
||||
<p class="muted">{t('wf.dropSub')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if uploadedFileName}
|
||||
<p class="file-name mono">{uploadedFileName}</p>
|
||||
{/if}
|
||||
|
||||
{#if jsonText}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('wf.jsonPreview')}</span>
|
||||
<textarea class="field-textarea mono preview" rows="8" readonly value={jsonText}></textarea>
|
||||
</label>
|
||||
<Button variant="secondary" size="sm" onclick={() => analyzeRaw(jsonText)}>{t('wf.reanalyze')}</Button>
|
||||
{/if}
|
||||
|
||||
{#if parseError}
|
||||
<div class="error">{parseError}</div>
|
||||
{/if}
|
||||
|
||||
{#if analyzed}
|
||||
<div class="io-panel">
|
||||
<div class="io-col">
|
||||
<h4>{t('wf.detectedInputs', { count: analyzedInputs.length })}</h4>
|
||||
{#if analyzedInputs.length === 0}
|
||||
<p class="muted">{@html t('wf.noInputs')}</p>
|
||||
{:else}
|
||||
{#each analyzedInputs as inp}
|
||||
<div class="pill input">
|
||||
<span class="kind">{inp.kind}</span>
|
||||
{#if inp.role}<span class="kind">{inp.role}</span>{/if}
|
||||
<span>{inp.label}</span>
|
||||
<span class="node mono">#{inp.nodeId}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="io-col">
|
||||
<h4>{t('wf.detectedOutputs', { count: analyzedOutputs.length })}</h4>
|
||||
{#if analyzedOutputs.length === 0}
|
||||
<p class="muted">{@html t('wf.noOutputs')}</p>
|
||||
{:else}
|
||||
{#each analyzedOutputs as out}
|
||||
<div class="pill output">
|
||||
<span class="kind">{out.kind}</span>
|
||||
{#if out.role}<span class="kind">{out.role}</span>{/if}
|
||||
<span>{out.label}</span>
|
||||
<span class="node mono">#{out.nodeId}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-grid">
|
||||
<label class="field">
|
||||
<span class="field-label">{t('common.name')}</span>
|
||||
<input class="field-input" bind:value={wfName} placeholder={t('wf.namePlaceholder')} required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('wf.kind')}</span>
|
||||
<select class="field-select" bind:value={wfKind}>
|
||||
<option value="image">{t('wf.kindImage')}</option>
|
||||
<option value="video">{t('wf.kindVideo')}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('wf.promptFormat')}</span>
|
||||
<select class="field-select" bind:value={wfProfile}>
|
||||
<option value="prose">{t('wf.profileProse')}</option>
|
||||
<option value="minimax_h3_ref">{t('wf.profileH3')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('common.description')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={wfDescription}
|
||||
rows="2"
|
||||
placeholder={t('wf.descPlaceholder')}
|
||||
></textarea>
|
||||
</label>
|
||||
<Button variant="primary" loading={saving} disabled={!wfName.trim()} onclick={saveToLibrary}>
|
||||
{t('wf.saveToLibrary')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="library">
|
||||
<div class="library-head">
|
||||
<h3>{t('wf.savedWorkflows', { count: list.length })}</h3>
|
||||
</div>
|
||||
|
||||
{#if $workflowsQuery.isLoading}
|
||||
<div class="cards" aria-busy="true" aria-label={t('wf.loadingWorkflows')}>
|
||||
{#each [1, 2, 3] as n (n)}
|
||||
<div class="card skel-card">
|
||||
<Skeleton circle height="40px" />
|
||||
<div class="skel-lines">
|
||||
<Skeleton width="45%" height="15px" />
|
||||
<Skeleton width="70%" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if list.length === 0}
|
||||
<div class="empty">
|
||||
<strong>{t('wf.noWorkflows')}</strong>
|
||||
<p class="muted">{t('wf.noWorkflowsHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="cards">
|
||||
{#each list as wf (wf.id)}
|
||||
<article class="card" class:disabled={!wf.is_enabled}>
|
||||
{#if editingId === wf.id}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('common.name')}</span>
|
||||
<input class="field-input" bind:value={editName} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('common.description')}</span>
|
||||
<textarea class="field-textarea" rows="2" bind:value={editDescription}></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('wf.promptFormat')}</span>
|
||||
<select class="field-select" bind:value={editProfile}>
|
||||
<option value="prose">{t('wf.profileProse')}</option>
|
||||
<option value="minimax_h3_ref">{t('wf.profileH3')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="field-hint">{t('wf.jsonLocked')}</p>
|
||||
<div class="row">
|
||||
<Button size="sm" onclick={() => saveEdit(wf.id)}>{t('common.save')}</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => (editingId = null)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-top">
|
||||
<div class="tile" class:video={wf.kind === 'video'}>
|
||||
{wf.kind === 'video' ? 'VID' : 'IMG'}
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<strong>{wf.name}</strong>
|
||||
<div class="tags">
|
||||
<span class="kind-badge" class:video={wf.kind === 'video'}>{wf.kind}</span>
|
||||
{#if wf.prompt_profile === 'minimax_h3_ref'}
|
||||
<span class="kind-badge h3">H3-ref</span>
|
||||
{/if}
|
||||
<span class="count">{t('wf.inputsCount', { count: wf.input_schema?.length ?? 0 })}</span>
|
||||
<span class="count">{t('wf.outputsCount', { count: wf.output_schema?.length ?? 0 })}</span>
|
||||
{#if !wf.is_enabled}<StatusChip status="disabled" label={t('wf.disabled')} />{/if}
|
||||
</div>
|
||||
{#if wf.description}
|
||||
<p class="desc">{wf.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<Button variant="ghost" size="sm" onclick={() => startEdit(wf)}>{t('common.edit')}</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => (detailsId = detailsId === wf.id ? null : wf.id)}
|
||||
>
|
||||
{detailsId === wf.id ? t('wf.hideIo') : t('wf.viewIo')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => toggleEnabled(wf)}>
|
||||
{wf.is_enabled ? t('wf.disable') : t('wf.enable')}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onclick={() => askDelete(wf)}>{t('common.delete')}</Button>
|
||||
</div>
|
||||
{#if detailsId === wf.id}
|
||||
<div class="io-panel nested">
|
||||
<div class="io-col">
|
||||
<h4>{t('wf.inputs')}</h4>
|
||||
{#each wf.input_schema ?? [] as inp}
|
||||
<div class="pill input">
|
||||
<span class="kind">{inp.kind}</span>
|
||||
{#if inp.role}<span class="kind">{inp.role}</span>{/if}
|
||||
<span>{inp.label}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">{t('wf.noInputsCached')}</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="io-col">
|
||||
<h4>{t('wf.outputs')}</h4>
|
||||
{#each wf.output_schema ?? [] as out}
|
||||
<div class="pill output">
|
||||
<span class="kind">{out.kind}</span>
|
||||
<span>{out.label}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">{t('wf.noOutputsCached')}</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('wf.deleteTitle')}
|
||||
message={deleteTarget ? t('wf.deleteMessage', { name: deleteTarget.name }) : ''}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onconfirm={confirmDelete}
|
||||
oncancel={() => (deleteTarget = null)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.block-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-lg);
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
.block-head h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.block-head p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
max-width: 52ch;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.block-head code {
|
||||
background: var(--bg-elevated);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.stat.image {
|
||||
color: var(--accent);
|
||||
border-color: rgba(139, 92, 246, 0.35);
|
||||
}
|
||||
.stat.video {
|
||||
color: var(--info);
|
||||
border-color: rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
.panel,
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
.register {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
.register h3,
|
||||
.library-head h3 {
|
||||
margin: 0 0 var(--space-md);
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.drop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-md);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.drop:hover,
|
||||
.drop.dragging {
|
||||
border-color: var(--accent);
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
}
|
||||
.drop:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.drop-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--space-md);
|
||||
}
|
||||
.preview {
|
||||
font-size: 11px;
|
||||
max-height: 180px;
|
||||
}
|
||||
.error {
|
||||
margin: var(--space-md) 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: var(--error);
|
||||
font-size: 13px;
|
||||
}
|
||||
.io-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-md);
|
||||
margin: var(--space-md) 0;
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.io-panel.nested {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.io-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.io-col h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.pill .kind {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.pill.input .kind {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
.pill.output .kind {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
.pill .node {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.library-head {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.card.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.card-top {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.tile {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tile.video {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: var(--info);
|
||||
}
|
||||
.card-meta strong {
|
||||
font-size: 15px;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.kind-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
.kind-badge.video {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--info);
|
||||
}
|
||||
.kind-badge.h3 {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
.count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.desc {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.empty {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
text-align: center;
|
||||
}
|
||||
.skel-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.skel-lines {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
import { settings, jobsApi } from '$lib/api';
|
||||
import { settings, jobsApi, comfy } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
@@ -81,9 +82,9 @@
|
||||
.length,
|
||||
);
|
||||
|
||||
// The ACTIVE ComfyUI connection from settings (HEL-611). The dot stays
|
||||
// neutral — a passing test is only claimed on the connections page,
|
||||
// where it comes from a real server-side probe.
|
||||
// The ACTIVE ComfyUI connection (HEL-611). Its status dot comes from a
|
||||
// real server-side probe (GET /system_stats from the workbench server,
|
||||
// §3) — green only when that probe passes.
|
||||
const activeEndpoint = $derived.by(() => {
|
||||
const s = $settingsQuery.data;
|
||||
if (!s) return null;
|
||||
@@ -100,6 +101,25 @@
|
||||
return raw;
|
||||
}
|
||||
});
|
||||
|
||||
const activeId = $derived(activeEndpoint?.id ?? null);
|
||||
const probeQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['comfy-probe', activeId],
|
||||
queryFn: () => comfy.testEndpoint({ endpoint_id: activeId ?? undefined, timeout_sec: 5 }),
|
||||
enabled: activeId != null,
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 60_000,
|
||||
retry: false,
|
||||
})),
|
||||
);
|
||||
const probeState = $derived.by<'unset' | 'testing' | 'ok' | 'fail'>(() => {
|
||||
if (!activeEndpoint) return 'unset';
|
||||
if ($probeQuery.isPending) return 'testing';
|
||||
if ($probeQuery.isError) return 'fail';
|
||||
return $probeQuery.data?.ok ? 'ok' : 'fail';
|
||||
});
|
||||
const endpointName = $derived(activeEndpoint?.name?.trim() || 'ComfyUI');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -116,8 +136,11 @@
|
||||
<div class="shell-frame">
|
||||
<aside class="side">
|
||||
<a class="brand" href="/" title={t('shell.brandHome')}>
|
||||
<span class="brand-mark"><Icon name="film" size={17} /></span>
|
||||
<span class="brand-name">{t('shell.brand')}</span>
|
||||
<span class="brand-mark" aria-hidden="true">{t('shell.brandMark')}</span>
|
||||
<span class="brand-copy">
|
||||
<span class="brand-name">{t('shell.brand')}</span>
|
||||
<span class="brand-sub">AIGC STUDIO</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="nav" aria-label={t('shell.navAria')}>
|
||||
@@ -146,13 +169,22 @@
|
||||
</nav>
|
||||
|
||||
<div class="side-foot">
|
||||
<a class="comfy-row" href="/connections" title={t('shell.comfyManage')}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<a
|
||||
class="comfy-row"
|
||||
href="/connections"
|
||||
title={probeState === 'fail'
|
||||
? ($probeQuery.data?.message ?? t('shell.comfyManage'))
|
||||
: t('shell.comfyManage')}
|
||||
>
|
||||
<span class="dot {probeState}" aria-hidden="true"></span>
|
||||
<span class="comfy-text">
|
||||
{#if comfyUrl}
|
||||
{t('shell.comfyAt', { host: comfyUrl })}
|
||||
{:else}
|
||||
{#if probeState === 'unset'}
|
||||
{t('shell.comfyUnset')}
|
||||
{:else}
|
||||
<span class="comfy-state" class:fail={probeState === 'fail'}>
|
||||
{t(`shell.comfy_${probeState}`, { name: endpointName })}
|
||||
</span>
|
||||
{#if comfyUrl}<span class="mono comfy-host">{comfyUrl}</span>{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
@@ -224,21 +256,38 @@
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #e8a33d, #b96a1b);
|
||||
color: #16130a;
|
||||
background: var(--avatar-1);
|
||||
color: var(--accent-contrast);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 15.5px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-family: var(--font-a-mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -292,14 +341,14 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 进行数徽章 (spec §3): accent-dim capsule with amber count */
|
||||
/* 进行数徽章:进行中计数用信号青 */
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
min-width: 20px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -335,21 +384,52 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Neutral until a real server-side probe says otherwise — the
|
||||
connections page (a6) owns the tested/failed states (HEL-611). */
|
||||
/* 连接状态点: cyan = online, cyan breathing = testing,
|
||||
red = failed, gray = no connection configured. */
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.dot.ok {
|
||||
background: var(--run);
|
||||
}
|
||||
.dot.fail {
|
||||
background: var(--error);
|
||||
}
|
||||
.dot.testing {
|
||||
background: var(--run);
|
||||
animation: shell-breathe 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
@keyframes shell-breathe {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.comfy-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.comfy-state,
|
||||
.comfy-host {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.comfy-state.fail {
|
||||
color: var(--error);
|
||||
}
|
||||
.comfy-host {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.lang-row :global(.lang-switcher) {
|
||||
background: var(--surface-2);
|
||||
@@ -426,7 +506,7 @@
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.brand-name {
|
||||
.brand-copy {
|
||||
display: none;
|
||||
}
|
||||
.nav {
|
||||
|
||||
@@ -2,22 +2,31 @@
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* A-style media upload slot (spec-v2 §5 上传槽): empty = 1.5px dashed
|
||||
* border + "click or drop", drag-hover = amber dashed, filled = solid
|
||||
* border + thumbnail + mono name + ✕, error = red border + red reason.
|
||||
* A-style media upload slot (spec-v2 §5 上传槽, ref-upload-spec §2):
|
||||
* empty = 1.5px dashed border + "click or drop" + format line, drag-hover
|
||||
* = amber dashed, filled = solid border + thumb + name + size + 替换 + ✕,
|
||||
* error = red border + red reason.
|
||||
*/
|
||||
interface Props {
|
||||
label: string;
|
||||
/** Extra hint under the label, e.g. 可从生图结果送入. */
|
||||
/** Hint on the right of the label, e.g. 必填 · 1 张 / 可从生图结果送入. */
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
/** Current value: a server-side path ('' = empty). */
|
||||
value: string;
|
||||
kind: 'image' | 'video' | 'audio';
|
||||
/** Thumb aspect: wide = 16:9 (first/last frame slots). */
|
||||
aspect?: 'square' | 'wide';
|
||||
/** Empty-state main line (defaults to 点击或拖入). */
|
||||
emptyText?: string;
|
||||
/** Empty-state mono line, e.g. accepted formats and size limit. */
|
||||
emptySub?: string;
|
||||
/** Byte size of the current file when known. */
|
||||
size?: number;
|
||||
uploading?: boolean;
|
||||
uploadName?: string;
|
||||
error?: string;
|
||||
@@ -31,9 +40,13 @@
|
||||
let {
|
||||
label,
|
||||
hint,
|
||||
required = false,
|
||||
value,
|
||||
kind,
|
||||
aspect = 'square',
|
||||
emptyText,
|
||||
emptySub,
|
||||
size,
|
||||
uploading = false,
|
||||
uploadName,
|
||||
error,
|
||||
@@ -46,21 +59,24 @@
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let dragDepth = $state(0);
|
||||
let broken = $state(false);
|
||||
let dims = $state('');
|
||||
$effect(() => {
|
||||
void value;
|
||||
broken = false;
|
||||
dims = '';
|
||||
});
|
||||
|
||||
const meta = $derived([dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '));
|
||||
|
||||
function accept(): string {
|
||||
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
|
||||
if (kind === 'audio') return 'audio/*';
|
||||
return 'image/*';
|
||||
return IMAGE_SLOT_ACCEPT;
|
||||
}
|
||||
|
||||
function baseName(p: string): string {
|
||||
const raw = p.split(/[/\\]/).pop() ?? p;
|
||||
// strip uuid8- prefix the upload endpoint writes
|
||||
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
|
||||
function noteDims(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.naturalWidth) dims = `${img.naturalWidth}×${img.naturalHeight}`;
|
||||
}
|
||||
|
||||
function pick() {
|
||||
@@ -80,7 +96,7 @@
|
||||
|
||||
<div class="media-slot" class:wide={aspect === 'wide'}>
|
||||
<div class="slot-label">
|
||||
<span class="lbl">{label}</span>
|
||||
<span class="lbl">{label}{#if required}<em>*</em>{/if}</span>
|
||||
{#if hint}<span class="slot-hint">{hint}</span>{/if}
|
||||
</div>
|
||||
|
||||
@@ -100,8 +116,10 @@
|
||||
{#if value && !broken}
|
||||
<div class="slot-filled" class:invalid={!!error}>
|
||||
<div class="slot-thumb">
|
||||
{#if kind === 'image'}
|
||||
<img src={assetUrl(value)} alt={label} onerror={() => (broken = true)} />
|
||||
{#if uploading}
|
||||
<Spinner size="sm" />
|
||||
{:else if kind === 'image'}
|
||||
<img src={assetUrl(value)} alt={label} onload={noteDims} onerror={() => (broken = true)} />
|
||||
{:else if kind === 'video'}
|
||||
<video src={assetUrl(value)} preload="metadata" muted></video>
|
||||
<span class="kind-badge" aria-hidden="true"><Icon name="film" size={12} /></span>
|
||||
@@ -110,22 +128,29 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="slot-meta">
|
||||
<span class="mono name" title={baseName(value)}>{baseName(value)}</span>
|
||||
<div class="slot-actions">
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="mini-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResults')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="slot-clear"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
onclick={onClear}
|
||||
>
|
||||
<Icon name="close" size={13} />
|
||||
<span class="name" title={uploading ? uploadName : uploadBaseName(value)}>
|
||||
{uploading ? uploadName : uploadBaseName(value)}
|
||||
</span>
|
||||
{#if meta && !uploading}<span class="mono dims">{meta}</span>{/if}
|
||||
</div>
|
||||
<div class="slot-actions">
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="mini-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<button type="button" class="mini-link" disabled={uploading} onclick={pick}>
|
||||
{t('studio.refReplace')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="slot-clear"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
disabled={uploading}
|
||||
onclick={onClear}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -151,7 +176,8 @@
|
||||
<span class="pick-text">{uploadName ?? t('studio.slotPick')}</span>
|
||||
{:else}
|
||||
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'image'} size={16} />
|
||||
<span class="pick-text">{t('studio.slotPick')}</span>
|
||||
<span class="pick-text">{emptyText ?? t('studio.slotPick')}</span>
|
||||
{#if emptySub}<span class="pick-sub mono">{emptySub}</span>{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if allowFromResults && onFromResults}
|
||||
@@ -181,6 +207,11 @@
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lbl em {
|
||||
color: var(--error);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.slot-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
@@ -227,14 +258,19 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
.slot-empty.invalid {
|
||||
border-color: rgba(229, 72, 77, 0.65);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.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;
|
||||
@@ -251,18 +287,21 @@
|
||||
}
|
||||
.slot-filled {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
padding: 8px;
|
||||
}
|
||||
.slot-filled.invalid {
|
||||
border-color: rgba(229, 72, 77, 0.65);
|
||||
border-color: var(--error);
|
||||
}
|
||||
.slot-thumb {
|
||||
border-radius: 7px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
@@ -271,39 +310,39 @@
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.media-slot.wide .slot-thumb {
|
||||
width: 88px;
|
||||
height: 50px;
|
||||
}
|
||||
.slot-thumb img,
|
||||
.slot-thumb video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 140px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.media-slot.wide .slot-thumb {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
.media-slot.wide .slot-thumb img,
|
||||
.media-slot.wide .slot-thumb video {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.slot-thumb .kind-badge {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.slot-meta {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
font-size: 12.5px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dims {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.slot-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -315,12 +354,17 @@
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 10.5px;
|
||||
color: var(--accent);
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-link:hover {
|
||||
text-decoration: underline;
|
||||
.mini-link:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini-link:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.slot-clear {
|
||||
display: inline-flex;
|
||||
@@ -330,7 +374,7 @@
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
<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,
|
||||
mediaSlotAccept,
|
||||
uploadBaseName,
|
||||
type MediaSlotKind,
|
||||
} from '$lib/comfy/imageFile';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Reference-media grid (ref-upload-spec §2 `.refgrid`, generalized to
|
||||
* image/video/audio in HEL-643): numbered cells 参考图/参考视频/参考音频
|
||||
* 1…N in the order they reach the workflow, each with replace and ✕; a
|
||||
* dashed add cell while below `max`; rejected files show as error cells
|
||||
* that take no place in the list.
|
||||
*/
|
||||
interface Reject {
|
||||
name: string;
|
||||
message: string;
|
||||
}
|
||||
interface Props {
|
||||
label: string;
|
||||
paths: string[];
|
||||
max: number;
|
||||
/** 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. */
|
||||
uploadingName?: string;
|
||||
/** Index being replaced while uploading (-1 = adding). */
|
||||
uploadingIndex?: number;
|
||||
rejects?: Reject[];
|
||||
error?: string;
|
||||
allowFromResults?: boolean;
|
||||
onAdd: (files: File[]) => void;
|
||||
onReplace: (index: number, file: File) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onDismissReject: (index: number) => void;
|
||||
onFromResults?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
paths,
|
||||
max,
|
||||
mediaKind = 'image',
|
||||
sizes = {},
|
||||
uploadingName,
|
||||
uploadingIndex = -1,
|
||||
rejects = [],
|
||||
error,
|
||||
allowFromResults = false,
|
||||
onAdd,
|
||||
onReplace,
|
||||
onRemove,
|
||||
onDismissReject,
|
||||
onFromResults,
|
||||
}: Props = $props();
|
||||
|
||||
let addInput = $state<HTMLInputElement | null>(null);
|
||||
let replaceInput = $state<HTMLInputElement | null>(null);
|
||||
let replaceIndex = $state(-1);
|
||||
let dragDepth = $state(0);
|
||||
let dims = $state<Record<string, string>>({});
|
||||
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 durationOf(path: string): string {
|
||||
const d = durations[path];
|
||||
if (d == null || !Number.isFinite(d)) return '';
|
||||
return `${d.toFixed(1)}s`;
|
||||
}
|
||||
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 pickAdd() {
|
||||
if (uploading || !addInput) return;
|
||||
addInput.value = '';
|
||||
addInput.click();
|
||||
}
|
||||
|
||||
function pickReplace(i: number) {
|
||||
if (uploading || !replaceInput) return;
|
||||
replaceIndex = i;
|
||||
replaceInput.value = '';
|
||||
replaceInput.click();
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragDepth = 0;
|
||||
if (uploading) return;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) onAdd(files);
|
||||
}
|
||||
|
||||
function noteDims(path: string, e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
|
||||
}
|
||||
|
||||
function metaOf(path: string): string {
|
||||
return [dims[path], durationOf(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">{hintLabel}</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
bind:this={addInput}
|
||||
type="file"
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={accept}
|
||||
multiple
|
||||
onchange={(e) => {
|
||||
const files = Array.from(e.currentTarget.files ?? []);
|
||||
if (files.length) onAdd(files);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
bind:this={replaceInput}
|
||||
type="file"
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={accept}
|
||||
onchange={(e) => {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
if (f && replaceIndex >= 0) onReplace(replaceIndex, f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="refgrid" class:audio={mediaKind === 'audio'}>
|
||||
{#each paths as path, i (path + i)}
|
||||
<div class="ref-cell" class:busy={uploadingIndex === i}>
|
||||
<button
|
||||
type="button"
|
||||
class="ref-thumb"
|
||||
title={t('studio.refReplace')}
|
||||
onclick={() => pickReplace(i)}
|
||||
>
|
||||
{#if mediaKind === 'image'}
|
||||
<img
|
||||
src={assetUrl(path)}
|
||||
alt={t('studio.slotRefN', { n: i + 1 })}
|
||||
onload={(e) => noteDims(path, e)}
|
||||
/>
|
||||
{:else if mediaKind === 'video'}
|
||||
<video
|
||||
src={assetUrl(path)}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
onloadedmetadata={(e) => noteDuration(path, e)}
|
||||
title={uploadBaseName(path)}
|
||||
></video>
|
||||
{:else}
|
||||
<span class="audio-glyph"><Icon name="music" size={18} /></span>
|
||||
<audio
|
||||
src={assetUrl(path)}
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => noteDuration(path, e)}
|
||||
title={uploadBaseName(path)}
|
||||
></audio>
|
||||
{/if}
|
||||
<span class="ref-no mono">{i + 1}</span>
|
||||
{#if uploadingIndex === i}
|
||||
<span class="ref-busy"><Spinner size="sm" /></span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ref-x"
|
||||
aria-label={isMedia ? t('studio.refRemoveMedia', { kind: label, n: i + 1 }) : t('studio.refRemove', { n: i + 1 })}
|
||||
onclick={() => onRemove(i)}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
</button>
|
||||
<span class="ref-name" title={uploadBaseName(path)}>{uploadBaseName(path)}</span>
|
||||
<span class="ref-meta mono">
|
||||
{metaOf(path)}
|
||||
<button type="button" class="mini-link" onclick={() => pickReplace(i)}>{t('studio.refReplace')}</button>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each rejects as rej, i (rej.name + i)}
|
||||
<div class="ref-cell rejected" role="alert">
|
||||
<div class="ref-thumb error">
|
||||
<Icon name="close" size={16} />
|
||||
<span class="mono">{t('studio.refRejectedShort')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ref-x"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
onclick={() => onDismissReject(i)}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
</button>
|
||||
<span class="ref-name err" title={rej.name}>{rej.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if remaining > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="ref-add"
|
||||
class:drag={dragDepth > 0}
|
||||
class:invalid={!!error}
|
||||
disabled={uploading}
|
||||
aria-busy={uploading || undefined}
|
||||
onclick={pickAdd}
|
||||
ondragenter={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
}}
|
||||
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if uploading && uploadingIndex < 0}
|
||||
<Spinner size="sm" />
|
||||
<span class="add-main">{uploadingName}</span>
|
||||
{:else}
|
||||
<Icon name="plus" size={16} />
|
||||
<span class="add-main">{isMedia ? t('studio.refAddMedia', { kind: label }) : t('studio.refAdd')}</span>
|
||||
<span class="add-sub mono">{isMedia ? t('studio.refAddSubMedia', { n: remaining, max }) : t('studio.refAddSub', { n: remaining, max })}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if allowFromResults && onFromResults && remaining > 0}
|
||||
<button type="button" class="from-results-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResults')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if remaining === 0}
|
||||
<span class="full-note">{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>
|
||||
{/each}
|
||||
{#if error?.trim()}<span class="slot-error" role="alert">{error}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.refgrid-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.refgrid.audio {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.refgrid.audio .ref-thumb {
|
||||
aspect-ratio: auto;
|
||||
min-height: 64px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
.refgrid.audio audio {
|
||||
width: 90%;
|
||||
height: 28px;
|
||||
}
|
||||
.refgrid video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.audio-glyph {
|
||||
color: var(--text-3);
|
||||
display: inline-flex;
|
||||
}
|
||||
.slot-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lbl em {
|
||||
color: var(--error);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.slot-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.refgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.ref-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.ref-cell.rejected {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.ref-thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ref-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.ref-thumb.error {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
cursor: default;
|
||||
color: var(--error);
|
||||
font-size: 10px;
|
||||
}
|
||||
.ref-no {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 4px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: var(--text);
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.ref-busy {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.ref-x {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ref-x:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
.ref-name {
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ref-name.err {
|
||||
color: var(--error);
|
||||
}
|
||||
.ref-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
font-size: 9.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mini-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ref-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 110px;
|
||||
padding: 8px 4px;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
color 150ms;
|
||||
}
|
||||
.ref-add:hover:not(:disabled) {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.ref-add.drag {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.ref-add.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.ref-add:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.add-main {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.add-sub {
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.from-results-link {
|
||||
align-self: flex-end;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.from-results-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.full-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.slot-error {
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.refgrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,11 @@
|
||||
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;
|
||||
loading?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
@@ -16,6 +19,7 @@
|
||||
let {
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
block = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
type = 'button',
|
||||
@@ -27,7 +31,10 @@
|
||||
|
||||
<button
|
||||
{type}
|
||||
class="btn btn-{variant} btn-{size}"
|
||||
class="a-btn btn {variant}"
|
||||
class:sm={size === 'sm'}
|
||||
class:lg={size === 'lg'}
|
||||
class:block
|
||||
class:loading
|
||||
disabled={disabled || loading}
|
||||
{title}
|
||||
@@ -44,86 +51,13 @@
|
||||
</button>
|
||||
|
||||
<style>
|
||||
/* Look comes from the shared `.a-btn` rules in app.css (§四.1). */
|
||||
.btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
border-color 150ms ease,
|
||||
color 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
transform 100ms ease;
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 28px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn-md {
|
||||
min-height: 34px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #23232b;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: var(--error);
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border-color: rgba(239, 68, 68, 0.45);
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -139,9 +73,15 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-primary .btn-spinner,
|
||||
.btn-danger .btn-spinner {
|
||||
--spinner-track: rgba(255, 255, 255, 0.35);
|
||||
--spinner-arc: #fff;
|
||||
.btn.primary .btn-spinner {
|
||||
--spinner-track: color-mix(in srgb, var(--accent-ink) 30%, transparent);
|
||||
--spinner-arc: var(--accent-ink);
|
||||
}
|
||||
.btn.danger .btn-spinner {
|
||||
--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}
|
||||
|
||||
@@ -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,6 +4,10 @@
|
||||
indeterminate?: boolean;
|
||||
label?: string;
|
||||
size?: 'sm' | 'md';
|
||||
/** 进度条: running = 信号青, done = green, failed = red (static). */
|
||||
tone?: 'run' | 'success' | 'error';
|
||||
/** Track only — no label / percentage row. */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -11,13 +15,15 @@
|
||||
indeterminate = false,
|
||||
label,
|
||||
size = 'md',
|
||||
tone = 'run',
|
||||
bare = false,
|
||||
}: Props = $props();
|
||||
|
||||
const pct = $derived(Math.round(Math.min(100, Math.max(0, value))));
|
||||
const showMeta = $derived(Boolean(label) || !indeterminate);
|
||||
const showMeta = $derived(!bare && (Boolean(label) || !indeterminate));
|
||||
</script>
|
||||
|
||||
<div class="progress progress-{size}">
|
||||
<div class="progress progress-{size} tone-{tone}">
|
||||
{#if showMeta}
|
||||
<div class="progress-meta">
|
||||
{#if label}
|
||||
@@ -39,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>
|
||||
@@ -69,8 +75,8 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-4);
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.progress-sm .track {
|
||||
height: 4px;
|
||||
@@ -80,9 +86,18 @@
|
||||
}
|
||||
.fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 250ms ease;
|
||||
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(--ok);
|
||||
box-shadow: none;
|
||||
}
|
||||
.tone-error .fill {
|
||||
background: var(--err);
|
||||
box-shadow: none;
|
||||
}
|
||||
.fill.indeterminate {
|
||||
position: absolute;
|
||||
@@ -92,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;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ Svelte 5 runes mode, TypeScript strict, scoped styles driven by the CSS vars in
|
||||
|
||||
Conventions shared by all primitives:
|
||||
|
||||
- Styling uses the global tokens (`--bg-surface`, `--border`, `--accent`, `--radius-*`, `--space-*`, fonts). Never hardcode hex colors outside this directory.
|
||||
- Styling uses the global A「片场暗房」tokens in `src/routes/app.css` (`--surface`, `--border`, `--accent`, …; legacy names such as `--bg-surface` are aliases). Button renders the shared `.a-btn` classes. Never hardcode hex colors.
|
||||
- All interactive primitives expose a `:focus-visible` accent ring. `app.css` also provides a global `:focus-visible` fallback for native elements.
|
||||
- Motion respects `prefers-reduced-motion`: `app.css` kills animation/transition durations globally; components that need a styled static fallback (Skeleton, ProgressBar indeterminate, StatusChip pulse) handle it in their own scoped styles.
|
||||
|
||||
@@ -19,7 +19,8 @@ Conventions shared by all primitives:
|
||||
| Prop | Type | Default | Notes |
|
||||
| ---------- | ------------------------------------------------- | ------------- | -------------------------------------------------- |
|
||||
| `variant` | `'primary' \| 'secondary' \| 'ghost' \| 'danger'` | `'secondary'` | primary = `--accent` bg; danger = `--error` tinted |
|
||||
| `size` | `'sm' \| 'md'` | `'md'` | sm = 28px, md = 34px min-height |
|
||||
| `size` | `'sm' \| 'md'` | `'md'` | sm = 30px, md = 34px min-height (shared `.a-btn`) |
|
||||
| `block` | `boolean` | `false` | full container width (`.a-btn.block`) |
|
||||
| `disabled` | `boolean` | `false` | reduced opacity, no pointer events |
|
||||
| `loading` | `boolean` | `false` | inline spinner, disables, width stays stable |
|
||||
| `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | |
|
||||
@@ -97,6 +98,8 @@ Status → color mapping (unknown strings fall back to muted and show the raw st
|
||||
| `indeterminate` | `boolean` | `false` | sliding accent gradient, no % readout |
|
||||
| `label` | `string` | — | text above the track, also the aria-label |
|
||||
| `size` | `'sm' \| 'md'` | `'md'` | sm = 4px, md = 6px track |
|
||||
| `tone` | `'accent' \| 'success' \| 'error'` | `'accent'` | done = success, failed = error (§5) |
|
||||
| `bare` | `boolean` | `false` | track only, no label / % row |
|
||||
|
||||
```svelte
|
||||
<ProgressBar value={job.progress} label={job.name} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onchange?: (next: boolean) => void;
|
||||
}
|
||||
|
||||
let { checked, label, disabled = false, onchange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- §3.10 开关: 34×19 capsule; on = --accent-lo track + ink knob, off = --bg-4. -->
|
||||
<button
|
||||
type="button"
|
||||
class="switch"
|
||||
class:on={checked}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
{disabled}
|
||||
onclick={() => onchange?.(!checked)}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 19px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-4);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition:
|
||||
background-color var(--d-state) ease,
|
||||
border-color var(--d-state) ease;
|
||||
}
|
||||
.switch:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--text-3);
|
||||
transition:
|
||||
transform var(--d-state) var(--ease-out),
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.switch.on {
|
||||
background: var(--accent-lo);
|
||||
border-color: var(--accent-lo);
|
||||
}
|
||||
.switch.on .knob {
|
||||
transform: translateX(15px);
|
||||
background: var(--accent-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -369,7 +369,7 @@
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
@@ -438,7 +438,7 @@
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn.gen {
|
||||
color: var(--accent);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -258,7 +258,7 @@
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
@@ -294,7 +294,7 @@
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn.sm {
|
||||
padding: 6px 12px;
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
@@ -412,7 +412,7 @@
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn.gen {
|
||||
color: var(--accent);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -159,7 +159,7 @@
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #edeef0;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -188,12 +188,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 +203,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
@@ -386,7 +386,7 @@
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
|
||||
@@ -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;
|
||||
@@ -310,7 +310,7 @@
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
@@ -375,7 +375,7 @@
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
export type EndpointKind = 'local' | 'lan' | 'cloud';
|
||||
|
||||
/** 本机 / 局域网 / 云端 by host — drives the blue (local, LAN) vs purple (cloud) tags (§2.1). */
|
||||
export function classifyEndpoint(rawUrl: string): EndpointKind {
|
||||
try {
|
||||
const u = new URL(rawUrl.trim());
|
||||
const h = u.hostname.toLowerCase();
|
||||
if (h === 'localhost' || h === '::1' || h.startsWith('127.')) return 'local';
|
||||
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (m) {
|
||||
const a = Number(m[1]);
|
||||
const b = Number(m[2]);
|
||||
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) return 'lan';
|
||||
if (a === 169 && b === 254) return 'lan';
|
||||
return 'cloud';
|
||||
}
|
||||
if (!h.includes('.') || h.endsWith('.local') || h.endsWith('.lan') || h.endsWith('.internal'))
|
||||
return 'lan';
|
||||
return 'cloud';
|
||||
} catch {
|
||||
return 'cloud';
|
||||
}
|
||||
}
|
||||
|
||||
export function endpointTypeLabel(kind: EndpointKind): string {
|
||||
return kind === 'local'
|
||||
? t('models.typeLocal')
|
||||
: kind === 'lan'
|
||||
? t('models.typeLan')
|
||||
: t('models.typeCloud');
|
||||
}
|
||||
+228
-10
@@ -164,6 +164,8 @@ export const de: Dict = {
|
||||
|
||||
// --- API errors ---
|
||||
'api.unknownError': 'Etwas ist schiefgelaufen',
|
||||
'api.networkError': 'Backend nicht erreichbar – prüfe, ob es läuft, und versuche es erneut',
|
||||
'api.httpError': 'Anfrage fehlgeschlagen (HTTP {status}): {detail}',
|
||||
'playground.openFailed': 'Playground konnte nicht geöffnet werden',
|
||||
'playground.opening': 'Playground wird geöffnet…',
|
||||
'agents.opening': 'Agent wird geöffnet…',
|
||||
@@ -964,6 +966,27 @@ export const de: Dict = {
|
||||
'library.emptyFilterBody': 'Für diesen Filter gibt es noch nichts – probiere „Alle“ oder erzeuge etwas Neues.',
|
||||
|
||||
// --- Settings ---
|
||||
'settings.pageSub': 'Warteschlange, Agenten-Laufzeit und Speicherpfade · LLMs, ComfyUI-Verbindungen und Skills haben eigene Seiten',
|
||||
'settings.secQueue': 'Warteschlange',
|
||||
'settings.secQueueBlurb': 'Parallelität, Wiederholungen, Abfrage und Testlauf',
|
||||
'settings.secAgent': 'Agenten-Laufzeit',
|
||||
'settings.secAgentBlurb': 'Schritte, Kontext, Prompt-Härtung und Shell',
|
||||
'settings.secStorage': 'Speicherpfade',
|
||||
'settings.secStorageBlurb': 'Daten-, Asset- und Arbeitsordner',
|
||||
'settings.elsewhere': 'Weitere Einstellungen',
|
||||
'settings.linkModelsBlurb': 'LLMs und Standardmodell je Agent',
|
||||
'settings.linkConnectionsBlurb': 'Adressen, Verbindungstests und Workflow-Bibliothek',
|
||||
'settings.linkAgentsBlurb': 'Skills und Einzelchats der fünf Agenten',
|
||||
'settings.dryRunTitle': 'Testlauf-Modus',
|
||||
'settings.dryRunHint': 'Aktiv: ComfyUI wird nicht aufgerufen, Aufträge liefern Platzhalter-Bilder/-Videos zum Durchspielen; für echte Generierung ausschalten.',
|
||||
'settings.agentRunSection': 'Agenten-Laufzeit',
|
||||
'settings.agentRunLead': 'Begrenzt Werkzeugschritte pro Lauf und mitgeführten Verlauf.',
|
||||
'settings.rangeHint': 'Bereich {min}–{max}',
|
||||
'settings.loadFailed': 'Einstellungen konnten nicht geladen werden – läuft der Server?',
|
||||
'skillName.character-consistency': 'Figurenkonsistenz',
|
||||
'skillName.h3-video-prompt-enhancer': 'H3-Videoprompt-Verstärker',
|
||||
'skillName.scene-to-video': 'Szene zu Video',
|
||||
'skillName.shot-composer-blockout': 'Einstellungs-Blocking',
|
||||
'settings.loading': 'Einstellungen werden geladen',
|
||||
'settings.allSaved': 'Alle Änderungen gespeichert',
|
||||
'settings.unsaved': 'Ungespeicherte Änderungen',
|
||||
@@ -1010,7 +1033,7 @@ export const de: Dict = {
|
||||
'settings.queueSection': 'Job-Warteschlange',
|
||||
'settings.queueLead': 'Parallelität und Zeitlimits für Generierungsjobs und Agent-Tool-Aufrufe.',
|
||||
'settings.concurrencyField': 'Gleichzeitige Jobs',
|
||||
'settings.pollIntervalField': 'Abfrageintervall (ms)',
|
||||
'settings.pollIntervalField': 'Abfrageintervall (s)',
|
||||
'settings.pollTimeoutField': 'Job-Timeout (s)',
|
||||
'settings.pollTimeoutHint': 'Wie lange ein ComfyUI-Job oder ein Agent-Tool laufen darf. 0 wartet bis zum Ende.',
|
||||
'settings.maxRetriesField': 'Max. Wiederholungen',
|
||||
@@ -1141,7 +1164,7 @@ export const de: Dict = {
|
||||
'models.title': 'Modelle',
|
||||
'models.subtitle': 'Eigene LLMs für Agenten anlegen · Phase 1 nur LLMs; Bild-/Videomodelle folgen später',
|
||||
'models.statusCount': '{n} Modelle · {m} verfügbar',
|
||||
'models.add': '+ Modell hinzufügen',
|
||||
'models.add': 'Modell hinzufügen',
|
||||
'models.tabLlm': 'Sprachmodelle',
|
||||
'models.tabImage': 'Bildmodelle',
|
||||
'models.tabVideo': 'Videomodelle',
|
||||
@@ -1225,6 +1248,7 @@ export const de: Dict = {
|
||||
|
||||
// --- A shell + projects overview (HEL-608) ---
|
||||
'shell.brand': 'Film-Atelier',
|
||||
'shell.brandMark': 'F',
|
||||
'shell.brandHome': 'Zur Projektübersicht',
|
||||
'shell.navAria': 'Hauptnavigation',
|
||||
'shell.navProjects': 'Projekte',
|
||||
@@ -1238,7 +1262,9 @@ export const de: Dict = {
|
||||
'shell.navSettings': 'Einstellungen',
|
||||
'shell.wip': 'In Entwicklung',
|
||||
'shell.wipHint': 'Diese Seite ist noch im Bau und folgt später',
|
||||
'shell.comfyAt': 'ComfyUI · {host}',
|
||||
'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.',
|
||||
@@ -1293,6 +1319,13 @@ export const de: Dict = {
|
||||
'conn.loading': 'Verbindungen werden geladen…',
|
||||
'conn.loadFailed': 'Verbindungen konnten nicht geladen werden',
|
||||
'conn.empty': 'Noch keine ComfyUI-Verbindung — die erste Adresse über „Verbindung hinzufügen“ eintragen.',
|
||||
'conn.targetsTitle': 'Ausführungsziel',
|
||||
'conn.targetImage': 'Einzelbild',
|
||||
'conn.targetVideo': 'Einzelvideo',
|
||||
'conn.targetFlow': 'Aufträge im Arbeitsablauf',
|
||||
'conn.targetChange': 'Ändern',
|
||||
'conn.targetMixed': '{n} Verbindungen',
|
||||
'conn.targetNote': 'Aufträge folgen der Workflow-Bindung; ungebundene Workflows nutzen die Standardverbindung. Bindungen in der Workflow-Bibliothek ändern – Bild-/Videoseite können vor dem Generieren umschalten.',
|
||||
'conn.defaultBadge': 'Standard',
|
||||
'conn.test': 'Verbindung testen',
|
||||
'conn.testing': 'Teste…',
|
||||
@@ -1356,13 +1389,63 @@ export const de: Dict = {
|
||||
'conn.wfParams': '{n} ausfüllbare Parameter',
|
||||
'conn.wfImport': 'Workflow importieren',
|
||||
'conn.wfImporting': 'Importiere…',
|
||||
'conn.wfImported': 'Workflow importiert',
|
||||
'conn.wfNodes': '{n} Knoten',
|
||||
'conn.wfImportedN': 'Workflow importiert – {n} Parameter erkannt',
|
||||
'conn.wfImportedNoParams': 'Workflow importiert, aber keine ausfüllbaren Parameter erkannt: er läuft mit seinen eingebauten Einstellungen',
|
||||
'conn.wfImportFailed': 'Import fehlgeschlagen: {detail}',
|
||||
'conn.wfImportTitle': 'ComfyUI-Workflow importieren (API-Format-JSON)',
|
||||
'conn.wfImportHint': 'Einfügen oder Datei wählen; Name ist Pflicht, die Art entscheidet über Bild- oder Video-Fenster.',
|
||||
'conn.wfJsonInvalid': 'Kein gültiges API-Format-JSON (erwartet: Objekt aus Knoten-ID → {{…class_type…}})',
|
||||
'conn.wfJsonPh': 'ComfyUI-API-JSON hier einfügen oder rechts eine Datei wählen',
|
||||
'conn.wfDeleted': 'Workflow gelöscht',
|
||||
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
|
||||
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
|
||||
// --- Five usages & import confirmation (HEL-636) ---
|
||||
'usage.text2image': 'Text → Image',
|
||||
'usage.image2image': 'Image → Image',
|
||||
'usage.text2video': 'Text → Video',
|
||||
'usage.image2video': 'Image → Video',
|
||||
'usage.ref2video': 'Reference → Video',
|
||||
'usage.pending': 'Usage pending',
|
||||
'wfRole.image': 'Input image',
|
||||
'wfRole.first_frame': 'First frame',
|
||||
'wfRole.last_frame': 'Last frame',
|
||||
'wfRole.reference_image': 'Reference',
|
||||
'wfRole.mask': 'Mask',
|
||||
'conn.wfImportAction': 'Import workflow',
|
||||
'conn.wfConfirmTitle': 'Confirm detection',
|
||||
'conn.wfStepAnalyze': 'Paste & analyze',
|
||||
'conn.wfStepConfirm': 'Confirm & save',
|
||||
'conn.wfChooseFile': 'Choose file',
|
||||
'conn.wfAnalyze': 'Analyze',
|
||||
'conn.wfAnalyzing': 'Analyzing…',
|
||||
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
|
||||
'conn.wfUsageLabel': 'Usage',
|
||||
'conn.wfUsageAuto': 'Auto-detected: {usage}',
|
||||
'conn.wfUsageNeedPick': 'Pick manually',
|
||||
'conn.wfPickUsage': 'Choose a usage first',
|
||||
'conn.wfSlotsLabel': 'Image slots',
|
||||
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
|
||||
'conn.wfBack': 'Back',
|
||||
'conn.wfConfirmSave': 'Confirm & save',
|
||||
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
|
||||
'conn.wfPendingCount': '{n} pending',
|
||||
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
|
||||
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.wfOutputLabel': 'Ausgabeknoten',
|
||||
'conn.wfOutputNeedPick': 'Unbekannte benutzerdefinierte Ausgabeknoten — bitte zuordnen',
|
||||
'conn.wfOutputHint': 'Markieren Sie die Knoten, die tatsächlich Ergebnisse liefern, und wählen Sie je den Medientyp; die Generierung sammelt Bilder/Videos nur aus diesen Knoten.',
|
||||
'conn.wfOutputPick': 'Mindestens einen Ausgabeknoten markieren',
|
||||
'conn.wfOutputKindAria': 'Medientyp des Ausgabeknotens',
|
||||
'conn.wfOutputPendingBadge': 'Ausgabe offen',
|
||||
'conn.wfOutputNeedMap': 'Dieser Workflow enthält nicht erkannte Ausgabeknoten; ordnen Sie sie samt Medientyp zu, um ihn zu aktivieren',
|
||||
'conn.wfOutputApply': 'Ausgabezuordnung speichern',
|
||||
'conn.wfMapCancel': 'Abbrechen',
|
||||
'conn.wfOutputApplied': 'Ausgabezuordnung gespeichert: Knoten {nodes}',
|
||||
'conn.wfOutputCleared': 'Ausgabezuordnung gelöscht — der Workflow ist wieder ausstehend',
|
||||
'conn.missingMaterial': 'Ein dediziertes Qwen Image 2.1-Workflow-JSON fehlt noch im Repo: aus ComfyUI exportieren und hier importieren — der generische Importweg steht bereit.',
|
||||
|
||||
// --- Eigenständige Bild-/Videogenerierung (a3/a4, HEL-612) ---
|
||||
@@ -1370,14 +1453,24 @@ export const de: Dict = {
|
||||
'studio.image.subtitle': 'Bilder mit importierten Workflows erzeugen · Verbindungen & Warteschlange gemeinsam mit ComfyUI-Verbindungen',
|
||||
'studio.video.title': 'Video',
|
||||
'studio.video.subtitle': 'Videos mit importierten Workflows erzeugen · Verbindungen & Warteschlange gemeinsam mit ComfyUI-Verbindungen',
|
||||
'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',
|
||||
'studio.historyCount': '{n} gesamt',
|
||||
'studio.doneAt': 'Fertiggestellt um {time}',
|
||||
'studio.kindImageText': 'Bild',
|
||||
'studio.kindVideoText': 'Video',
|
||||
'studio.loading': 'Lädt…',
|
||||
'studio.connLabel': 'Verbindung',
|
||||
'studio.connQueue': 'Warteschlange {n}',
|
||||
'studio.connUntested': 'Ungetestet',
|
||||
'studio.connOk': 'Letzter Test OK · {ms} ms',
|
||||
'studio.connFail': 'Letzter Test fehlgeschlagen',
|
||||
'studio.connUntested': 'Prüfe…',
|
||||
'studio.connOk': 'Verbunden · {ms} ms',
|
||||
'studio.connFail': 'Nicht erreichbar',
|
||||
'studio.connManage': 'Verbindungen verwalten',
|
||||
'studio.wfLabel': 'Workflow',
|
||||
'studio.wfParams': '{n} ausfüllbare Parameter',
|
||||
@@ -1440,17 +1533,55 @@ export const de: Dict = {
|
||||
'studio.sentToImg2ImgToast': 'Als Referenzbild eingetragen — Parameter prüfen und generieren',
|
||||
'studio.noImg2ImgWf': 'Noch kein Bild-Workflow mit Referenzbild-Eingabe — zuerst einen img2img-Workflow in ComfyUI-Verbindungen importieren',
|
||||
'studio.videoRefStrip': 'Referenz erster / letzter Frame',
|
||||
'studio.historyTitle': 'Verlauf',
|
||||
'studio.historyEmptyImage': 'Noch keine Bildgenerierungen — links Parameter setzen und Generieren drücken',
|
||||
'studio.historyEmptyVideo': 'Noch keine Videogenerierungen — links Parameter setzen und Generieren drücken',
|
||||
'studio.jobName': 'Aufgabe #{id}',
|
||||
'studio.retryLink': 'Erneut versuchen',
|
||||
'studio.retryQueuedToast': 'Aufgabe wieder eingereiht',
|
||||
'studio.retryFailedToast': 'Erneut Einreihen fehlgeschlagen: {detail}',
|
||||
'studio.errNoWorkflow': 'Bitte zuerst einen Workflow wählen',
|
||||
'studio.errBlocked': 'Senden nicht möglich: {reason}',
|
||||
'studio.queuedToast': 'An „{name}“ gesendet, in der Warteschlange',
|
||||
'studio.submitFailedToast': 'Senden fehlgeschlagen: {detail}',
|
||||
'studio.jobFailedToast': 'Generierung fehlgeschlagen: {detail}',
|
||||
'studio.wfNoParamsNote': 'Dieser Workflow hat keine ausfüllbaren Parameter und läuft mit eingebautem Prompt und Einstellungen. Um sie hier auszufüllen, Knotentitel in ComfyUI mit (Input:prompt) usw. markieren und neu importieren.',
|
||||
'studio.wfAutoParamsNote': 'Keine (Input:…)-Titelmarken – {n} Parameter anhand der Knotentypen erkannt; leere Felder behalten die Workflow-Werte.',
|
||||
'studio.promptNotDeclared': 'Dieser Workflow hat keinen Prompt-Knoten',
|
||||
'studio.modeImageEdit': 'Image edit',
|
||||
'studio.modeImageToVideo': 'Image to video',
|
||||
'studio.modeRefToVideo': 'Reference to video',
|
||||
'studio.slotInput': 'Input image',
|
||||
'studio.slotHintOne': 'Required · 1 image',
|
||||
'studio.slotEmptyInput': 'Click or drop the input image',
|
||||
'studio.slotEmptyFirst': 'Click or drop the first frame',
|
||||
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
|
||||
'studio.slotFromResultsShort': 'From results',
|
||||
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
|
||||
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
|
||||
'studio.errNeedInputImage': 'Image edit needs an input image',
|
||||
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
|
||||
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
|
||||
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
|
||||
'studio.refHint': 'Required · 1–{max} · {n} selected',
|
||||
'studio.refAdd': 'Add reference image',
|
||||
'studio.refAddSub': '{n} more · max {max}',
|
||||
'studio.refReplace': 'Replace',
|
||||
'studio.refRemove': 'Remove reference {n}',
|
||||
'studio.refRejectedShort': 'Not added',
|
||||
'studio.refFull': 'Limit of {max} reached — remove one to add another',
|
||||
'studio.refStrip': 'Reference images ({n} used)',
|
||||
'studio.resolutionLabel': 'Output resolution',
|
||||
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
|
||||
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
|
||||
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
|
||||
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
|
||||
'studio.cancelToast': 'Abbruch angefordert',
|
||||
'studio.deleteBtn': 'Löschen',
|
||||
'studio.delTitle': 'Aufgabeneintrag löschen',
|
||||
'studio.delConfirm': 'Die Ergebnisdateien werden mitgelöscht. Löschen?',
|
||||
'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.deletedToast': 'Auftrag und zugehörige Ergebnisdateien 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',
|
||||
@@ -1492,8 +1623,11 @@ export const de: Dict = {
|
||||
'agentsPage.title': 'Agenten',
|
||||
'agentsPage.subtitle': 'Chatte unabhängig mit den fünf Kreativ-Agenten; Skills definieren und zuweisen',
|
||||
'agentsPage.statusChip': '{n} Skills · {m} aktiv',
|
||||
'agentsPage.agentsTitle': 'Agent',
|
||||
'agentsPage.agentsTitle': 'Fünf Agenten',
|
||||
'agentsPage.skillLib': 'Skill-Bibliothek',
|
||||
'agentsPage.skillLibCount': '{n}',
|
||||
'agentsPage.installedTitle': '{name} · installierte Skills',
|
||||
'agentsPage.installedCount': '{n} · {m} aktiv',
|
||||
'agentsPage.newSkill': 'Neuer Skill',
|
||||
'agentsPage.skillLibEmpty': 'Noch keine Skills — lege einen über „Neuer Skill“ an.',
|
||||
'agentsPage.skillsCount': '{n} Skills',
|
||||
@@ -1706,4 +1840,88 @@ export const de: Dict = {
|
||||
'home.errScriptShort': 'Drehbuch zu kurz — prüfe den eingefügten Text (mind. 30 Zeichen)',
|
||||
'home.stageProgress': 'Fünf-Phasen-Fortschritt',
|
||||
'tasks.sourceWorkbench': 'Kreativ-Fluss',
|
||||
'jobst.idSuffix': ' · ComfyUI-Prompt-ID {id}',
|
||||
'jobst.done': 'Fertig',
|
||||
'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.queued': 'Wartet',
|
||||
'jobst.queuedDetail': 'Wartet in der Workbench-Warteschlange auf ComfyUI',
|
||||
'jobst.connecting': 'Verbinde',
|
||||
'jobst.connectingDetail': 'Verbinde mit ComfyUI und lade Eingaben hoch',
|
||||
'jobst.comfyQueued': 'Angenommen',
|
||||
'jobst.comfyQueuedDetail': 'ComfyUI hat den Job angenommen und eingereiht',
|
||||
'jobst.comfyQueuedAhead': 'ComfyUI hat den Job angenommen – {n} davor in der Warteschlange',
|
||||
'jobst.comfyRunning': 'Läuft',
|
||||
'jobst.comfyRunningDetail': 'ComfyUI führt den Job aus',
|
||||
'jobst.saving': 'Speichert',
|
||||
'jobst.savingDetail': 'ComfyUI hat Ergebnisse geliefert, lade herunter',
|
||||
'jobst.failed': 'Fehlgeschlagen',
|
||||
'jobst.cancelled': 'Abgebrochen',
|
||||
'studio.ratioLabel': 'Seitenverhältnis',
|
||||
'studio.megapixelsLabel': 'Megapixel',
|
||||
'studio.outputSize': 'Ausgabe {w} × {h}',
|
||||
'studio.denoiseLabel': 'Entrauschen',
|
||||
'studio.errNumber': '„{label}“ muss eine Zahl sein',
|
||||
'studio.errInteger': '„{label}“ muss eine ganze Zahl sein',
|
||||
'studio.errMin': '„{label}“ muss mindestens {v} sein',
|
||||
'studio.errMax': '„{label}“ darf höchstens {v} sein',
|
||||
'studio.errOption': '„{label}“ ist keine Option des Workflows',
|
||||
'studio.reuseParams': 'Einstellungen übernehmen',
|
||||
'studio.reusedToast': 'Einstellungen dieses Jobs übernommen – anpassen und neu generieren',
|
||||
'studio.reuseMissingWf': 'Der Workflow dieses Jobs existiert nicht mehr',
|
||||
'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',
|
||||
};
|
||||
|
||||
+228
-10
@@ -164,6 +164,8 @@ export const en = {
|
||||
|
||||
// --- API errors ---
|
||||
'api.unknownError': 'Something went wrong',
|
||||
'api.networkError': 'Cannot reach the workbench backend — check that it is running and retry',
|
||||
'api.httpError': 'Request failed (HTTP {status}): {detail}',
|
||||
'playground.openFailed': 'Failed to open playground',
|
||||
'playground.opening': 'Opening playground…',
|
||||
'agents.opening': 'Opening agent…',
|
||||
@@ -964,6 +966,27 @@ export const en = {
|
||||
'library.emptyFilterBody': 'Nothing matches this filter yet — try All or generate something new.',
|
||||
|
||||
// --- Settings ---
|
||||
'settings.pageSub': 'Generation queue, agent runtime and storage paths · LLMs, ComfyUI connections and Skills live on their own pages',
|
||||
'settings.secQueue': 'Queue & generation',
|
||||
'settings.secQueueBlurb': 'Concurrency, retries, polling and dry run',
|
||||
'settings.secAgent': 'Agent runtime',
|
||||
'settings.secAgentBlurb': 'Steps, context, prompt hardening and shell',
|
||||
'settings.secStorage': 'Storage paths',
|
||||
'settings.secStorageBlurb': 'Data, assets and agent workspace folders',
|
||||
'settings.elsewhere': 'Managed elsewhere',
|
||||
'settings.linkModelsBlurb': 'LLMs and default model per agent',
|
||||
'settings.linkConnectionsBlurb': 'Addresses, connection tests and workflow library',
|
||||
'settings.linkAgentsBlurb': 'Skills and standalone chats for the five agents',
|
||||
'settings.dryRunTitle': 'Dry-run mode',
|
||||
'settings.dryRunHint': 'When on, ComfyUI is not called and jobs return placeholder images / videos so you can walk the flow; turn off for real generation.',
|
||||
'settings.agentRunSection': 'Agent runtime',
|
||||
'settings.agentRunLead': 'Caps tool-call steps per run and how much history is carried along.',
|
||||
'settings.rangeHint': 'Range {min}–{max}',
|
||||
'settings.loadFailed': 'Could not load settings — check that the workbench server is running',
|
||||
'skillName.character-consistency': 'Character consistency',
|
||||
'skillName.h3-video-prompt-enhancer': 'H3 video prompt enhancer',
|
||||
'skillName.scene-to-video': 'Scene to video',
|
||||
'skillName.shot-composer-blockout': 'Shot blockout',
|
||||
'settings.loading': 'Loading settings',
|
||||
'settings.allSaved': 'All changes saved',
|
||||
'settings.unsaved': 'Unsaved changes',
|
||||
@@ -1010,7 +1033,7 @@ export const en = {
|
||||
'settings.queueSection': 'Job queue',
|
||||
'settings.queueLead': 'Concurrency and timeouts for generation jobs and agent tool calls.',
|
||||
'settings.concurrencyField': 'Concurrent jobs',
|
||||
'settings.pollIntervalField': 'Poll interval (ms)',
|
||||
'settings.pollIntervalField': 'Poll interval (sec)',
|
||||
'settings.pollTimeoutField': 'Job timeout (s)',
|
||||
'settings.pollTimeoutHint': 'How long a ComfyUI job or one agent tool call may run. 0 waits until it finishes.',
|
||||
'settings.maxRetriesField': 'Max retries',
|
||||
@@ -1143,7 +1166,7 @@ export const en = {
|
||||
'models.title': 'Models',
|
||||
'models.subtitle': 'Add custom LLMs for your agents · phase 1 covers LLMs only; image / video models come later',
|
||||
'models.statusCount': '{n} models · {m} available',
|
||||
'models.add': '+ Add model',
|
||||
'models.add': 'Add model',
|
||||
'models.tabLlm': 'Language models',
|
||||
'models.tabImage': 'Image models',
|
||||
'models.tabVideo': 'Video models',
|
||||
@@ -1227,6 +1250,7 @@ export const en = {
|
||||
|
||||
// --- A shell + projects overview (HEL-608) ---
|
||||
'shell.brand': 'Film Atelier',
|
||||
'shell.brandMark': 'F',
|
||||
'shell.brandHome': 'Back to project overview',
|
||||
'shell.navAria': 'Primary',
|
||||
'shell.navProjects': 'Projects',
|
||||
@@ -1240,7 +1264,9 @@ export const en = {
|
||||
'shell.navSettings': 'Settings',
|
||||
'shell.wip': 'In development',
|
||||
'shell.wipHint': 'This page is under construction and will open later',
|
||||
'shell.comfyAt': 'ComfyUI · {host}',
|
||||
'shell.comfy_ok': '{name} connected',
|
||||
'shell.comfy_fail': '{name} unreachable',
|
||||
'shell.comfy_testing': 'Testing {name}…',
|
||||
'shell.comfyUnset': 'ComfyUI · not configured',
|
||||
'shell.comfyManage': 'Configure the ComfyUI address in Settings',
|
||||
'shell.mobileNotice': 'Please open this workbench in a desktop browser (at least 768px wide). Phones are not supported yet.',
|
||||
@@ -1293,6 +1319,13 @@ export const en = {
|
||||
'conn.loading': 'Loading connections…',
|
||||
'conn.loadFailed': 'Failed to load connections',
|
||||
'conn.empty': 'No ComfyUI connections yet — add your first address with “Add connection”.',
|
||||
'conn.targetsTitle': 'Where jobs run',
|
||||
'conn.targetImage': 'Standalone image',
|
||||
'conn.targetVideo': 'Standalone video',
|
||||
'conn.targetFlow': 'Workbench jobs',
|
||||
'conn.targetChange': 'Change',
|
||||
'conn.targetMixed': '{n} connections',
|
||||
'conn.targetNote': 'Jobs follow each workflow’s binding; unbound workflows use the default connection. Change bindings in the workflow library — the image / video pages can also switch before generating.',
|
||||
'conn.defaultBadge': 'Default',
|
||||
'conn.test': 'Test connection',
|
||||
'conn.testing': 'Testing…',
|
||||
@@ -1356,27 +1389,87 @@ export const en = {
|
||||
'conn.wfParams': '{n} fillable parameters',
|
||||
'conn.wfImport': 'Import workflow',
|
||||
'conn.wfImporting': 'Importing…',
|
||||
'conn.wfImported': 'Workflow imported',
|
||||
'conn.wfNodes': '{n} nodes',
|
||||
'conn.wfImportedN': 'Workflow imported — {n} fillable parameters detected',
|
||||
'conn.wfImportedNoParams': 'Workflow imported, but no fillable parameters were detected: it will run with its built-in settings',
|
||||
'conn.wfImportFailed': 'Import failed: {detail}',
|
||||
'conn.wfImportTitle': 'Import ComfyUI workflow (API-format JSON)',
|
||||
'conn.wfImportHint': 'Paste or pick a file; name is required, the kind decides whether it shows in the image or video window.',
|
||||
'conn.wfJsonInvalid': 'Not valid API-format JSON (expected an object of node id to class_type)',
|
||||
'conn.wfJsonPh': 'Paste the ComfyUI API-format JSON here, or pick a file',
|
||||
'conn.wfDeleted': 'Workflow deleted',
|
||||
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
|
||||
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
|
||||
// --- Five usages & import confirmation (HEL-636) ---
|
||||
'usage.text2image': 'Text → Image',
|
||||
'usage.image2image': 'Image → Image',
|
||||
'usage.text2video': 'Text → Video',
|
||||
'usage.image2video': 'Image → Video',
|
||||
'usage.ref2video': 'Reference → Video',
|
||||
'usage.pending': 'Usage pending',
|
||||
'wfRole.image': 'Input image',
|
||||
'wfRole.first_frame': 'First frame',
|
||||
'wfRole.last_frame': 'Last frame',
|
||||
'wfRole.reference_image': 'Reference',
|
||||
'wfRole.mask': 'Mask',
|
||||
'conn.wfImportAction': 'Import workflow',
|
||||
'conn.wfConfirmTitle': 'Confirm detection',
|
||||
'conn.wfStepAnalyze': 'Paste & analyze',
|
||||
'conn.wfStepConfirm': 'Confirm & save',
|
||||
'conn.wfChooseFile': 'Choose file',
|
||||
'conn.wfAnalyze': 'Analyze',
|
||||
'conn.wfAnalyzing': 'Analyzing…',
|
||||
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
|
||||
'conn.wfUsageLabel': 'Usage',
|
||||
'conn.wfUsageAuto': 'Auto-detected: {usage}',
|
||||
'conn.wfUsageNeedPick': 'Pick manually',
|
||||
'conn.wfPickUsage': 'Choose a usage first',
|
||||
'conn.wfSlotsLabel': 'Image slots',
|
||||
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
|
||||
'conn.wfBack': 'Back',
|
||||
'conn.wfConfirmSave': 'Confirm & save',
|
||||
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
|
||||
'conn.wfPendingCount': '{n} pending',
|
||||
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
|
||||
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.wfOutputLabel': 'Output nodes',
|
||||
'conn.wfOutputNeedPick': 'Unrecognized custom output nodes — designate them',
|
||||
'conn.wfOutputHint': 'Check the nodes that actually produce results and pick each one’s media type; generation harvests images/videos only from these nodes.',
|
||||
'conn.wfOutputPick': 'Check at least one output node',
|
||||
'conn.wfOutputKindAria': 'Output node media type',
|
||||
'conn.wfOutputPendingBadge': 'Outputs unset',
|
||||
'conn.wfOutputNeedMap': 'This workflow has unrecognized output nodes; designate them and their media type to enable it',
|
||||
'conn.wfOutputApply': 'Save output mapping',
|
||||
'conn.wfMapCancel': 'Cancel',
|
||||
'conn.wfOutputApplied': 'Output mapping saved: node {nodes}',
|
||||
'conn.wfOutputCleared': 'Output mapping cleared — the workflow is pending again',
|
||||
'conn.missingMaterial': 'A dedicated Qwen Image 2.1 workflow JSON is not in the repo yet: export it from ComfyUI and import it here — the generic import path is ready.',
|
||||
// --- Standalone image / video generation (a3/a4, HEL-612) ---
|
||||
'studio.image.title': 'Image',
|
||||
'studio.image.subtitle': 'Generate images with imported workflows · shares connections & queue with ComfyUI Connections',
|
||||
'studio.video.title': 'Video',
|
||||
'studio.video.subtitle': 'Generate videos with imported workflows · shares connections & queue with ComfyUI Connections',
|
||||
'studio.genParams': 'Parameters',
|
||||
'studio.modeTextToImage': 'Text to image',
|
||||
'studio.modeTextToVideo': 'Text to video',
|
||||
'studio.chipIdle': '{name} · queue idle',
|
||||
'studio.chipBusy': '{name} · {n} in queue',
|
||||
'studio.video.resultsTitle': 'Preview',
|
||||
'studio.image.historyTitle': 'Image history',
|
||||
'studio.video.historyTitle': 'Video history',
|
||||
'studio.historyCount': '{n} total',
|
||||
'studio.doneAt': 'Done at {time}',
|
||||
'studio.kindImageText': 'image',
|
||||
'studio.kindVideoText': 'video',
|
||||
'studio.loading': 'Loading…',
|
||||
'studio.connLabel': 'Connection',
|
||||
'studio.connQueue': 'Queue {n}',
|
||||
'studio.connUntested': 'Untested',
|
||||
'studio.connOk': 'Last test passed · {ms} ms',
|
||||
'studio.connFail': 'Last test failed',
|
||||
'studio.connUntested': 'Checking…',
|
||||
'studio.connOk': 'Connected · {ms} ms',
|
||||
'studio.connFail': 'Unreachable',
|
||||
'studio.connManage': 'Manage connections',
|
||||
'studio.wfLabel': 'Workflow',
|
||||
'studio.wfParams': '{n} fillable params',
|
||||
@@ -1439,17 +1532,55 @@ export const en = {
|
||||
'studio.sentToImg2ImgToast': 'Filled in as the reference image — check the params and generate',
|
||||
'studio.noImg2ImgWf': 'No image workflow declares a reference-image input yet — import an img2img workflow in ComfyUI Connections first',
|
||||
'studio.videoRefStrip': 'First / last frame reference',
|
||||
'studio.historyTitle': 'History',
|
||||
'studio.historyEmptyImage': 'No image generations yet — set the parameters on the left and press Generate',
|
||||
'studio.historyEmptyVideo': 'No video generations yet — set the parameters on the left and press Generate',
|
||||
'studio.jobName': 'Task #{id}',
|
||||
'studio.retryLink': 'Retry',
|
||||
'studio.retryQueuedToast': 'Task re-queued',
|
||||
'studio.retryFailedToast': 'Re-queue failed: {detail}',
|
||||
'studio.errNoWorkflow': 'Choose a workflow first',
|
||||
'studio.errBlocked': 'Cannot submit: {reason}',
|
||||
'studio.queuedToast': 'Submitted to “{name}”, queued',
|
||||
'studio.submitFailedToast': 'Submit failed: {detail}',
|
||||
'studio.jobFailedToast': 'Generation failed: {detail}',
|
||||
'studio.wfNoParamsNote': 'This workflow has no fillable parameters and will run with its built-in prompt and settings. To fill them here, tag node titles with (Input:prompt) etc. in ComfyUI and re-import.',
|
||||
'studio.wfAutoParamsNote': 'No (Input:…) title tags found — {n} parameters were detected from node types; blank fields keep the workflow values.',
|
||||
'studio.promptNotDeclared': 'This workflow has no prompt node — nothing to fill',
|
||||
'studio.modeImageEdit': 'Image edit',
|
||||
'studio.modeImageToVideo': 'Image to video',
|
||||
'studio.modeRefToVideo': 'Reference to video',
|
||||
'studio.slotInput': 'Input image',
|
||||
'studio.slotHintOne': 'Required · 1 image',
|
||||
'studio.slotEmptyInput': 'Click or drop the input image',
|
||||
'studio.slotEmptyFirst': 'Click or drop the first frame',
|
||||
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
|
||||
'studio.slotFromResultsShort': 'From results',
|
||||
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
|
||||
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
|
||||
'studio.errNeedInputImage': 'Image edit needs an input image',
|
||||
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
|
||||
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
|
||||
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
|
||||
'studio.refHint': 'Required · 1–{max} · {n} selected',
|
||||
'studio.refAdd': 'Add reference image',
|
||||
'studio.refAddSub': '{n} more · max {max}',
|
||||
'studio.refReplace': 'Replace',
|
||||
'studio.refRemove': 'Remove reference {n}',
|
||||
'studio.refRejectedShort': 'Not added',
|
||||
'studio.refFull': 'Limit of {max} reached — remove one to add another',
|
||||
'studio.refStrip': 'Reference images ({n} used)',
|
||||
'studio.resolutionLabel': 'Output resolution',
|
||||
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
|
||||
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
|
||||
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
|
||||
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
|
||||
'studio.cancelToast': 'Cancel requested',
|
||||
'studio.deleteBtn': 'Delete',
|
||||
'studio.delTitle': 'Delete task record',
|
||||
'studio.delConfirm': 'The result files will be removed together with the record. Delete it?',
|
||||
'studio.delConfirm': 'Deleting also removes this job’s result files; the history cannot be restored. Delete?',
|
||||
'studio.delTitleJob': 'Delete job “{name}”',
|
||||
'studio.delConfirmMulti': 'This job produced {n} results. Deleting removes the whole job record and all {n} result files; this cannot be undone. Delete?',
|
||||
'studio.deletedToast': 'Job deleted together with its result files',
|
||||
'studio.wfEmptyTitle': 'No {kind} workflows yet',
|
||||
'studio.wfEmptyBody': "Enable ComfyUI's developer mode, export the workflow as API JSON, then import it on the ComfyUI Connections page as {kind}.",
|
||||
'studio.wfEmptyGo': 'Import in ComfyUI Connections',
|
||||
@@ -1491,8 +1622,11 @@ export const en = {
|
||||
'agentsPage.title': 'Agents',
|
||||
'agentsPage.subtitle': 'Chat independently with the five creation agents; define and assign Skills',
|
||||
'agentsPage.statusChip': '{n} Skills · {m} enabled',
|
||||
'agentsPage.agentsTitle': 'Agent',
|
||||
'agentsPage.agentsTitle': 'Five agents',
|
||||
'agentsPage.skillLib': 'Skill library',
|
||||
'agentsPage.skillLibCount': '{n}',
|
||||
'agentsPage.installedTitle': '{name} · installed Skills',
|
||||
'agentsPage.installedCount': '{n} · {m} on',
|
||||
'agentsPage.newSkill': 'New Skill',
|
||||
'agentsPage.skillLibEmpty': 'No Skills yet — create one with “New Skill” above.',
|
||||
'agentsPage.skillsCount': '{n} Skills',
|
||||
@@ -1705,6 +1839,90 @@ export const en = {
|
||||
'home.errScriptShort': 'The script looks too short — check the paste is complete (min 30 characters)',
|
||||
'home.stageProgress': 'Five-stage progress',
|
||||
'tasks.sourceWorkbench': 'Creation flow',
|
||||
'jobst.idSuffix': ' · ComfyUI prompt ID {id}',
|
||||
'jobst.done': 'Done',
|
||||
'jobst.doneDetail': 'ComfyUI finished and the results are saved',
|
||||
'jobst.dryRun': 'Placeholder',
|
||||
'jobst.dryRunDetail': 'Placeholder only: Dry-run is on, ComfyUI was not called',
|
||||
'jobst.noOutput': 'No output',
|
||||
'jobst.noOutputDetail': 'The job ended without any result file — not a finished generation',
|
||||
'jobst.queued': 'Queued',
|
||||
'jobst.queuedDetail': 'Waiting in the workbench queue to be sent to ComfyUI',
|
||||
'jobst.connecting': 'Connecting',
|
||||
'jobst.connectingDetail': 'Connecting to ComfyUI and uploading inputs',
|
||||
'jobst.comfyQueued': 'Accepted',
|
||||
'jobst.comfyQueuedDetail': 'ComfyUI accepted the job and queued it',
|
||||
'jobst.comfyQueuedAhead': 'ComfyUI accepted the job — {n} ahead in its queue',
|
||||
'jobst.comfyRunning': 'Running',
|
||||
'jobst.comfyRunningDetail': 'ComfyUI is running the job',
|
||||
'jobst.saving': 'Saving',
|
||||
'jobst.savingDetail': 'ComfyUI returned results, downloading them',
|
||||
'jobst.failed': 'Failed',
|
||||
'jobst.cancelled': 'Cancelled',
|
||||
'studio.ratioLabel': 'Aspect ratio',
|
||||
'studio.megapixelsLabel': 'Megapixels',
|
||||
'studio.outputSize': 'Output {w} × {h}',
|
||||
'studio.denoiseLabel': 'Denoise',
|
||||
'studio.errNumber': '“{label}” must be a number',
|
||||
'studio.errInteger': '“{label}” must be a whole number',
|
||||
'studio.errMin': '“{label}” must be at least {v}',
|
||||
'studio.errMax': '“{label}” must be at most {v}',
|
||||
'studio.errOption': '“{label}” is not one of the workflow\'s options',
|
||||
'studio.reuseParams': 'Reuse settings',
|
||||
'studio.reusedToast': 'Settings from this job are filled in — adjust and generate again',
|
||||
'studio.reuseMissingWf': 'The workflow this job used no longer exists',
|
||||
'studio.doneToast': 'Done: {n} file(s) (ComfyUI prompt ID {id})',
|
||||
'studio.dryRunToast': 'Placeholder only: Dry-run is on, ComfyUI was not called',
|
||||
'studio.dryRunNote': 'Dry-run is on: Generate only writes placeholder files and never calls ComfyUI. Turn it off in Settings.',
|
||||
// --- HEL-643: workflow import check / real test / media groups ---
|
||||
'studio.errVideoFormat': '"{name}" is not a supported video format — use MP4 / WebM / MOV',
|
||||
'studio.errAudioFormat': '"{name}" is not a supported audio format — use MP3 / WAV / M4A',
|
||||
'studio.errMediaSize': '"{name}" exceeds the size limit — compress it first',
|
||||
'studio.errNeedRefMedia': '"{label}" needs at least {n} clips',
|
||||
'studio.refHintMedia': 'Required · 1–{max} clips · {n} selected',
|
||||
'studio.refAddMedia': 'Add {kind}',
|
||||
'studio.refAddSubMedia': '{n} more · max {max}',
|
||||
'studio.refRemoveMedia': 'Remove {kind} {n}',
|
||||
'studio.refFullMedia': 'Limit of {max} clips reached — remove one to add another',
|
||||
'wfMedia.h3Limits': 'MiniMax H3 Ref2VA official limits: up to 9 reference images, 3 reference videos and 3 reference audios; each clip 2–15s with video/audio totals ≤15s each; audio cannot be the only reference; at most 12 reference files in total.',
|
||||
'wfMedia.h3TotalOver': '{n} reference files selected — above the official cap of 12; generation may fail',
|
||||
'wfMedia.h3AudioAlone': 'Audio cannot be the only reference — add at least one reference image or video',
|
||||
'conn.wfCheckTitle': 'Connection compatibility check',
|
||||
'conn.wfCheckEndpoint': 'Target: {name}',
|
||||
'conn.wfCheckBtn': 'Re-check',
|
||||
'conn.wfChecking': 'Checking…',
|
||||
'conn.wfCheckOk': 'This connection has the nodes and models this workflow needs',
|
||||
'conn.wfCheckProblems': '{n} missing node(s)/model(s) found',
|
||||
'conn.wfCheckUnreachable': 'Cannot verify (ComfyUI unreachable) — saving stays possible; re-check after fixing the connection',
|
||||
'conn.wfCheckSaveHint': 'You can still save the draft (it is not marked generatable); install the missing pieces and re-check.',
|
||||
'conn.wfTestBtn': 'Test',
|
||||
'conn.wfLastTest': 'Last test',
|
||||
'conn.wfTestPassed': '{time} passed ({n} files)',
|
||||
'conn.wfTestRunning': '{time} running',
|
||||
'conn.wfTestFailed': '{time} failed',
|
||||
'wft.title': 'Test workflow',
|
||||
'wft.connection': 'Target connection',
|
||||
'wft.costNote': 'A test submits one real job to the selected ComfyUI and uses its VRAM and queue time.',
|
||||
'wft.submit': 'Start test',
|
||||
'wft.confirmTitle': 'Confirm a real run',
|
||||
'wft.confirmBody': 'One real job will be submitted to "{endpoint}" ({n} jobs in queue). The test only passes when at least one downloadable output of the right media type comes back.',
|
||||
'wft.running': 'Testing…',
|
||||
'wft.cancel': 'Cancel test',
|
||||
'wft.passed': 'Test passed: {n} file(s) produced',
|
||||
'wft.passedShort': 'Passed ({n} files)',
|
||||
'wft.failed': 'Test failed',
|
||||
'wft.failedShort': 'Failed',
|
||||
'wft.cancelledShort': 'Cancelled',
|
||||
'wft.reusedToast': 'A test is already running for this workflow — attached to it',
|
||||
'wft.history': 'Test records ({n})',
|
||||
'wft.clear': 'Clear test records',
|
||||
'wft.cleared': 'Test records cleared',
|
||||
'wft.never': 'Never tested',
|
||||
'wft.download': 'Download',
|
||||
'wft.errRequired': 'Please fill in "{label}"',
|
||||
'wft.errListMin': '"{label}" needs at least {n} {unit}',
|
||||
'tasks.testBadge': 'Test',
|
||||
'tasks.sourceWorkflowTest': 'Workflow test',
|
||||
};
|
||||
|
||||
export type Dict = typeof en;
|
||||
|
||||
+228
-10
@@ -164,6 +164,8 @@ export const es: Dict = {
|
||||
|
||||
// --- API errors ---
|
||||
'api.unknownError': 'Algo salió mal',
|
||||
'api.networkError': 'No se puede conectar con el backend; comprueba que esté en marcha y reintenta',
|
||||
'api.httpError': 'La solicitud falló (HTTP {status}): {detail}',
|
||||
'playground.openFailed': 'No se pudo abrir el playground',
|
||||
'playground.opening': 'Abriendo el playground…',
|
||||
'agents.opening': 'Abriendo el agente…',
|
||||
@@ -964,6 +966,27 @@ export const es: Dict = {
|
||||
'library.emptyFilterBody': 'Nada coincide con este filtro todavía – prueba «Todo» o genera algo nuevo.',
|
||||
|
||||
// --- Settings ---
|
||||
'settings.pageSub': 'Cola de generación, ejecución de agentes y rutas · LLM, conexiones ComfyUI y Skills tienen su propia página',
|
||||
'settings.secQueue': 'Cola y generación',
|
||||
'settings.secQueueBlurb': 'Concurrencia, reintentos, sondeo y prueba',
|
||||
'settings.secAgent': 'Ejecución de agentes',
|
||||
'settings.secAgentBlurb': 'Pasos, contexto, refuerzo de prompts y shell',
|
||||
'settings.secStorage': 'Rutas de almacenamiento',
|
||||
'settings.secStorageBlurb': 'Carpetas de datos, recursos y espacio de trabajo',
|
||||
'settings.elsewhere': 'Gestionado en otras páginas',
|
||||
'settings.linkModelsBlurb': 'LLM y modelo por defecto de cada agente',
|
||||
'settings.linkConnectionsBlurb': 'Direcciones, pruebas de conexión y biblioteca de flujos',
|
||||
'settings.linkAgentsBlurb': 'Skills y chats independientes de los cinco agentes',
|
||||
'settings.dryRunTitle': 'Modo de prueba',
|
||||
'settings.dryRunHint': 'Activado: no se llama a ComfyUI y las tareas devuelven imágenes / vídeos de muestra; desactívalo para generar de verdad.',
|
||||
'settings.agentRunSection': 'Ejecución de agentes',
|
||||
'settings.agentRunLead': 'Limita los pasos de herramientas por ejecución y el historial incluido.',
|
||||
'settings.rangeHint': 'Rango {min}–{max}',
|
||||
'settings.loadFailed': 'No se pudieron cargar los ajustes: comprueba que el servidor esté activo',
|
||||
'skillName.character-consistency': 'Coherencia de personajes',
|
||||
'skillName.h3-video-prompt-enhancer': 'Mejora de prompts de vídeo H3',
|
||||
'skillName.scene-to-video': 'Escena a vídeo',
|
||||
'skillName.shot-composer-blockout': 'Encuadre previo del plano',
|
||||
'settings.loading': 'Cargando ajustes',
|
||||
'settings.allSaved': 'Todos los cambios guardados',
|
||||
'settings.unsaved': 'Cambios sin guardar',
|
||||
@@ -1010,7 +1033,7 @@ export const es: Dict = {
|
||||
'settings.queueSection': 'Cola de tareas',
|
||||
'settings.queueLead': 'Concurrencia y tiempos de espera de las tareas de generación y las llamadas de herramientas del agente.',
|
||||
'settings.concurrencyField': 'Tareas simultáneas',
|
||||
'settings.pollIntervalField': 'Intervalo de sondeo (ms)',
|
||||
'settings.pollIntervalField': 'Intervalo de sondeo (s)',
|
||||
'settings.pollTimeoutField': 'Tiempo límite de tarea (s)',
|
||||
'settings.pollTimeoutHint': 'Cuánto puede durar un trabajo de ComfyUI o una llamada de herramienta del agente. 0 espera hasta que termine.',
|
||||
'settings.maxRetriesField': 'Reintentos máximos',
|
||||
@@ -1141,7 +1164,7 @@ export const es: Dict = {
|
||||
'models.title': 'Modelos',
|
||||
'models.subtitle': 'Añade LLM personalizados para tus agentes · la fase 1 solo incluye LLM; los modelos de imagen / vídeo llegarán después',
|
||||
'models.statusCount': '{n} modelos · {m} disponibles',
|
||||
'models.add': '+ Añadir modelo',
|
||||
'models.add': 'Añadir modelo',
|
||||
'models.tabLlm': 'Modelos de lenguaje',
|
||||
'models.tabImage': 'Modelos de imagen',
|
||||
'models.tabVideo': 'Modelos de vídeo',
|
||||
@@ -1225,6 +1248,7 @@ export const es: Dict = {
|
||||
|
||||
// --- A shell + projects overview (HEL-608) ---
|
||||
'shell.brand': 'Atelier de Cine',
|
||||
'shell.brandMark': 'A',
|
||||
'shell.brandHome': 'Volver al resumen de proyectos',
|
||||
'shell.navAria': 'Navegación principal',
|
||||
'shell.navProjects': 'Proyectos',
|
||||
@@ -1238,7 +1262,9 @@ export const es: Dict = {
|
||||
'shell.navSettings': 'Ajustes',
|
||||
'shell.wip': 'En desarrollo',
|
||||
'shell.wipHint': 'Esta página está en construcción y llegará más adelante',
|
||||
'shell.comfyAt': 'ComfyUI · {host}',
|
||||
'shell.comfy_ok': '{name} conectado',
|
||||
'shell.comfy_fail': '{name} sin conexión',
|
||||
'shell.comfy_testing': 'Probando {name}…',
|
||||
'shell.comfyUnset': 'ComfyUI · sin configurar',
|
||||
'shell.comfyManage': 'Configura la dirección de ComfyUI en Ajustes',
|
||||
'shell.mobileNotice': 'Abre este banco de trabajo en un navegador de escritorio (mínimo 768 px); los móviles aún no se admiten.',
|
||||
@@ -1293,6 +1319,13 @@ export const es: Dict = {
|
||||
'conn.loading': 'Cargando conexiones…',
|
||||
'conn.loadFailed': 'Error al cargar las conexiones',
|
||||
'conn.empty': 'Aún no hay conexiones ComfyUI; añade la primera dirección con «Añadir conexión».',
|
||||
'conn.targetsTitle': 'Destino de las tareas',
|
||||
'conn.targetImage': 'Imagen independiente',
|
||||
'conn.targetVideo': 'Vídeo independiente',
|
||||
'conn.targetFlow': 'Tareas del flujo creativo',
|
||||
'conn.targetChange': 'Cambiar',
|
||||
'conn.targetMixed': '{n} conexiones',
|
||||
'conn.targetNote': 'Las tareas siguen la conexión de cada flujo; los no vinculados usan la predeterminada. Cambia vínculos en la biblioteca de flujos; las páginas de imagen / vídeo también permiten cambiar antes de generar.',
|
||||
'conn.defaultBadge': 'Predeterminada',
|
||||
'conn.test': 'Probar conexión',
|
||||
'conn.testing': 'Probando…',
|
||||
@@ -1356,13 +1389,63 @@ export const es: Dict = {
|
||||
'conn.wfParams': '{n} parámetros rellenables',
|
||||
'conn.wfImport': 'Importar flujo',
|
||||
'conn.wfImporting': 'Importando…',
|
||||
'conn.wfImported': 'Flujo importado',
|
||||
'conn.wfNodes': '{n} nodos',
|
||||
'conn.wfImportedN': 'Flujo importado: {n} parámetros detectados',
|
||||
'conn.wfImportedNoParams': 'Flujo importado, pero sin parámetros rellenables: se ejecutará con su configuración interna',
|
||||
'conn.wfImportFailed': 'Error al importar: {detail}',
|
||||
'conn.wfImportTitle': 'Importar flujo de ComfyUI (JSON en formato API)',
|
||||
'conn.wfImportHint': 'Pega o elige un archivo; el nombre es obligatorio y el tipo decide si aparece en la ventana de imagen o vídeo.',
|
||||
'conn.wfJsonInvalid': 'JSON no válido en formato API (se esperaba un objeto id de nodo → {{…class_type…}})',
|
||||
'conn.wfJsonPh': 'Pega aquí el JSON en formato API de ComfyUI o elige un archivo',
|
||||
'conn.wfDeleted': 'Flujo eliminado',
|
||||
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
|
||||
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
|
||||
// --- Five usages & import confirmation (HEL-636) ---
|
||||
'usage.text2image': 'Text → Image',
|
||||
'usage.image2image': 'Image → Image',
|
||||
'usage.text2video': 'Text → Video',
|
||||
'usage.image2video': 'Image → Video',
|
||||
'usage.ref2video': 'Reference → Video',
|
||||
'usage.pending': 'Usage pending',
|
||||
'wfRole.image': 'Input image',
|
||||
'wfRole.first_frame': 'First frame',
|
||||
'wfRole.last_frame': 'Last frame',
|
||||
'wfRole.reference_image': 'Reference',
|
||||
'wfRole.mask': 'Mask',
|
||||
'conn.wfImportAction': 'Import workflow',
|
||||
'conn.wfConfirmTitle': 'Confirm detection',
|
||||
'conn.wfStepAnalyze': 'Paste & analyze',
|
||||
'conn.wfStepConfirm': 'Confirm & save',
|
||||
'conn.wfChooseFile': 'Choose file',
|
||||
'conn.wfAnalyze': 'Analyze',
|
||||
'conn.wfAnalyzing': 'Analyzing…',
|
||||
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
|
||||
'conn.wfUsageLabel': 'Usage',
|
||||
'conn.wfUsageAuto': 'Auto-detected: {usage}',
|
||||
'conn.wfUsageNeedPick': 'Pick manually',
|
||||
'conn.wfPickUsage': 'Choose a usage first',
|
||||
'conn.wfSlotsLabel': 'Image slots',
|
||||
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
|
||||
'conn.wfBack': 'Back',
|
||||
'conn.wfConfirmSave': 'Confirm & save',
|
||||
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
|
||||
'conn.wfPendingCount': '{n} pending',
|
||||
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
|
||||
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.wfOutputLabel': 'Nodos de salida',
|
||||
'conn.wfOutputNeedPick': 'Nodos de salida personalizados no reconocidos: asígnalos',
|
||||
'conn.wfOutputHint': 'Marca los nodos que realmente producen resultados y elige el tipo de medio de cada uno; la generación solo extrae imágenes/vídeos de estos nodos.',
|
||||
'conn.wfOutputPick': 'Marca al menos un nodo de salida',
|
||||
'conn.wfOutputKindAria': 'Tipo de medio del nodo de salida',
|
||||
'conn.wfOutputPendingBadge': 'Salida pendiente',
|
||||
'conn.wfOutputNeedMap': 'Este flujo tiene nodos de salida no reconocidos; asígnalos y su tipo de medio para activarlo',
|
||||
'conn.wfOutputApply': 'Guardar mapeo de salida',
|
||||
'conn.wfMapCancel': 'Cancelar',
|
||||
'conn.wfOutputApplied': 'Mapeo de salida guardado: nodo {nodes}',
|
||||
'conn.wfOutputCleared': 'Mapeo de salida borrado: el flujo vuelve a estar pendiente',
|
||||
'conn.missingMaterial': 'Aún falta en el repo un JSON dedicado de Qwen Image 2.1: expórtalo desde ComfyUI e impórtalo aquí; la vía genérica ya está lista.',
|
||||
|
||||
// --- Generación independiente de imagen / vídeo (a3/a4, HEL-612) ---
|
||||
@@ -1370,14 +1453,24 @@ export const es: Dict = {
|
||||
'studio.image.subtitle': 'Genera imágenes con flujos importados · conexiones y cola compartidas con Conexiones ComfyUI',
|
||||
'studio.video.title': 'Vídeo',
|
||||
'studio.video.subtitle': 'Genera vídeos con flujos importados · conexiones y cola compartidas con Conexiones ComfyUI',
|
||||
'studio.genParams': 'Parámetros',
|
||||
'studio.modeTextToImage': 'Texto a imagen',
|
||||
'studio.modeTextToVideo': 'Texto a vídeo',
|
||||
'studio.chipIdle': '{name} · cola libre',
|
||||
'studio.chipBusy': '{name} · {n} en cola',
|
||||
'studio.video.resultsTitle': 'Vista previa',
|
||||
'studio.image.historyTitle': 'Historial de imágenes',
|
||||
'studio.video.historyTitle': 'Historial de vídeos',
|
||||
'studio.historyCount': '{n} en total',
|
||||
'studio.doneAt': 'Completado a las {time}',
|
||||
'studio.kindImageText': 'imagen',
|
||||
'studio.kindVideoText': 'vídeo',
|
||||
'studio.loading': 'Cargando…',
|
||||
'studio.connLabel': 'Conexión',
|
||||
'studio.connQueue': 'Cola {n}',
|
||||
'studio.connUntested': 'Sin probar',
|
||||
'studio.connOk': 'Última prueba OK · {ms} ms',
|
||||
'studio.connFail': 'Última prueba fallida',
|
||||
'studio.connUntested': 'Comprobando…',
|
||||
'studio.connOk': 'Conectado · {ms} ms',
|
||||
'studio.connFail': 'Sin conexión',
|
||||
'studio.connManage': 'Gestionar conexiones',
|
||||
'studio.wfLabel': 'Flujo',
|
||||
'studio.wfParams': '{n} parámetros rellenables',
|
||||
@@ -1440,17 +1533,55 @@ export const es: Dict = {
|
||||
'studio.sentToImg2ImgToast': 'Añadido como referencia; revisa los parámetros y genera',
|
||||
'studio.noImg2ImgWf': 'Ningún flujo de imagen declara una entrada de referencia todavía; importa antes un flujo img2img en Conexiones ComfyUI',
|
||||
'studio.videoRefStrip': 'Referencia primer / último fotograma',
|
||||
'studio.historyTitle': 'Historial',
|
||||
'studio.historyEmptyImage': 'Aún no hay generaciones de imagen; ajusta los parámetros y pulsa Generar',
|
||||
'studio.historyEmptyVideo': 'Aún no hay generaciones de vídeo; ajusta los parámetros y pulsa Generar',
|
||||
'studio.jobName': 'Tarea #{id}',
|
||||
'studio.retryLink': 'Reintentar',
|
||||
'studio.retryQueuedToast': 'Tarea vuelta a la cola',
|
||||
'studio.retryFailedToast': 'Error al reencolar: {detail}',
|
||||
'studio.errNoWorkflow': 'Elige primero un flujo',
|
||||
'studio.errBlocked': 'No se puede enviar: {reason}',
|
||||
'studio.queuedToast': 'Enviado a «{name}», en cola',
|
||||
'studio.submitFailedToast': 'Error al enviar: {detail}',
|
||||
'studio.jobFailedToast': 'La generación falló: {detail}',
|
||||
'studio.wfNoParamsNote': 'Este flujo no tiene parámetros rellenables y se ejecutará con su prompt y ajustes internos. Para rellenarlos aquí, etiqueta los títulos de nodo con (Input:prompt) etc. en ComfyUI y vuelve a importar.',
|
||||
'studio.wfAutoParamsNote': 'Sin etiquetas (Input:…): se detectaron {n} parámetros por tipo de nodo; los campos vacíos conservan los valores del flujo.',
|
||||
'studio.promptNotDeclared': 'Este flujo no tiene nodo de prompt',
|
||||
'studio.modeImageEdit': 'Image edit',
|
||||
'studio.modeImageToVideo': 'Image to video',
|
||||
'studio.modeRefToVideo': 'Reference to video',
|
||||
'studio.slotInput': 'Input image',
|
||||
'studio.slotHintOne': 'Required · 1 image',
|
||||
'studio.slotEmptyInput': 'Click or drop the input image',
|
||||
'studio.slotEmptyFirst': 'Click or drop the first frame',
|
||||
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
|
||||
'studio.slotFromResultsShort': 'From results',
|
||||
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
|
||||
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
|
||||
'studio.errNeedInputImage': 'Image edit needs an input image',
|
||||
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
|
||||
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
|
||||
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
|
||||
'studio.refHint': 'Required · 1–{max} · {n} selected',
|
||||
'studio.refAdd': 'Add reference image',
|
||||
'studio.refAddSub': '{n} more · max {max}',
|
||||
'studio.refReplace': 'Replace',
|
||||
'studio.refRemove': 'Remove reference {n}',
|
||||
'studio.refRejectedShort': 'Not added',
|
||||
'studio.refFull': 'Limit of {max} reached — remove one to add another',
|
||||
'studio.refStrip': 'Reference images ({n} used)',
|
||||
'studio.resolutionLabel': 'Output resolution',
|
||||
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
|
||||
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
|
||||
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
|
||||
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
|
||||
'studio.cancelToast': 'Cancelación solicitada',
|
||||
'studio.deleteBtn': 'Eliminar',
|
||||
'studio.delTitle': 'Eliminar registro de tarea',
|
||||
'studio.delConfirm': 'Los archivos de resultados se eliminarán junto con el registro. ¿Eliminar?',
|
||||
'studio.delConfirm': 'Al eliminar también se quitan los archivos de resultados de esta tarea; el historial no se puede restaurar. ¿Eliminar?',
|
||||
'studio.delTitleJob': 'Eliminar tarea «{name}»',
|
||||
'studio.delConfirmMulti': 'Esta tarea produjo {n} resultados. Al eliminarla se quita el registro completo y los {n} archivos de resultados; no se puede deshacer. ¿Eliminar?',
|
||||
'studio.deletedToast': 'Tarea eliminada junto con sus archivos de resultados',
|
||||
'studio.wfEmptyTitle': 'Aún no hay flujos de {kind}',
|
||||
'studio.wfEmptyBody': 'Activa el modo desarrollador de ComfyUI, exporta el flujo como JSON API e impórtalo en Conexiones ComfyUI como {kind}.',
|
||||
'studio.wfEmptyGo': 'Importar en Conexiones ComfyUI',
|
||||
@@ -1492,8 +1623,11 @@ export const es: Dict = {
|
||||
'agentsPage.title': 'Agentes',
|
||||
'agentsPage.subtitle': 'Habla de forma independiente con los cinco agentes de creación; define y asigna Skills',
|
||||
'agentsPage.statusChip': '{n} Skills · {m} activos',
|
||||
'agentsPage.agentsTitle': 'Agent',
|
||||
'agentsPage.agentsTitle': 'Cinco agentes',
|
||||
'agentsPage.skillLib': 'Biblioteca de Skills',
|
||||
'agentsPage.skillLibCount': '{n}',
|
||||
'agentsPage.installedTitle': '{name} · Skills instalados',
|
||||
'agentsPage.installedCount': '{n} · {m} activos',
|
||||
'agentsPage.newSkill': 'Nuevo Skill',
|
||||
'agentsPage.skillLibEmpty': 'Aún no hay Skills; crea uno con «Nuevo Skill».',
|
||||
'agentsPage.skillsCount': '{n} Skills',
|
||||
@@ -1706,4 +1840,88 @@ export const es: Dict = {
|
||||
'home.errScriptShort': 'El guion es demasiado corto — revisa el pegado (mín. 30 caracteres)',
|
||||
'home.stageProgress': 'Progreso de cinco etapas',
|
||||
'tasks.sourceWorkbench': 'Flujo de creación',
|
||||
'jobst.idSuffix': ' · ID de prompt de ComfyUI {id}',
|
||||
'jobst.done': 'Listo',
|
||||
'jobst.doneDetail': 'ComfyUI terminó y los resultados se guardaron',
|
||||
'jobst.dryRun': 'Marcador',
|
||||
'jobst.dryRunDetail': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
|
||||
'jobst.noOutput': 'Sin salida',
|
||||
'jobst.noOutputDetail': 'El trabajo terminó sin archivos de resultado: no cuenta como generado',
|
||||
'jobst.queued': 'En cola',
|
||||
'jobst.queuedDetail': 'Esperando en la cola del taller para enviarse a ComfyUI',
|
||||
'jobst.connecting': 'Conectando',
|
||||
'jobst.connectingDetail': 'Conectando con ComfyUI y subiendo entradas',
|
||||
'jobst.comfyQueued': 'Aceptado',
|
||||
'jobst.comfyQueuedDetail': 'ComfyUI aceptó el trabajo y lo puso en cola',
|
||||
'jobst.comfyQueuedAhead': 'ComfyUI aceptó el trabajo: {n} por delante en su cola',
|
||||
'jobst.comfyRunning': 'Ejecutando',
|
||||
'jobst.comfyRunningDetail': 'ComfyUI está ejecutando el trabajo',
|
||||
'jobst.saving': 'Guardando',
|
||||
'jobst.savingDetail': 'ComfyUI devolvió resultados, descargándolos',
|
||||
'jobst.failed': 'Fallido',
|
||||
'jobst.cancelled': 'Cancelado',
|
||||
'studio.ratioLabel': 'Relación de aspecto',
|
||||
'studio.megapixelsLabel': 'Megapíxeles',
|
||||
'studio.outputSize': 'Salida {w} × {h}',
|
||||
'studio.denoiseLabel': 'Eliminación de ruido',
|
||||
'studio.errNumber': '«{label}» debe ser un número',
|
||||
'studio.errInteger': '«{label}» debe ser un número entero',
|
||||
'studio.errMin': '«{label}» debe ser al menos {v}',
|
||||
'studio.errMax': '«{label}» debe ser como máximo {v}',
|
||||
'studio.errOption': '«{label}» no es una opción del flujo',
|
||||
'studio.reuseParams': 'Reutilizar ajustes',
|
||||
'studio.reusedToast': 'Ajustes de este trabajo aplicados: modifícalos y genera de nuevo',
|
||||
'studio.reuseMissingWf': 'El flujo que usó este trabajo ya no existe',
|
||||
'studio.doneToast': 'Listo: {n} archivo(s) (ID de prompt de ComfyUI {id})',
|
||||
'studio.dryRunToast': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
|
||||
'studio.dryRunNote': 'Dry-run activo: Generar solo escribe marcadores y nunca llama a ComfyUI. Desactívalo en Ajustes.',
|
||||
// --- HEL-643: verificación de importación / prueba real / grupos de medios ---
|
||||
'studio.errVideoFormat': '«{name}» no es un formato de vídeo admitido: usa MP4 / WebM / MOV',
|
||||
'studio.errAudioFormat': '«{name}» no es un formato de audio admitido: usa MP3 / WAV / M4A',
|
||||
'studio.errMediaSize': '«{name}» supera el límite de tamaño, comprímelo primero',
|
||||
'studio.errNeedRefMedia': '«{label}» necesita al menos {n} clips',
|
||||
'studio.refHintMedia': 'Obligatorio · 1–{max} clips · {n} seleccionados',
|
||||
'studio.refAddMedia': 'Añadir {kind}',
|
||||
'studio.refAddSubMedia': 'Quedan {n} · máx. {max}',
|
||||
'studio.refRemoveMedia': 'Quitar {kind} {n}',
|
||||
'studio.refFullMedia': 'Límite de {max} clips alcanzado: quita uno para añadir otro',
|
||||
'wfMedia.h3Limits': 'Límites oficiales de MiniMax H3 Ref2VA: hasta 9 imágenes, 3 vídeos y 3 audios de referencia; cada clip de 2–15 s con totales ≤15 s por tipo; el audio no puede ser la única referencia; 12 archivos de referencia como máximo.',
|
||||
'wfMedia.h3TotalOver': '{n} archivos de referencia: por encima del límite oficial de 12; la generación puede fallar',
|
||||
'wfMedia.h3AudioAlone': 'El audio no puede ser la única referencia: añade al menos una imagen o un vídeo',
|
||||
'conn.wfCheckTitle': 'Comprobación de compatibilidad de la conexión',
|
||||
'conn.wfCheckEndpoint': 'Destino: {name}',
|
||||
'conn.wfCheckBtn': 'Volver a comprobar',
|
||||
'conn.wfChecking': 'Comprobando…',
|
||||
'conn.wfCheckOk': 'Esta conexión tiene los nodos y modelos que necesita este flujo',
|
||||
'conn.wfCheckProblems': 'Se encontraron {n} nodos/modelos ausentes',
|
||||
'conn.wfCheckUnreachable': 'No se puede verificar (ComfyUI inaccesible): puedes guardar igualmente; vuelve a comprobar tras arreglar la conexión',
|
||||
'conn.wfCheckSaveHint': 'Puedes guardar el borrador (no se marcará como generable); instala lo que falta y vuelve a comprobar.',
|
||||
'conn.wfTestBtn': 'Probar',
|
||||
'conn.wfLastTest': 'Última prueba',
|
||||
'conn.wfTestPassed': '{time} superada ({n} archivos)',
|
||||
'conn.wfTestRunning': '{time} en curso',
|
||||
'conn.wfTestFailed': '{time} no superada',
|
||||
'wft.title': 'Probar flujo',
|
||||
'wft.connection': 'Conexión de destino',
|
||||
'wft.costNote': 'La prueba envía un trabajo real al ComfyUI elegido y usa su VRAM y su cola.',
|
||||
'wft.submit': 'Iniciar prueba',
|
||||
'wft.confirmTitle': 'Confirmar ejecución real',
|
||||
'wft.confirmBody': 'Se enviará un trabajo real a «{endpoint}» ({n} trabajos en cola). La prueba solo se aprueba si vuelve al menos un archivo descargable del tipo correcto.',
|
||||
'wft.running': 'Probando…',
|
||||
'wft.cancel': 'Cancelar prueba',
|
||||
'wft.passed': 'Prueba superada: {n} archivo(s) producidos',
|
||||
'wft.passedShort': 'Superada ({n} archivos)',
|
||||
'wft.failed': 'Prueba no superada',
|
||||
'wft.failedShort': 'No superada',
|
||||
'wft.cancelledShort': 'Cancelada',
|
||||
'wft.reusedToast': 'Ya hay una prueba en curso para este flujo: se ha retomado',
|
||||
'wft.history': 'Registros de prueba ({n})',
|
||||
'wft.clear': 'Borrar registros de prueba',
|
||||
'wft.cleared': 'Registros de prueba borrados',
|
||||
'wft.never': 'Nunca probado',
|
||||
'wft.download': 'Descargar',
|
||||
'wft.errRequired': 'Rellena «{label}»',
|
||||
'wft.errListMin': '«{label}» necesita al menos {n} {unit}',
|
||||
'tasks.testBadge': 'Prueba',
|
||||
'tasks.sourceWorkflowTest': 'Prueba de flujo',
|
||||
};
|
||||
|
||||
+228
-10
@@ -164,6 +164,8 @@ export const fr: Dict = {
|
||||
|
||||
// --- API errors ---
|
||||
'api.unknownError': "Une erreur s'est produite",
|
||||
'api.networkError': 'Impossible de joindre le backend : vérifiez qu\'il tourne puis réessayez',
|
||||
'api.httpError': 'Échec de la requête (HTTP {status}) : {detail}',
|
||||
'playground.openFailed': "Échec de l'ouverture du bac à sable",
|
||||
'playground.opening': 'Ouverture du bac à sable…',
|
||||
'agents.opening': "Ouverture de l'agent…",
|
||||
@@ -964,6 +966,27 @@ export const fr: Dict = {
|
||||
'library.emptyFilterBody': 'Rien ne correspond à ce filtre pour l’instant – essayez « Tout » ou générez quelque chose de nouveau.',
|
||||
|
||||
// --- Settings ---
|
||||
'settings.pageSub': 'File de génération, exécution des agents et chemins · LLM, connexions ComfyUI et Skills ont leur propre page',
|
||||
'settings.secQueue': 'File et génération',
|
||||
'settings.secQueueBlurb': 'Parallélisme, relances, interrogation et essai',
|
||||
'settings.secAgent': 'Exécution des agents',
|
||||
'settings.secAgentBlurb': 'Étapes, contexte, durcissement des prompts et shell',
|
||||
'settings.secStorage': 'Chemins de stockage',
|
||||
'settings.secStorageBlurb': 'Dossiers de données, ressources et espace de travail',
|
||||
'settings.elsewhere': 'Géré ailleurs',
|
||||
'settings.linkModelsBlurb': 'LLM et modèle par défaut de chaque agent',
|
||||
'settings.linkConnectionsBlurb': 'Adresses, tests de connexion et bibliothèque de workflows',
|
||||
'settings.linkAgentsBlurb': 'Skills et discussions dédiées des cinq agents',
|
||||
'settings.dryRunTitle': 'Mode essai',
|
||||
'settings.dryRunHint': 'Activé : ComfyUI n’est pas appelé et les tâches renvoient des images / vidéos factices ; désactivez pour générer réellement.',
|
||||
'settings.agentRunSection': 'Exécution des agents',
|
||||
'settings.agentRunLead': 'Limite les étapes d’outils par exécution et l’historique transmis.',
|
||||
'settings.rangeHint': 'Plage {min}–{max}',
|
||||
'settings.loadFailed': 'Impossible de charger les réglages : vérifiez que le serveur tourne',
|
||||
'skillName.character-consistency': 'Cohérence des personnages',
|
||||
'skillName.h3-video-prompt-enhancer': 'Amélioration de prompts vidéo H3',
|
||||
'skillName.scene-to-video': 'Scène en vidéo',
|
||||
'skillName.shot-composer-blockout': 'Pré-cadrage du plan',
|
||||
'settings.loading': 'Chargement des paramètres',
|
||||
'settings.allSaved': 'Toutes les modifications sont enregistrées',
|
||||
'settings.unsaved': 'Modifications non enregistrées',
|
||||
@@ -1010,7 +1033,7 @@ export const fr: Dict = {
|
||||
'settings.queueSection': "File d'attente",
|
||||
'settings.queueLead': 'Parallélisme et délais des tâches de génération et des appels d’outils de l’agent.',
|
||||
'settings.concurrencyField': 'Tâches simultanées',
|
||||
'settings.pollIntervalField': 'Intervalle de vérification (ms)',
|
||||
'settings.pollIntervalField': 'Intervalle de vérification (s)',
|
||||
'settings.pollTimeoutField': 'Délai de la tâche (s)',
|
||||
'settings.pollTimeoutHint': 'Durée maximale d’un job ComfyUI ou d’un appel d’outil de l’agent. 0 attend la fin.',
|
||||
'settings.maxRetriesField': 'Tentatives max.',
|
||||
@@ -1141,7 +1164,7 @@ export const fr: Dict = {
|
||||
'models.title': 'Modèles',
|
||||
'models.subtitle': 'Ajoutez des LLM personnalisés pour vos agents · la phase 1 ne couvre que les LLM ; les modèles image / vidéo arriveront ensuite',
|
||||
'models.statusCount': '{n} modèles · {m} disponibles',
|
||||
'models.add': '+ Ajouter un modèle',
|
||||
'models.add': 'Ajouter un modèle',
|
||||
'models.tabLlm': 'Modèles de langage',
|
||||
'models.tabImage': "Modèles d'image",
|
||||
'models.tabVideo': 'Modèles vidéo',
|
||||
@@ -1225,6 +1248,7 @@ export const fr: Dict = {
|
||||
|
||||
// --- A shell + projects overview (HEL-608) ---
|
||||
'shell.brand': 'Atelier Cinéma',
|
||||
'shell.brandMark': 'A',
|
||||
'shell.brandHome': 'Retour à l\'aperçu des projets',
|
||||
'shell.navAria': 'Navigation principale',
|
||||
'shell.navProjects': 'Projets',
|
||||
@@ -1238,7 +1262,9 @@ export const fr: Dict = {
|
||||
'shell.navSettings': 'Réglages',
|
||||
'shell.wip': 'En développement',
|
||||
'shell.wipHint': 'Cette page est en construction et arrivera plus tard',
|
||||
'shell.comfyAt': 'ComfyUI · {host}',
|
||||
'shell.comfy_ok': '{name} connecté',
|
||||
'shell.comfy_fail': '{name} injoignable',
|
||||
'shell.comfy_testing': 'Test de {name}…',
|
||||
'shell.comfyUnset': 'ComfyUI · non configuré',
|
||||
'shell.comfyManage': 'Configurez l\'adresse ComfyUI dans les Réglages',
|
||||
'shell.mobileNotice': 'Ouvrez cet atelier dans un navigateur de bureau (largeur minimum 768 px) ; les téléphones ne sont pas encore pris en charge.',
|
||||
@@ -1293,6 +1319,13 @@ export const fr: Dict = {
|
||||
'conn.loading': 'Chargement des connexions…',
|
||||
'conn.loadFailed': 'Échec du chargement des connexions',
|
||||
'conn.empty': 'Aucune connexion ComfyUI pour l\'instant — ajoutez la première adresse via « Ajouter une connexion ».',
|
||||
'conn.targetsTitle': 'Cible des tâches',
|
||||
'conn.targetImage': 'Image autonome',
|
||||
'conn.targetVideo': 'Vidéo autonome',
|
||||
'conn.targetFlow': 'Tâches du flux créatif',
|
||||
'conn.targetChange': 'Modifier',
|
||||
'conn.targetMixed': '{n} connexions',
|
||||
'conn.targetNote': 'Les tâches suivent la liaison de chaque workflow ; sans liaison, la connexion par défaut est utilisée. Modifiez les liaisons dans la bibliothèque — les pages image / vidéo peuvent aussi changer avant génération.',
|
||||
'conn.defaultBadge': 'Par défaut',
|
||||
'conn.test': 'Tester la connexion',
|
||||
'conn.testing': 'Test…',
|
||||
@@ -1356,13 +1389,63 @@ export const fr: Dict = {
|
||||
'conn.wfParams': '{n} paramètres remplissables',
|
||||
'conn.wfImport': 'Importer le workflow',
|
||||
'conn.wfImporting': 'Import…',
|
||||
'conn.wfImported': 'Workflow importé',
|
||||
'conn.wfNodes': '{n} nœuds',
|
||||
'conn.wfImportedN': 'Workflow importé : {n} paramètres détectés',
|
||||
'conn.wfImportedNoParams': 'Workflow importé, mais aucun paramètre détecté : il sera exécuté avec ses réglages intégrés',
|
||||
'conn.wfImportFailed': 'Échec de l\'import : {detail}',
|
||||
'conn.wfImportTitle': 'Importer un workflow ComfyUI (JSON format API)',
|
||||
'conn.wfImportHint': 'Collez ou choisissez un fichier ; le nom est requis, le type décide de la fenêtre image ou vidéo.',
|
||||
'conn.wfJsonInvalid': 'JSON invalide au format API (attendu : objet id de nœud → {{…class_type…}})',
|
||||
'conn.wfJsonPh': 'Collez ici le JSON format API ComfyUI ou choisissez un fichier',
|
||||
'conn.wfDeleted': 'Workflow supprimé',
|
||||
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
|
||||
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
|
||||
// --- Five usages & import confirmation (HEL-636) ---
|
||||
'usage.text2image': 'Text → Image',
|
||||
'usage.image2image': 'Image → Image',
|
||||
'usage.text2video': 'Text → Video',
|
||||
'usage.image2video': 'Image → Video',
|
||||
'usage.ref2video': 'Reference → Video',
|
||||
'usage.pending': 'Usage pending',
|
||||
'wfRole.image': 'Input image',
|
||||
'wfRole.first_frame': 'First frame',
|
||||
'wfRole.last_frame': 'Last frame',
|
||||
'wfRole.reference_image': 'Reference',
|
||||
'wfRole.mask': 'Mask',
|
||||
'conn.wfImportAction': 'Import workflow',
|
||||
'conn.wfConfirmTitle': 'Confirm detection',
|
||||
'conn.wfStepAnalyze': 'Paste & analyze',
|
||||
'conn.wfStepConfirm': 'Confirm & save',
|
||||
'conn.wfChooseFile': 'Choose file',
|
||||
'conn.wfAnalyze': 'Analyze',
|
||||
'conn.wfAnalyzing': 'Analyzing…',
|
||||
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
|
||||
'conn.wfUsageLabel': 'Usage',
|
||||
'conn.wfUsageAuto': 'Auto-detected: {usage}',
|
||||
'conn.wfUsageNeedPick': 'Pick manually',
|
||||
'conn.wfPickUsage': 'Choose a usage first',
|
||||
'conn.wfSlotsLabel': 'Image slots',
|
||||
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
|
||||
'conn.wfBack': 'Back',
|
||||
'conn.wfConfirmSave': 'Confirm & save',
|
||||
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
|
||||
'conn.wfPendingCount': '{n} pending',
|
||||
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
|
||||
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.wfOutputLabel': 'Nœuds de sortie',
|
||||
'conn.wfOutputNeedPick': 'Nœuds de sortie personnalisés non reconnus — à désigner',
|
||||
'conn.wfOutputHint': 'Cochez les nœuds qui produisent réellement les résultats et choisissez leur type de média ; la génération ne collecte les images/vidéos que sur ces nœuds.',
|
||||
'conn.wfOutputPick': 'Cochez au moins un nœud de sortie',
|
||||
'conn.wfOutputKindAria': 'Type de média du nœud de sortie',
|
||||
'conn.wfOutputPendingBadge': 'Sortie à définir',
|
||||
'conn.wfOutputNeedMap': 'Ce workflow contient des nœuds de sortie non reconnus ; désignez-les avec leur type de média pour l’activer',
|
||||
'conn.wfOutputApply': 'Enregistrer le mapping de sortie',
|
||||
'conn.wfMapCancel': 'Annuler',
|
||||
'conn.wfOutputApplied': 'Mapping de sortie enregistré : nœud {nodes}',
|
||||
'conn.wfOutputCleared': 'Mapping de sortie effacé — le workflow est de nouveau en attente',
|
||||
'conn.missingMaterial': 'Un JSON de workflow Qwen Image 2.1 dédié manque encore au dépôt : exportez-le depuis ComfyUI et importez-le ici — le chemin d\'import générique est prêt.',
|
||||
|
||||
// --- Génération indépendante image / vidéo (a3/a4, HEL-612) ---
|
||||
@@ -1370,14 +1453,24 @@ export const fr: Dict = {
|
||||
'studio.image.subtitle': 'Générez des images avec les workflows importés · connexions et file partagées avec Connexions ComfyUI',
|
||||
'studio.video.title': 'Vidéo',
|
||||
'studio.video.subtitle': 'Générez des vidéos avec les workflows importés · connexions et file partagées avec Connexions ComfyUI',
|
||||
'studio.genParams': 'Paramètres',
|
||||
'studio.modeTextToImage': 'Texte vers image',
|
||||
'studio.modeTextToVideo': 'Texte vers vidéo',
|
||||
'studio.chipIdle': '{name} · file libre',
|
||||
'studio.chipBusy': '{name} · {n} en file',
|
||||
'studio.video.resultsTitle': 'Aperçu',
|
||||
'studio.image.historyTitle': 'Historique images',
|
||||
'studio.video.historyTitle': 'Historique vidéos',
|
||||
'studio.historyCount': '{n} au total',
|
||||
'studio.doneAt': 'Terminé à {time}',
|
||||
'studio.kindImageText': 'image',
|
||||
'studio.kindVideoText': 'vidéo',
|
||||
'studio.loading': 'Chargement…',
|
||||
'studio.connLabel': 'Connexion',
|
||||
'studio.connQueue': 'File {n}',
|
||||
'studio.connUntested': 'Non testée',
|
||||
'studio.connOk': 'Dernier test OK · {ms} ms',
|
||||
'studio.connFail': 'Dernier test échoué',
|
||||
'studio.connUntested': 'Vérification…',
|
||||
'studio.connOk': 'Connecté · {ms} ms',
|
||||
'studio.connFail': 'Injoignable',
|
||||
'studio.connManage': 'Gérer les connexions',
|
||||
'studio.wfLabel': 'Workflow',
|
||||
'studio.wfParams': '{n} paramètres remplissables',
|
||||
@@ -1440,17 +1533,55 @@ export const fr: Dict = {
|
||||
'studio.sentToImg2ImgToast': 'Ajouté comme référence ; vérifiez les paramètres et lancez',
|
||||
'studio.noImg2ImgWf': 'Aucun workflow image ne déclare d’entrée de référence ; importez d’abord un workflow img2img dans Connexions ComfyUI',
|
||||
'studio.videoRefStrip': 'Référence premier / dernier cadre',
|
||||
'studio.historyTitle': 'Historique',
|
||||
'studio.historyEmptyImage': 'Aucune génération d’image ; réglez les paramètres puis lancez Générer',
|
||||
'studio.historyEmptyVideo': 'Aucune génération de vidéo ; réglez les paramètres puis lancez Générer',
|
||||
'studio.jobName': 'Tâche #{id}',
|
||||
'studio.retryLink': 'Réessayer',
|
||||
'studio.retryQueuedToast': 'Tâche remise en file',
|
||||
'studio.retryFailedToast': 'Échec de la remise en file : {detail}',
|
||||
'studio.errNoWorkflow': 'Choisissez d\'abord un workflow',
|
||||
'studio.errBlocked': 'Envoi impossible : {reason}',
|
||||
'studio.queuedToast': 'Envoyé à « {name} », en file',
|
||||
'studio.submitFailedToast': 'Échec de l\'envoi : {detail}',
|
||||
'studio.jobFailedToast': 'Échec de la génération : {detail}',
|
||||
'studio.wfNoParamsNote': 'Ce workflow n\'a aucun paramètre à remplir et sera exécuté avec son prompt et ses réglages intégrés. Pour les remplir ici, ajoutez (Input:prompt) etc. aux titres de nœuds dans ComfyUI puis réimportez.',
|
||||
'studio.wfAutoParamsNote': 'Aucune balise (Input:…) : {n} paramètres détectés par type de nœud ; les champs vides gardent les valeurs du workflow.',
|
||||
'studio.promptNotDeclared': 'Ce workflow n\'a pas de nœud de prompt',
|
||||
'studio.modeImageEdit': 'Image edit',
|
||||
'studio.modeImageToVideo': 'Image to video',
|
||||
'studio.modeRefToVideo': 'Reference to video',
|
||||
'studio.slotInput': 'Input image',
|
||||
'studio.slotHintOne': 'Required · 1 image',
|
||||
'studio.slotEmptyInput': 'Click or drop the input image',
|
||||
'studio.slotEmptyFirst': 'Click or drop the first frame',
|
||||
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
|
||||
'studio.slotFromResultsShort': 'From results',
|
||||
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
|
||||
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
|
||||
'studio.errNeedInputImage': 'Image edit needs an input image',
|
||||
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
|
||||
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
|
||||
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
|
||||
'studio.refHint': 'Required · 1–{max} · {n} selected',
|
||||
'studio.refAdd': 'Add reference image',
|
||||
'studio.refAddSub': '{n} more · max {max}',
|
||||
'studio.refReplace': 'Replace',
|
||||
'studio.refRemove': 'Remove reference {n}',
|
||||
'studio.refRejectedShort': 'Not added',
|
||||
'studio.refFull': 'Limit of {max} reached — remove one to add another',
|
||||
'studio.refStrip': 'Reference images ({n} used)',
|
||||
'studio.resolutionLabel': 'Output resolution',
|
||||
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
|
||||
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
|
||||
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
|
||||
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
|
||||
'studio.cancelToast': 'Annulation demandée',
|
||||
'studio.deleteBtn': 'Supprimer',
|
||||
'studio.delTitle': 'Supprimer la tâche',
|
||||
'studio.delConfirm': 'Les fichiers de résultats seront supprimés avec la tâche. Supprimer ?',
|
||||
'studio.delConfirm': 'La suppression retire aussi les fichiers de résultats de cette tâche ; l’historique est irrécupérable. Supprimer ?',
|
||||
'studio.delTitleJob': 'Supprimer la tâche « {name} »',
|
||||
'studio.delConfirmMulti': 'Cette tâche a produit {n} résultats. La suppression retire la tâche entière et ses {n} fichiers de résultats ; action irréversible. Supprimer ?',
|
||||
'studio.deletedToast': 'Tâche supprimée avec ses fichiers de résultats',
|
||||
'studio.wfEmptyTitle': 'Pas encore de workflows {kind}',
|
||||
'studio.wfEmptyBody': 'Activez le mode développeur de ComfyUI, exportez le workflow en JSON API, puis importez-le dans Connexions ComfyUI comme {kind}.',
|
||||
'studio.wfEmptyGo': 'Importer dans Connexions ComfyUI',
|
||||
@@ -1492,8 +1623,11 @@ export const fr: Dict = {
|
||||
'agentsPage.title': 'Agents',
|
||||
'agentsPage.subtitle': 'Discutez séparément avec les cinq agents de création ; définissez et attribuez des Skills',
|
||||
'agentsPage.statusChip': '{n} Skills · {m} activés',
|
||||
'agentsPage.agentsTitle': 'Agent',
|
||||
'agentsPage.agentsTitle': 'Cinq agents',
|
||||
'agentsPage.skillLib': 'Bibliothèque de Skills',
|
||||
'agentsPage.skillLibCount': '{n}',
|
||||
'agentsPage.installedTitle': '{name} · Skills installés',
|
||||
'agentsPage.installedCount': '{n} · {m} actifs',
|
||||
'agentsPage.newSkill': 'Nouveau Skill',
|
||||
'agentsPage.skillLibEmpty': 'Aucun Skill pour l’instant — créez-en un avec « Nouveau Skill ».',
|
||||
'agentsPage.skillsCount': '{n} Skills',
|
||||
@@ -1706,4 +1840,88 @@ export const fr: Dict = {
|
||||
'home.errScriptShort': 'Scénario trop court — vérifiez le collage (30 caractères min.)',
|
||||
'home.stageProgress': 'Progression en cinq étapes',
|
||||
'tasks.sourceWorkbench': 'Flux de création',
|
||||
'jobst.idSuffix': ' · ID de prompt ComfyUI {id}',
|
||||
'jobst.done': 'Terminé',
|
||||
'jobst.doneDetail': 'ComfyUI a terminé, résultats enregistrés',
|
||||
'jobst.dryRun': 'Substitut',
|
||||
'jobst.dryRunDetail': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
|
||||
'jobst.noOutput': 'Aucune sortie',
|
||||
'jobst.noOutputDetail': 'La tâche s\'est terminée sans fichier de résultat : pas une génération',
|
||||
'jobst.queued': 'En attente',
|
||||
'jobst.queuedDetail': 'En attente dans la file de l\'atelier avant envoi à ComfyUI',
|
||||
'jobst.connecting': 'Connexion',
|
||||
'jobst.connectingDetail': 'Connexion à ComfyUI et envoi des entrées',
|
||||
'jobst.comfyQueued': 'Acceptée',
|
||||
'jobst.comfyQueuedDetail': 'ComfyUI a accepté la tâche et l\'a mise en file',
|
||||
'jobst.comfyQueuedAhead': 'ComfyUI a accepté la tâche — {n} devant dans sa file',
|
||||
'jobst.comfyRunning': 'En cours',
|
||||
'jobst.comfyRunningDetail': 'ComfyUI exécute la tâche',
|
||||
'jobst.saving': 'Enregistrement',
|
||||
'jobst.savingDetail': 'ComfyUI a renvoyé des résultats, téléchargement',
|
||||
'jobst.failed': 'Échec',
|
||||
'jobst.cancelled': 'Annulée',
|
||||
'studio.ratioLabel': 'Format d\'image',
|
||||
'studio.megapixelsLabel': 'Mégapixels',
|
||||
'studio.outputSize': 'Sortie {w} × {h}',
|
||||
'studio.denoiseLabel': 'Débruitage',
|
||||
'studio.errNumber': '« {label} » doit être un nombre',
|
||||
'studio.errInteger': '« {label} » doit être un entier',
|
||||
'studio.errMin': '« {label} » doit valoir au moins {v}',
|
||||
'studio.errMax': '« {label} » doit valoir au plus {v}',
|
||||
'studio.errOption': '« {label} » n\'est pas une option du workflow',
|
||||
'studio.reuseParams': 'Réutiliser les réglages',
|
||||
'studio.reusedToast': 'Réglages de cette tâche repris : ajustez puis relancez',
|
||||
'studio.reuseMissingWf': 'Le workflow de cette tâche n\'existe plus',
|
||||
'studio.doneToast': 'Terminé : {n} fichier(s) (ID de prompt ComfyUI {id})',
|
||||
'studio.dryRunToast': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
|
||||
'studio.dryRunNote': 'Dry-run actif : Générer n\'écrit que des substituts et n\'appelle jamais ComfyUI. Désactivez-le dans Réglages.',
|
||||
// --- HEL-643 : vérification d'import / test réel / groupes de médias ---
|
||||
'studio.errVideoFormat': "« {name} » n'est pas un format vidéo pris en charge — utilisez MP4 / WebM / MOV",
|
||||
'studio.errAudioFormat': "« {name} » n'est pas un format audio pris en charge — utilisez MP3 / WAV / M4A",
|
||||
'studio.errMediaSize': "« {name} » dépasse la limite de taille — compressez-le d'abord",
|
||||
'studio.errNeedRefMedia': '« {label} » exige au moins {n} clips',
|
||||
'studio.refHintMedia': 'Requis · 1–{max} clips · {n} sélectionnés',
|
||||
'studio.refAddMedia': 'Ajouter {kind}',
|
||||
'studio.refAddSubMedia': '{n} de plus · max {max}',
|
||||
'studio.refRemoveMedia': 'Retirer {kind} {n}',
|
||||
'studio.refFullMedia': 'Limite de {max} clips atteinte — retirez-en un pour en ajouter un autre',
|
||||
'wfMedia.h3Limits': "Limites officielles MiniMax H3 Ref2VA : jusqu'à 9 images, 3 vidéos et 3 audios de référence ; chaque clip de 2–15 s, totaux ≤15 s par type ; l'audio ne peut être la seule référence ; 12 fichiers de référence au maximum.",
|
||||
'wfMedia.h3TotalOver': '{n} fichiers de référence — au-delà du plafond officiel de 12 ; la génération peut échouer',
|
||||
'wfMedia.h3AudioAlone': "L'audio ne peut pas être la seule référence — ajoutez au moins une image ou une vidéo",
|
||||
'conn.wfCheckTitle': 'Vérification de compatibilité de la connexion',
|
||||
'conn.wfCheckEndpoint': 'Cible : {name}',
|
||||
'conn.wfCheckBtn': 'Revérifier',
|
||||
'conn.wfChecking': 'Vérification…',
|
||||
'conn.wfCheckOk': 'Cette connexion possède les nœuds et modèles requis par ce flux',
|
||||
'conn.wfCheckProblems': '{n} nœud(s)/modèle(s) manquant(s) détecté(s)',
|
||||
'conn.wfCheckUnreachable': "Vérification impossible (ComfyUI injoignable) — l'enregistrement reste possible ; revérifiez après avoir réparé la connexion",
|
||||
'conn.wfCheckSaveHint': 'Vous pouvez enregistrer le brouillon (il ne sera pas marqué générable) ; installez le manquant puis revérifiez.',
|
||||
'conn.wfTestBtn': 'Tester',
|
||||
'conn.wfLastTest': 'Dernier test',
|
||||
'conn.wfTestPassed': '{time} réussi ({n} fichiers)',
|
||||
'conn.wfTestRunning': '{time} en cours',
|
||||
'conn.wfTestFailed': '{time} échoué',
|
||||
'wft.title': 'Tester le flux',
|
||||
'wft.connection': 'Connexion cible',
|
||||
'wft.costNote': 'Un test envoie une tâche réelle au ComfyUI choisi et mobilise son VRAM et sa file.',
|
||||
'wft.submit': 'Lancer le test',
|
||||
'wft.confirmTitle': 'Confirmer une exécution réelle',
|
||||
'wft.confirmBody': 'Une tâche réelle sera envoyée à « {endpoint} » ({n} tâches en file). Le test ne réussit que si au moins un fichier téléchargeable du bon type revient.',
|
||||
'wft.running': 'Test en cours…',
|
||||
'wft.cancel': 'Annuler le test',
|
||||
'wft.passed': 'Test réussi : {n} fichier(s) produit(s)',
|
||||
'wft.passedShort': 'Réussi ({n} fichiers)',
|
||||
'wft.failed': 'Test échoué',
|
||||
'wft.failedShort': 'Échoué',
|
||||
'wft.cancelledShort': 'Annulé',
|
||||
'wft.reusedToast': 'Un test est déjà en cours pour ce flux — rattaché à celui-ci',
|
||||
'wft.history': 'Historique des tests ({n})',
|
||||
'wft.clear': "Effacer l'historique des tests",
|
||||
'wft.cleared': 'Historique des tests effacé',
|
||||
'wft.never': 'Jamais testé',
|
||||
'wft.download': 'Télécharger',
|
||||
'wft.errRequired': 'Veuillez renseigner « {label} »',
|
||||
'wft.errListMin': '« {label} » exige au moins {n} {unit}',
|
||||
'tasks.testBadge': 'Test',
|
||||
'tasks.sourceWorkflowTest': 'Test de flux',
|
||||
};
|
||||
|
||||
+228
-10
@@ -164,6 +164,8 @@ export const ja: Dict = {
|
||||
|
||||
// --- API errors ---
|
||||
'api.unknownError': '問題が発生しました',
|
||||
'api.networkError': 'ワークベンチのバックエンドに接続できません。起動を確認して再試行してください',
|
||||
'api.httpError': 'リクエスト失敗(HTTP {status}):{detail}',
|
||||
'playground.openFailed': 'プレイグラウンドを開けませんでした',
|
||||
'playground.opening': 'プレイグラウンドを開いています…',
|
||||
'agents.opening': 'エージェントを開いています…',
|
||||
@@ -964,6 +966,27 @@ export const ja: Dict = {
|
||||
'library.emptyFilterBody': 'このフィルターに一致するものはまだありません – 「すべて」を試すか、新しく生成してください。',
|
||||
|
||||
// --- Settings ---
|
||||
'settings.pageSub': '生成キュー・エージェント実行・保存パス · LLM、ComfyUI 接続、Skill はそれぞれのページで管理',
|
||||
'settings.secQueue': '生成とキュー',
|
||||
'settings.secQueueBlurb': '並列数・再試行・ポーリング・試運転',
|
||||
'settings.secAgent': 'エージェント実行',
|
||||
'settings.secAgentBlurb': 'ステップ・コンテキスト・プロンプト強化・Shell',
|
||||
'settings.secStorage': '保存パス',
|
||||
'settings.secStorageBlurb': 'データ・素材・作業フォルダ',
|
||||
'settings.elsewhere': 'その他の設定',
|
||||
'settings.linkModelsBlurb': 'LLM と各エージェントの既定モデル',
|
||||
'settings.linkConnectionsBlurb': '接続先・接続テスト・ワークフロー',
|
||||
'settings.linkAgentsBlurb': '5 つのエージェントの Skill と個別チャット',
|
||||
'settings.dryRunTitle': '試運転モード',
|
||||
'settings.dryRunHint': 'オンの間は ComfyUI を呼ばず、仮の画像 / 動画を返して流れを確認できます。実際に生成するにはオフにします。',
|
||||
'settings.agentRunSection': 'エージェント実行',
|
||||
'settings.agentRunLead': '1 回の実行のツール呼び出し数と持ち込む履歴を制限します。',
|
||||
'settings.rangeHint': '範囲 {min}–{max}',
|
||||
'settings.loadFailed': '設定を読み込めません。サーバーが起動しているか確認してください',
|
||||
'skillName.character-consistency': 'キャラクター一貫性',
|
||||
'skillName.h3-video-prompt-enhancer': 'H3 動画プロンプト強化',
|
||||
'skillName.scene-to-video': 'シーンから動画',
|
||||
'skillName.shot-composer-blockout': 'ショット構図プレビュー',
|
||||
'settings.loading': '設定を読み込み中',
|
||||
'settings.allSaved': 'すべての変更を保存しました',
|
||||
'settings.unsaved': '未保存の変更',
|
||||
@@ -1010,7 +1033,7 @@ export const ja: Dict = {
|
||||
'settings.queueSection': 'ジョブキュー',
|
||||
'settings.queueLead': '生成ジョブとエージェントのツール呼び出しの並列数とタイムアウト。',
|
||||
'settings.concurrencyField': '同時実行ジョブ数',
|
||||
'settings.pollIntervalField': 'ポーリング間隔(ms)',
|
||||
'settings.pollIntervalField': 'ポーリング間隔(秒)',
|
||||
'settings.pollTimeoutField': 'ジョブタイムアウト(秒)',
|
||||
'settings.pollTimeoutHint': 'ComfyUI のジョブ、またはエージェントのツール呼び出し 1 回の上限。0 は完了まで待つ。',
|
||||
'settings.maxRetriesField': '最大再試行回数',
|
||||
@@ -1141,7 +1164,7 @@ export const ja: Dict = {
|
||||
'models.title': 'モデル設定',
|
||||
'models.subtitle': 'エージェントが使うカスタム LLM を追加 · 第一段階は LLM のみ、画像 / 動画モデルは後日',
|
||||
'models.statusCount': 'モデル {n} 件 · 利用可能 {m} 件',
|
||||
'models.add': '+ モデルを追加',
|
||||
'models.add': 'モデルを追加',
|
||||
'models.tabLlm': '大規模言語モデル',
|
||||
'models.tabImage': '画像モデル',
|
||||
'models.tabVideo': '動画モデル',
|
||||
@@ -1225,6 +1248,7 @@ export const ja: Dict = {
|
||||
|
||||
// --- A shell + projects overview (HEL-608) ---
|
||||
'shell.brand': '映画工房',
|
||||
'shell.brandMark': '映',
|
||||
'shell.brandHome': 'プロジェクト概要へ戻る',
|
||||
'shell.navAria': 'メインナビゲーション',
|
||||
'shell.navProjects': 'プロジェクト',
|
||||
@@ -1238,7 +1262,9 @@ export const ja: Dict = {
|
||||
'shell.navSettings': '設定',
|
||||
'shell.wip': '開発中',
|
||||
'shell.wipHint': 'このページは準備中です。後ほど公開されます',
|
||||
'shell.comfyAt': 'ComfyUI · {host}',
|
||||
'shell.comfy_ok': '{name} 接続済み',
|
||||
'shell.comfy_fail': '{name} 接続失敗',
|
||||
'shell.comfy_testing': '{name} テスト中…',
|
||||
'shell.comfyUnset': 'ComfyUI · 未設定',
|
||||
'shell.comfyManage': '「設定」で ComfyUI のアドレスを設定できます',
|
||||
'shell.mobileNotice': 'このワークベンチはデスクトップブラウザ(幅 768px 以上)で開いてください。スマートフォンには未対応です。',
|
||||
@@ -1293,6 +1319,13 @@ export const ja: Dict = {
|
||||
'conn.loading': '接続を読み込み中…',
|
||||
'conn.loadFailed': '接続情報の読み込みに失敗しました',
|
||||
'conn.empty': 'ComfyUI 接続がまだありません。右上の「接続を追加」で最初のアドレスを登録してください。',
|
||||
'conn.targetsTitle': 'タスクの実行先',
|
||||
'conn.targetImage': '単体画像生成',
|
||||
'conn.targetVideo': '単体動画生成',
|
||||
'conn.targetFlow': '制作フロー内のタスク',
|
||||
'conn.targetChange': '変更',
|
||||
'conn.targetMixed': '{n} 件の接続',
|
||||
'conn.targetNote': '各ワークフローの割り当てに従い、未割り当てなら既定の接続を使います。割り当てはワークフローライブラリで変更。画像 / 動画ページでも生成前に切り替えられます。',
|
||||
'conn.defaultBadge': 'デフォルト',
|
||||
'conn.test': '接続テスト',
|
||||
'conn.testing': 'テスト中…',
|
||||
@@ -1356,13 +1389,63 @@ export const ja: Dict = {
|
||||
'conn.wfParams': '記入可能パラメータ {n} 件',
|
||||
'conn.wfImport': 'ワークフローを取り込む',
|
||||
'conn.wfImporting': '取り込み中…',
|
||||
'conn.wfImported': 'ワークフローを取り込みました',
|
||||
'conn.wfNodes': '{n} ノード',
|
||||
'conn.wfImportedN': 'ワークフローを取り込みました(記入可能パラメータ {n} 件)',
|
||||
'conn.wfImportedNoParams': '取り込みましたが記入可能パラメータは検出されませんでした。内蔵設定のまま実行されます',
|
||||
'conn.wfImportFailed': '取り込みに失敗しました:{detail}',
|
||||
'conn.wfImportTitle': 'ComfyUI ワークフローを取り込む(API 形式 JSON)',
|
||||
'conn.wfImportHint': '貼り付けまたはファイル選択。名前は必須で、種類が画像 / 動画ウィンドウのどちらに表示されるかを決めます。',
|
||||
'conn.wfJsonInvalid': '有効な API 形式 JSON ではありません(ノード ID → {{…class_type…}} のオブジェクトが必要)',
|
||||
'conn.wfJsonPh': 'ここに ComfyUI の API 形式 JSON を貼り付けるか、右のボタンでファイルを選択',
|
||||
'conn.wfDeleted': 'ワークフローを削除しました',
|
||||
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
|
||||
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
|
||||
// --- Five usages & import confirmation (HEL-636) ---
|
||||
'usage.text2image': 'Text → Image',
|
||||
'usage.image2image': 'Image → Image',
|
||||
'usage.text2video': 'Text → Video',
|
||||
'usage.image2video': 'Image → Video',
|
||||
'usage.ref2video': 'Reference → Video',
|
||||
'usage.pending': 'Usage pending',
|
||||
'wfRole.image': 'Input image',
|
||||
'wfRole.first_frame': 'First frame',
|
||||
'wfRole.last_frame': 'Last frame',
|
||||
'wfRole.reference_image': 'Reference',
|
||||
'wfRole.mask': 'Mask',
|
||||
'conn.wfImportAction': 'Import workflow',
|
||||
'conn.wfConfirmTitle': 'Confirm detection',
|
||||
'conn.wfStepAnalyze': 'Paste & analyze',
|
||||
'conn.wfStepConfirm': 'Confirm & save',
|
||||
'conn.wfChooseFile': 'Choose file',
|
||||
'conn.wfAnalyze': 'Analyze',
|
||||
'conn.wfAnalyzing': 'Analyzing…',
|
||||
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
|
||||
'conn.wfUsageLabel': 'Usage',
|
||||
'conn.wfUsageAuto': 'Auto-detected: {usage}',
|
||||
'conn.wfUsageNeedPick': 'Pick manually',
|
||||
'conn.wfPickUsage': 'Choose a usage first',
|
||||
'conn.wfSlotsLabel': 'Image slots',
|
||||
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
|
||||
'conn.wfBack': 'Back',
|
||||
'conn.wfConfirmSave': 'Confirm & save',
|
||||
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
|
||||
'conn.wfPendingCount': '{n} pending',
|
||||
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
|
||||
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.wfOutputLabel': '出力ノード',
|
||||
'conn.wfOutputNeedPick': '認識できないカスタム出力ノードがあります。手動で指定してください',
|
||||
'conn.wfOutputHint': '実際に結果を出すノードにチェックし、それぞれのメディア種別を選んでください。生成時にこれらのノードからのみ画像/動画を取り出します。',
|
||||
'conn.wfOutputPick': '出力ノードを1つ以上チェックしてください',
|
||||
'conn.wfOutputKindAria': '出力ノードのメディア種別',
|
||||
'conn.wfOutputPendingBadge': '出力未指定',
|
||||
'conn.wfOutputNeedMap': '認識できない出力ノードを含みます。ノードとメディア種別を指定すると有効化できます',
|
||||
'conn.wfOutputApply': '出力マッピングを保存',
|
||||
'conn.wfMapCancel': 'キャンセル',
|
||||
'conn.wfOutputApplied': '出力マッピングを保存しました:ノード {nodes}',
|
||||
'conn.wfOutputCleared': '出力マッピングを解除しました。ワークフローは保留に戻ります',
|
||||
'conn.missingMaterial': 'Qwen Image 2.1 専用のワークフロー JSON はまだリポジトリにありません。ComfyUI から書き出してここで取り込んでください。汎用の取り込み経路は用意済みです。',
|
||||
|
||||
// --- 独立画像/動画生成(a3/a4、HEL-612)---
|
||||
@@ -1370,14 +1453,24 @@ export const ja: Dict = {
|
||||
'studio.image.subtitle': '取り込んだ画像ワークフローで生成 · 接続とキューは「ComfyUI 接続」と共通',
|
||||
'studio.video.title': '動画生成',
|
||||
'studio.video.subtitle': '取り込んだ動画ワークフローで生成 · 接続とキューは「ComfyUI 接続」と共通',
|
||||
'studio.genParams': '生成パラメータ',
|
||||
'studio.modeTextToImage': 'テキストから画像',
|
||||
'studio.modeTextToVideo': 'テキストから動画',
|
||||
'studio.chipIdle': '{name} · キュー空き',
|
||||
'studio.chipBusy': '{name} · キュー {n} 件',
|
||||
'studio.video.resultsTitle': 'プレビュー',
|
||||
'studio.image.historyTitle': '画像生成履歴',
|
||||
'studio.video.historyTitle': '動画生成履歴',
|
||||
'studio.historyCount': '計 {n} 回',
|
||||
'studio.doneAt': '完了 {time}',
|
||||
'studio.kindImageText': '画像',
|
||||
'studio.kindVideoText': '動画',
|
||||
'studio.loading': '読み込み中…',
|
||||
'studio.connLabel': '生成接続',
|
||||
'studio.connQueue': 'キュー {n}',
|
||||
'studio.connUntested': '未テスト',
|
||||
'studio.connOk': '前回テスト成功 · {ms} ms',
|
||||
'studio.connFail': '前回テスト失敗',
|
||||
'studio.connUntested': '確認中…',
|
||||
'studio.connOk': '接続済み · {ms} ms',
|
||||
'studio.connFail': '接続失敗',
|
||||
'studio.connManage': '接続を管理',
|
||||
'studio.wfLabel': 'ワークフロー',
|
||||
'studio.wfParams': '入力可能パラメータ {n} 件',
|
||||
@@ -1440,17 +1533,55 @@ export const ja: Dict = {
|
||||
'studio.sentToImg2ImgToast': '参照画像として設定しました。パラメータを確認して生成してください',
|
||||
'studio.noImg2ImgWf': '参照画像入力を持つ画像ワークフローがまだありません。先に「ComfyUI 接続」で img2img を取り込んでください',
|
||||
'studio.videoRefStrip': '始端 / 終端フレーム参照',
|
||||
'studio.historyTitle': '履歴',
|
||||
'studio.historyEmptyImage': '画像生成の記録がありません。左でパラメータを設定して生成開始',
|
||||
'studio.historyEmptyVideo': '動画生成の記録がありません。左でパラメータを設定して生成開始',
|
||||
'studio.jobName': 'タスク #{id}',
|
||||
'studio.retryLink': '再試行',
|
||||
'studio.retryQueuedToast': 'タスクを再びキューに入れました',
|
||||
'studio.retryFailedToast': '再キュー失敗:{detail}',
|
||||
'studio.errNoWorkflow': '先にワークフローを選択してください',
|
||||
'studio.errBlocked': '送信できません:{reason}',
|
||||
'studio.queuedToast': '「{name}」に送信しました(待機中)',
|
||||
'studio.submitFailedToast': '送信失敗:{detail}',
|
||||
'studio.jobFailedToast': '生成失敗:{detail}',
|
||||
'studio.wfNoParamsNote': 'このワークフローには記入可能なパラメータがなく、内蔵のプロンプトと設定で実行されます。ここで記入するには ComfyUI でノードタイトルに (Input:prompt) などを付けて再度取り込んでください。',
|
||||
'studio.wfAutoParamsNote': '(Input:…) タグがないため、ノード種別から {n} 件のパラメータを自動検出しました。空欄はワークフローの値を使います。',
|
||||
'studio.promptNotDeclared': 'このワークフローにはプロンプトノードがありません',
|
||||
'studio.modeImageEdit': 'Image edit',
|
||||
'studio.modeImageToVideo': 'Image to video',
|
||||
'studio.modeRefToVideo': 'Reference to video',
|
||||
'studio.slotInput': 'Input image',
|
||||
'studio.slotHintOne': 'Required · 1 image',
|
||||
'studio.slotEmptyInput': 'Click or drop the input image',
|
||||
'studio.slotEmptyFirst': 'Click or drop the first frame',
|
||||
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
|
||||
'studio.slotFromResultsShort': 'From results',
|
||||
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
|
||||
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
|
||||
'studio.errNeedInputImage': 'Image edit needs an input image',
|
||||
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
|
||||
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
|
||||
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
|
||||
'studio.refHint': 'Required · 1–{max} · {n} selected',
|
||||
'studio.refAdd': 'Add reference image',
|
||||
'studio.refAddSub': '{n} more · max {max}',
|
||||
'studio.refReplace': 'Replace',
|
||||
'studio.refRemove': 'Remove reference {n}',
|
||||
'studio.refRejectedShort': 'Not added',
|
||||
'studio.refFull': 'Limit of {max} reached — remove one to add another',
|
||||
'studio.refStrip': 'Reference images ({n} used)',
|
||||
'studio.resolutionLabel': 'Output resolution',
|
||||
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
|
||||
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
|
||||
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
|
||||
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
|
||||
'studio.cancelToast': 'キャンセルを要求しました',
|
||||
'studio.deleteBtn': '削除',
|
||||
'studio.delTitle': 'タスク記録を削除',
|
||||
'studio.delConfirm': '結果ファイルも一緒に削除され、履歴は復元できません。削除しますか?',
|
||||
'studio.delConfirm': '削除するとこのタスクの結果ファイルも削除され、履歴は元に戻せません。削除しますか?',
|
||||
'studio.delTitleJob': 'タスク「{name}」を削除',
|
||||
'studio.delConfirmMulti': 'このタスクには結果が {n} 件あります。削除するとタスク記録と {n} 件すべての結果ファイルが失われ、元に戻せません。削除しますか?',
|
||||
'studio.deletedToast': 'タスクと結果ファイルを削除しました',
|
||||
'studio.wfEmptyTitle': '{kind}ワークフローがまだありません',
|
||||
'studio.wfEmptyBody': 'ComfyUI の開発者モードで API 形式 JSON を書き出し、「ComfyUI 接続」ページで{kind}として取り込んでください。',
|
||||
'studio.wfEmptyGo': '「ComfyUI 接続」で取り込む',
|
||||
@@ -1492,8 +1623,11 @@ export const ja: Dict = {
|
||||
'agentsPage.title': 'エージェント',
|
||||
'agentsPage.subtitle': '5つの制作エージェントと個別に会話し、Skill を定義・割り当て',
|
||||
'agentsPage.statusChip': 'Skill {n} 件 · 有効 {m} 件',
|
||||
'agentsPage.agentsTitle': 'Agent',
|
||||
'agentsPage.agentsTitle': '5 つの Agent',
|
||||
'agentsPage.skillLib': 'Skill ライブラリ',
|
||||
'agentsPage.skillLibCount': '{n} 件',
|
||||
'agentsPage.installedTitle': '{name} · 導入済み Skill',
|
||||
'agentsPage.installedCount': '{n} 件 · {m} 有効',
|
||||
'agentsPage.newSkill': 'Skill を追加',
|
||||
'agentsPage.skillLibEmpty': 'Skill がありません。上の「Skill を追加」から作成してください。',
|
||||
'agentsPage.skillsCount': 'Skill {n} 件',
|
||||
@@ -1706,4 +1840,88 @@ export const ja: Dict = {
|
||||
'home.errScriptShort': '脚本が短すぎます。貼り付けが完全か確認してください(30 文字以上)',
|
||||
'home.stageProgress': '五段階の進捗',
|
||||
'tasks.sourceWorkbench': '制作フロー',
|
||||
'jobst.idSuffix': ' · ComfyUI プロンプト ID {id}',
|
||||
'jobst.done': '完了',
|
||||
'jobst.doneDetail': 'ComfyUI の実行が完了し、結果を保存しました',
|
||||
'jobst.dryRun': 'プレースホルダー',
|
||||
'jobst.dryRunDetail': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
|
||||
'jobst.noOutput': '出力なし',
|
||||
'jobst.noOutputDetail': '結果ファイルなしで終了しました(生成完了ではありません)',
|
||||
'jobst.queued': '待機中',
|
||||
'jobst.queuedDetail': 'ワークベンチのキューで ComfyUI への送信を待っています',
|
||||
'jobst.connecting': '接続中',
|
||||
'jobst.connectingDetail': 'ComfyUI に接続して素材をアップロードしています',
|
||||
'jobst.comfyQueued': '受付済み',
|
||||
'jobst.comfyQueuedDetail': 'ComfyUI が受け付け、実行待ちです',
|
||||
'jobst.comfyQueuedAhead': 'ComfyUI が受け付けました。前に {n} 件あります',
|
||||
'jobst.comfyRunning': '実行中',
|
||||
'jobst.comfyRunningDetail': 'ComfyUI が実行中です',
|
||||
'jobst.saving': '保存中',
|
||||
'jobst.savingDetail': 'ComfyUI の結果をダウンロードしています',
|
||||
'jobst.failed': '失敗',
|
||||
'jobst.cancelled': 'キャンセル済み',
|
||||
'studio.ratioLabel': 'アスペクト比',
|
||||
'studio.megapixelsLabel': '総画素数(MP)',
|
||||
'studio.outputSize': '出力 {w} × {h}',
|
||||
'studio.denoiseLabel': 'ノイズ除去',
|
||||
'studio.errNumber': '「{label}」は数値で入力してください',
|
||||
'studio.errInteger': '「{label}」は整数で入力してください',
|
||||
'studio.errMin': '「{label}」は {v} 以上にしてください',
|
||||
'studio.errMax': '「{label}」は {v} 以下にしてください',
|
||||
'studio.errOption': '「{label}」はワークフローの選択肢にありません',
|
||||
'studio.reuseParams': '設定を再利用',
|
||||
'studio.reusedToast': 'このジョブの設定を反映しました。調整して再生成できます',
|
||||
'studio.reuseMissingWf': 'このジョブのワークフローはもう存在しません',
|
||||
'studio.doneToast': '完了:{n} ファイル(ComfyUI プロンプト ID {id})',
|
||||
'studio.dryRunToast': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
|
||||
'studio.dryRunNote': 'Dry-run が有効です:生成はプレースホルダーを書き出すだけで ComfyUI を呼び出しません。設定でオフにできます。',
|
||||
// --- HEL-643: インポート検査 / 実テスト / メディアグループ ---
|
||||
'studio.errVideoFormat': '「{name}」は未対応の動画形式です。MP4 / WebM / MOV を使ってください',
|
||||
'studio.errAudioFormat': '「{name}」は未対応の音声形式です。MP3 / WAV / M4A を使ってください',
|
||||
'studio.errMediaSize': '「{name}」はサイズ上限を超えています。先に圧縮してください',
|
||||
'studio.errNeedRefMedia': '「{label}」は最低 {n} 本必要です',
|
||||
'studio.refHintMedia': '必須 · 1–{max} 本 · 選択 {n}',
|
||||
'studio.refAddMedia': '{kind}を追加',
|
||||
'studio.refAddSubMedia': 'あと {n} 本 · 上限 {max}',
|
||||
'studio.refRemoveMedia': '{kind} {n} を削除',
|
||||
'studio.refFullMedia': '上限 {max} 本に達しました。削除してから追加してください',
|
||||
'wfMedia.h3Limits': 'MiniMax H3 Ref2VA の公式上限:参照画像は最大 9 枠、参照動画は 3 本、参照音声は 3 本。各クリップ 2–15 秒で種類ごとの合計 15 秒以下。音声のみの参照は不可。参照ファイルは合計 12 まで。',
|
||||
'wfMedia.h3TotalOver': '参照ファイルが {n} 個あり、公式上限 12 を超えています。生成に失敗する可能性があります',
|
||||
'wfMedia.h3AudioAlone': '音声だけで参照にすることはできません。参照画像か参照動画を少なくとも 1 つ追加してください',
|
||||
'conn.wfCheckTitle': '接続互換性チェック',
|
||||
'conn.wfCheckEndpoint': '対象:{name}',
|
||||
'conn.wfCheckBtn': '再チェック',
|
||||
'conn.wfChecking': 'チェック中…',
|
||||
'conn.wfCheckOk': 'この接続にはこのワークフローに必要なノードとモデルがあります',
|
||||
'conn.wfCheckProblems': '不足しているノード/モデルが {n} 件見つかりました',
|
||||
'conn.wfCheckUnreachable': '確認できません(ComfyUI に接続不可)。保存は可能です。接続を直してから再チェックしてください',
|
||||
'conn.wfCheckSaveHint': 'ドラフトは保存できます(生成可能とは表示されません)。不足項目を入れて再チェックしてください。',
|
||||
'conn.wfTestBtn': 'テスト',
|
||||
'conn.wfLastTest': '前回のテスト',
|
||||
'conn.wfTestPassed': '{time} 合格({n} ファイル)',
|
||||
'conn.wfTestRunning': '{time} 実行中',
|
||||
'conn.wfTestFailed': '{time} 不合格',
|
||||
'wft.title': 'ワークフローをテスト',
|
||||
'wft.connection': '対象接続',
|
||||
'wft.costNote': 'テストは選択した ComfyUI に実際のジョブを投入し、VRAM と待ち時間を消費します。',
|
||||
'wft.submit': 'テスト開始',
|
||||
'wft.confirmTitle': '実行の確認',
|
||||
'wft.confirmBody': '「{endpoint}」へ実際のジョブを 1 件投入します(現在のキュー {n} 件)。正しい種類のダウンロード可能な出力が 1 つ以上戻った場合のみ合格です。',
|
||||
'wft.running': 'テスト中…',
|
||||
'wft.cancel': 'テストを中止',
|
||||
'wft.passed': 'テスト合格:{n} ファイル生成',
|
||||
'wft.passedShort': '合格({n} ファイル)',
|
||||
'wft.failed': 'テスト不合格',
|
||||
'wft.failedShort': '不合格',
|
||||
'wft.cancelledShort': '中止しました',
|
||||
'wft.reusedToast': 'このワークフローはテスト実行中のため、そのジョブに接続しました',
|
||||
'wft.history': 'テスト記録({n} 件)',
|
||||
'wft.clear': 'テスト記録を消去',
|
||||
'wft.cleared': 'テスト記録を消去しました',
|
||||
'wft.never': '未テスト',
|
||||
'wft.download': 'ダウンロード',
|
||||
'wft.errRequired': '「{label}」を入力してください',
|
||||
'wft.errListMin': '「{label}」は最低 {n} {unit} 必要です',
|
||||
'tasks.testBadge': 'テスト',
|
||||
'tasks.sourceWorkflowTest': 'ワークフローテスト',
|
||||
};
|
||||
|
||||
+228
-10
@@ -164,6 +164,8 @@ export const ko: Dict = {
|
||||
|
||||
// --- API errors ---
|
||||
'api.unknownError': '문제가 발생했습니다',
|
||||
'api.networkError': '워크벤치 백엔드에 연결할 수 없습니다. 실행 중인지 확인 후 다시 시도하세요',
|
||||
'api.httpError': '요청 실패 (HTTP {status}): {detail}',
|
||||
'playground.openFailed': '플레이그라운드를 열지 못했습니다',
|
||||
'playground.opening': '플레이그라운드 여는 중…',
|
||||
'agents.opening': '에이전트 여는 중…',
|
||||
@@ -964,6 +966,27 @@ export const ko: Dict = {
|
||||
'library.emptyFilterBody': '이 필터와 일치하는 항목이 아직 없습니다 – 전체를 시도하거나 새로 생성해 보세요.',
|
||||
|
||||
// --- Settings ---
|
||||
'settings.pageSub': '생성 대기열·에이전트 실행·저장 경로 · LLM, ComfyUI 연결, Skill은 각 페이지에서 관리',
|
||||
'settings.secQueue': '대기열 및 생성',
|
||||
'settings.secQueueBlurb': '동시 실행, 재시도, 폴링, 시험 실행',
|
||||
'settings.secAgent': '에이전트 실행',
|
||||
'settings.secAgentBlurb': '단계, 컨텍스트, 프롬프트 강화, Shell',
|
||||
'settings.secStorage': '저장 경로',
|
||||
'settings.secStorageBlurb': '데이터·에셋·작업 폴더',
|
||||
'settings.elsewhere': '다른 설정',
|
||||
'settings.linkModelsBlurb': 'LLM과 에이전트별 기본 모델',
|
||||
'settings.linkConnectionsBlurb': '주소, 연결 테스트, 워크플로 라이브러리',
|
||||
'settings.linkAgentsBlurb': '다섯 에이전트의 Skill과 개별 대화',
|
||||
'settings.dryRunTitle': '시험 실행 모드',
|
||||
'settings.dryRunHint': '켜면 ComfyUI를 호출하지 않고 임시 이미지/영상을 반환해 흐름을 점검합니다. 실제 생성하려면 끄세요.',
|
||||
'settings.agentRunSection': '에이전트 실행',
|
||||
'settings.agentRunLead': '실행당 도구 호출 단계와 가져오는 기록을 제한합니다.',
|
||||
'settings.rangeHint': '범위 {min}–{max}',
|
||||
'settings.loadFailed': '설정을 불러오지 못했습니다. 서버가 실행 중인지 확인하세요',
|
||||
'skillName.character-consistency': '캐릭터 일관성',
|
||||
'skillName.h3-video-prompt-enhancer': 'H3 영상 프롬프트 강화',
|
||||
'skillName.scene-to-video': '장면을 영상으로',
|
||||
'skillName.shot-composer-blockout': '숏 구도 블로킹',
|
||||
'settings.loading': '설정 불러오는 중',
|
||||
'settings.allSaved': '모든 변경 사항이 저장되었습니다',
|
||||
'settings.unsaved': '저장되지 않은 변경 사항',
|
||||
@@ -1010,7 +1033,7 @@ export const ko: Dict = {
|
||||
'settings.queueSection': '작업 대기열',
|
||||
'settings.queueLead': '생성 작업과 에이전트 도구 호출의 동시 실행 수와 시간 초과를 설정합니다.',
|
||||
'settings.concurrencyField': '동시 작업 수',
|
||||
'settings.pollIntervalField': '폴링 간격 (ms)',
|
||||
'settings.pollIntervalField': '폴링 간격 (초)',
|
||||
'settings.pollTimeoutField': '작업 시간 초과 (초)',
|
||||
'settings.pollTimeoutHint': 'ComfyUI 작업 또는 에이전트 도구 호출 한 번의 제한 시간. 0이면 끝날 때까지 기다립니다.',
|
||||
'settings.maxRetriesField': '최대 재시도',
|
||||
@@ -1141,7 +1164,7 @@ export const ko: Dict = {
|
||||
'models.title': '모델 설정',
|
||||
'models.subtitle': '에이전트가 사용할 사용자 지정 LLM을 추가합니다 · 1단계는 LLM만 지원, 이미지 / 동영상 모델은 추후 제공',
|
||||
'models.statusCount': '모델 {n}개 · 사용 가능 {m}개',
|
||||
'models.add': '+ 모델 추가',
|
||||
'models.add': '모델 추가',
|
||||
'models.tabLlm': '대규모 언어 모델',
|
||||
'models.tabImage': '이미지 모델',
|
||||
'models.tabVideo': '동영상 모델',
|
||||
@@ -1225,6 +1248,7 @@ export const ko: Dict = {
|
||||
|
||||
// --- A shell + projects overview (HEL-608) ---
|
||||
'shell.brand': '영화 공작소',
|
||||
'shell.brandMark': '영',
|
||||
'shell.brandHome': '프로젝트 개요로 돌아가기',
|
||||
'shell.navAria': '주요 탐색',
|
||||
'shell.navProjects': '프로젝트',
|
||||
@@ -1238,7 +1262,9 @@ export const ko: Dict = {
|
||||
'shell.navSettings': '설정',
|
||||
'shell.wip': '개발 중',
|
||||
'shell.wipHint': '이 페이지는 준비 중이며 곧 열립니다',
|
||||
'shell.comfyAt': 'ComfyUI · {host}',
|
||||
'shell.comfy_ok': '{name} 연결됨',
|
||||
'shell.comfy_fail': '{name} 연결 실패',
|
||||
'shell.comfy_testing': '{name} 테스트 중…',
|
||||
'shell.comfyUnset': 'ComfyUI · 미설정',
|
||||
'shell.comfyManage': '「설정」에서 ComfyUI 주소를 설정하세요',
|
||||
'shell.mobileNotice': '이 워크벤치는 데스크톱 브라우저(폭 768px 이상)에서 열어 주세요. 휴대폰은 아직 지원되지 않습니다.',
|
||||
@@ -1293,6 +1319,13 @@ export const ko: Dict = {
|
||||
'conn.loading': '연결 불러오는 중…',
|
||||
'conn.loadFailed': '연결 정보를 불러오지 못했습니다',
|
||||
'conn.empty': '아직 ComfyUI 연결이 없습니다. 우측 상단 \'연결 추가\'로 첫 주소를 등록하세요.',
|
||||
'conn.targetsTitle': '작업 실행 대상',
|
||||
'conn.targetImage': '단독 이미지 생성',
|
||||
'conn.targetVideo': '단독 영상 생성',
|
||||
'conn.targetFlow': '제작 흐름 내 작업',
|
||||
'conn.targetChange': '변경',
|
||||
'conn.targetMixed': '연결 {n}개',
|
||||
'conn.targetNote': '작업은 워크플로 바인딩을 따르고, 바인딩이 없으면 기본 연결을 사용합니다. 바인딩은 워크플로 라이브러리에서 변경하며, 이미지 / 영상 페이지에서도 생성 전에 바꿀 수 있습니다.',
|
||||
'conn.defaultBadge': '기본',
|
||||
'conn.test': '연결 테스트',
|
||||
'conn.testing': '테스트 중…',
|
||||
@@ -1356,13 +1389,63 @@ export const ko: Dict = {
|
||||
'conn.wfParams': '채울 수 있는 매개변수 {n}개',
|
||||
'conn.wfImport': '워크플로 가져오기',
|
||||
'conn.wfImporting': '가져오는 중…',
|
||||
'conn.wfImported': '워크플로를 가져왔습니다',
|
||||
'conn.wfNodes': '노드 {n}개',
|
||||
'conn.wfImportedN': '워크플로를 가져왔습니다 — 매개변수 {n}개 감지',
|
||||
'conn.wfImportedNoParams': '가져왔지만 채울 수 있는 매개변수가 없습니다. 내장 설정으로 실행됩니다',
|
||||
'conn.wfImportFailed': '가져오기 실패: {detail}',
|
||||
'conn.wfImportTitle': 'ComfyUI 워크플로 가져오기(API 형식 JSON)',
|
||||
'conn.wfImportHint': '붙여넣기 또는 파일 선택. 이름은 필수이며 종류가 이미지/동영상 창 표시를 결정합니다.',
|
||||
'conn.wfJsonInvalid': '유효한 API 형식 JSON이 아닙니다(노드 ID → {{…class_type…}} 객체 필요)',
|
||||
'conn.wfJsonPh': '여기에 ComfyUI API 형식 JSON을 붙여넣거나 오른쪽 버튼으로 파일을 선택하세요',
|
||||
'conn.wfDeleted': '워크플로가 삭제되었습니다',
|
||||
'conn.wfImportedBroken': 'Imported, but the workflow file is incomplete and must be re-exported before generating: {detail}',
|
||||
'conn.wfBrokenBadge': 'Incomplete file — cannot generate',
|
||||
// --- Five usages & import confirmation (HEL-636) ---
|
||||
'usage.text2image': 'Text → Image',
|
||||
'usage.image2image': 'Image → Image',
|
||||
'usage.text2video': 'Text → Video',
|
||||
'usage.image2video': 'Image → Video',
|
||||
'usage.ref2video': 'Reference → Video',
|
||||
'usage.pending': 'Usage pending',
|
||||
'wfRole.image': 'Input image',
|
||||
'wfRole.first_frame': 'First frame',
|
||||
'wfRole.last_frame': 'Last frame',
|
||||
'wfRole.reference_image': 'Reference',
|
||||
'wfRole.mask': 'Mask',
|
||||
'conn.wfImportAction': 'Import workflow',
|
||||
'conn.wfConfirmTitle': 'Confirm detection',
|
||||
'conn.wfStepAnalyze': 'Paste & analyze',
|
||||
'conn.wfStepConfirm': 'Confirm & save',
|
||||
'conn.wfChooseFile': 'Choose file',
|
||||
'conn.wfAnalyze': 'Analyze',
|
||||
'conn.wfAnalyzing': 'Analyzing…',
|
||||
'conn.wfConfirmHint': 'Review the evidence, output nodes and image slots; correct the usage or slot roles before saving.',
|
||||
'conn.wfUsageLabel': 'Usage',
|
||||
'conn.wfUsageAuto': 'Auto-detected: {usage}',
|
||||
'conn.wfUsageNeedPick': 'Pick manually',
|
||||
'conn.wfPickUsage': 'Choose a usage first',
|
||||
'conn.wfSlotsLabel': 'Image slots',
|
||||
'conn.wfSlotsHint': 'Roles are inferred from wiring; override them (input image / first / last frame / reference / mask) and the manual choice sticks.',
|
||||
'conn.wfBack': 'Back',
|
||||
'conn.wfConfirmSave': 'Confirm & save',
|
||||
'conn.wfImportedWithUsage': 'Saved as “{usage}” with {n} fillable parameters',
|
||||
'conn.wfPendingCount': '{n} pending',
|
||||
'conn.wfConfirmed': 'Usage confirmed: {usage} — the workflow can generate now',
|
||||
'conn.wfSourceUser': 'Usage set manually; re-analysis will not overwrite it',
|
||||
'conn.wfSourceAuto': 'Usage auto-detected',
|
||||
'conn.wfDisabledBadge': 'Disabled',
|
||||
'conn.wfConfirmAria': 'Confirm workflow usage',
|
||||
'conn.wfOutputLabel': '출력 노드',
|
||||
'conn.wfOutputNeedPick': '인식할 수 없는 사용자 지정 출력 노드가 있습니다. 직접 지정하세요',
|
||||
'conn.wfOutputHint': '실제 결과를 내는 노드를 체크하고 각각의 미디어 유형을 선택하세요. 생성 시 이 노드에서만 이미지/동영상을 수집합니다.',
|
||||
'conn.wfOutputPick': '출력 노드를 하나 이상 체크하세요',
|
||||
'conn.wfOutputKindAria': '출력 노드 미디어 유형',
|
||||
'conn.wfOutputPendingBadge': '출력 미지정',
|
||||
'conn.wfOutputNeedMap': '인식할 수 없는 출력 노드가 포함되어 있습니다. 노드와 미디어 유형을 지정하면 활성화됩니다',
|
||||
'conn.wfOutputApply': '출력 매핑 저장',
|
||||
'conn.wfMapCancel': '취소',
|
||||
'conn.wfOutputApplied': '출력 매핑 저장됨: 노드 {nodes}',
|
||||
'conn.wfOutputCleared': '출력 매핑이 삭제되었습니다. 워크플로가 다시 대기 상태가 됩니다',
|
||||
'conn.missingMaterial': 'Qwen Image 2.1 전용 워크플로 JSON이 아직 저장소에 없습니다. ComfyUI에서 내보내 여기서 가져오세요. 범용 가져오기 경로는 준비되어 있습니다.',
|
||||
|
||||
// --- 독립 이미지/비디오 생성 (a3/a4, HEL-612) ---
|
||||
@@ -1370,14 +1453,24 @@ export const ko: Dict = {
|
||||
'studio.image.subtitle': '가져온 이미지 워크플로로 생성 · 연결과 큐는 ComfyUI 연결과 공유',
|
||||
'studio.video.title': '비디오 생성',
|
||||
'studio.video.subtitle': '가져온 비디오 워크플로로 생성 · 연결과 큐는 ComfyUI 연결과 공유',
|
||||
'studio.genParams': '생성 매개변수',
|
||||
'studio.modeTextToImage': '텍스트→이미지',
|
||||
'studio.modeTextToVideo': '텍스트→영상',
|
||||
'studio.chipIdle': '{name} · 대기열 비어 있음',
|
||||
'studio.chipBusy': '{name} · 대기열 {n}건',
|
||||
'studio.video.resultsTitle': '미리보기',
|
||||
'studio.image.historyTitle': '이미지 생성 기록',
|
||||
'studio.video.historyTitle': '영상 생성 기록',
|
||||
'studio.historyCount': '총 {n}회',
|
||||
'studio.doneAt': '완료 {time}',
|
||||
'studio.kindImageText': '이미지',
|
||||
'studio.kindVideoText': '비디오',
|
||||
'studio.loading': '불러오는 중…',
|
||||
'studio.connLabel': '생성 연결',
|
||||
'studio.connQueue': '대기열 {n}',
|
||||
'studio.connUntested': '미테스트',
|
||||
'studio.connOk': '마지막 테스트 통과 · {ms} ms',
|
||||
'studio.connFail': '마지막 테스트 실패',
|
||||
'studio.connUntested': '확인 중…',
|
||||
'studio.connOk': '연결됨 · {ms} ms',
|
||||
'studio.connFail': '연결 실패',
|
||||
'studio.connManage': '연결 관리',
|
||||
'studio.wfLabel': '워크플로',
|
||||
'studio.wfParams': '채울 수 있는 매개변수 {n}개',
|
||||
@@ -1440,17 +1533,55 @@ export const ko: Dict = {
|
||||
'studio.sentToImg2ImgToast': '참조 이미지로 채웠습니다 — 매개변수를 확인하고 생성하세요',
|
||||
'studio.noImg2ImgWf': '참조 이미지 입력을 선언한 이미지 워크플로가 아직 없습니다. 먼저 ComfyUI 연결에서 img2img 워크플로를 가져오세요',
|
||||
'studio.videoRefStrip': '첫 / 마지막 프레임 참조',
|
||||
'studio.historyTitle': '기록',
|
||||
'studio.historyEmptyImage': '이미지 생성 기록이 없습니다. 왼쪽에서 매개변수를 설정하고 생성 시작',
|
||||
'studio.historyEmptyVideo': '비디오 생성 기록이 없습니다. 왼쪽에서 매개변수를 설정하고 생성 시작',
|
||||
'studio.jobName': '작업 #{id}',
|
||||
'studio.retryLink': '재시도',
|
||||
'studio.retryQueuedToast': '작업이 다시 대기열에 들어갔습니다',
|
||||
'studio.retryFailedToast': '재대기열 실패: {detail}',
|
||||
'studio.errNoWorkflow': '먼저 워크플로를 선택하세요',
|
||||
'studio.errBlocked': '제출할 수 없음: {reason}',
|
||||
'studio.queuedToast': '“{name}”에 제출됨, 대기 중',
|
||||
'studio.submitFailedToast': '제출 실패: {detail}',
|
||||
'studio.jobFailedToast': '생성 실패: {detail}',
|
||||
'studio.wfNoParamsNote': '이 워크플로에는 채울 매개변수가 없어 내장 프롬프트와 설정으로 실행됩니다. 여기서 입력하려면 ComfyUI에서 노드 제목에 (Input:prompt) 등을 붙인 뒤 다시 가져오세요.',
|
||||
'studio.wfAutoParamsNote': '(Input:…) 제목 태그가 없어 노드 유형으로 매개변수 {n}개를 자동 감지했습니다. 빈 칸은 워크플로 값을 유지합니다.',
|
||||
'studio.promptNotDeclared': '이 워크플로에는 프롬프트 노드가 없습니다',
|
||||
'studio.modeImageEdit': 'Image edit',
|
||||
'studio.modeImageToVideo': 'Image to video',
|
||||
'studio.modeRefToVideo': 'Reference to video',
|
||||
'studio.slotInput': 'Input image',
|
||||
'studio.slotHintOne': 'Required · 1 image',
|
||||
'studio.slotEmptyInput': 'Click or drop the input image',
|
||||
'studio.slotEmptyFirst': 'Click or drop the first frame',
|
||||
'studio.slotEmptySub': 'PNG / JPG / WebP · ≤ 20MB each',
|
||||
'studio.slotFromResultsShort': 'From results',
|
||||
'studio.errImageFormat': '"{name}" is not supported — use PNG / JPG / WebP; this file will not be used',
|
||||
'studio.errImageSize': '"{name}" is larger than 20MB — compress it and upload again',
|
||||
'studio.errNeedInputImage': 'Image edit needs an input image',
|
||||
'studio.errNeedFirstFrame': 'Image to video needs a first frame',
|
||||
'studio.errNeedRef': 'Reference to video needs at least 1 reference image',
|
||||
'studio.errRefMax': 'At most {max} reference images: {n} selected, these {k} were not added',
|
||||
'studio.refHint': 'Required · 1–{max} · {n} selected',
|
||||
'studio.refAdd': 'Add reference image',
|
||||
'studio.refAddSub': '{n} more · max {max}',
|
||||
'studio.refReplace': 'Replace',
|
||||
'studio.refRemove': 'Remove reference {n}',
|
||||
'studio.refRejectedShort': 'Not added',
|
||||
'studio.refFull': 'Limit of {max} reached — remove one to add another',
|
||||
'studio.refStrip': 'Reference images ({n} used)',
|
||||
'studio.resolutionLabel': 'Output resolution',
|
||||
'studio.resolutionHint': 'About {v}×{v} px, keeps the input aspect; 0 = original size',
|
||||
'studio.wfBrokenTitle': 'This workflow file is incomplete — ComfyUI cannot run it, generation is blocked',
|
||||
'studio.wfBrokenHint': 'Re-export the API-format JSON from a ComfyUI that has the custom nodes installed, then import it again under ComfyUI connections.',
|
||||
'studio.errWfBroken': 'Workflow file is incomplete — re-export it before generating',
|
||||
'studio.cancelToast': '취소를 요청했습니다',
|
||||
'studio.deleteBtn': '삭제',
|
||||
'studio.delTitle': '작업 기록 삭제',
|
||||
'studio.delConfirm': '결과 파일도 함께 삭제되며 기록은 복구할 수 없습니다. 삭제할까요?',
|
||||
'studio.delConfirm': '삭제 시 이 작업의 결과 파일도 함께 제거되며 기록은 복구할 수 없습니다. 삭제할까요?',
|
||||
'studio.delTitleJob': '작업 «{name}» 삭제',
|
||||
'studio.delConfirmMulti': '이 작업에는 결과가 {n}개 있습니다. 삭제하면 작업 기록과 결과 파일 {n}개 모두 제거되며 복구할 수 없습니다. 삭제할까요?',
|
||||
'studio.deletedToast': '작업과 결과 파일을 삭제했습니다',
|
||||
'studio.wfEmptyTitle': '{kind} 워크플로가 아직 없습니다',
|
||||
'studio.wfEmptyBody': 'ComfyUI 개발자 모드를 켜고 API 형식 JSON으로 내보낸 뒤, ComfyUI 연결 페이지에서 {kind}(으)로 가져오세요.',
|
||||
'studio.wfEmptyGo': 'ComfyUI 연결에서 가져오기',
|
||||
@@ -1492,8 +1623,11 @@ export const ko: Dict = {
|
||||
'agentsPage.title': '에이전트',
|
||||
'agentsPage.subtitle': '다섯 제작 에이전트와 개별 대화하며 Skill을 정의·할당',
|
||||
'agentsPage.statusChip': 'Skill {n}개 · 활성 {m}개',
|
||||
'agentsPage.agentsTitle': 'Agent',
|
||||
'agentsPage.agentsTitle': '다섯 Agent',
|
||||
'agentsPage.skillLib': 'Skill 라이브러리',
|
||||
'agentsPage.skillLibCount': '{n}개',
|
||||
'agentsPage.installedTitle': '{name} · 설치된 Skill',
|
||||
'agentsPage.installedCount': '{n}개 · {m} 사용',
|
||||
'agentsPage.newSkill': 'Skill 추가',
|
||||
'agentsPage.skillLibEmpty': 'Skill이 없습니다. 위의「Skill 추가」로 만들어 보세요.',
|
||||
'agentsPage.skillsCount': 'Skill {n}개',
|
||||
@@ -1706,4 +1840,88 @@ export const ko: Dict = {
|
||||
'home.errScriptShort': '대본이 너무 짧습니다. 붙여넣기가 완전한지 확인하세요(30자 이상)',
|
||||
'home.stageProgress': '5단계 진행',
|
||||
'tasks.sourceWorkbench': '제작 흐름',
|
||||
'jobst.idSuffix': ' · ComfyUI 프롬프트 ID {id}',
|
||||
'jobst.done': '완료',
|
||||
'jobst.doneDetail': 'ComfyUI 실행이 끝나 결과를 저장했습니다',
|
||||
'jobst.dryRun': '자리표시자',
|
||||
'jobst.dryRunDetail': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
|
||||
'jobst.noOutput': '출력 없음',
|
||||
'jobst.noOutputDetail': '결과 파일 없이 끝났습니다 — 생성 완료가 아닙니다',
|
||||
'jobst.queued': '대기 중',
|
||||
'jobst.queuedDetail': '워크벤치 대기열에서 ComfyUI 전송을 기다리는 중',
|
||||
'jobst.connecting': '연결 중',
|
||||
'jobst.connectingDetail': 'ComfyUI에 연결하고 입력을 업로드하는 중',
|
||||
'jobst.comfyQueued': '접수됨',
|
||||
'jobst.comfyQueuedDetail': 'ComfyUI가 작업을 접수해 대기열에 넣었습니다',
|
||||
'jobst.comfyQueuedAhead': 'ComfyUI가 접수함 — 앞에 {n}개 대기',
|
||||
'jobst.comfyRunning': '실행 중',
|
||||
'jobst.comfyRunningDetail': 'ComfyUI가 실행 중입니다',
|
||||
'jobst.saving': '저장 중',
|
||||
'jobst.savingDetail': 'ComfyUI 결과를 내려받는 중',
|
||||
'jobst.failed': '실패',
|
||||
'jobst.cancelled': '취소됨',
|
||||
'studio.ratioLabel': '화면 비율',
|
||||
'studio.megapixelsLabel': '메가픽셀',
|
||||
'studio.outputSize': '출력 {w} × {h}',
|
||||
'studio.denoiseLabel': '디노이즈',
|
||||
'studio.errNumber': '‘{label}’은(는) 숫자여야 합니다',
|
||||
'studio.errInteger': '‘{label}’은(는) 정수여야 합니다',
|
||||
'studio.errMin': '‘{label}’은(는) {v} 이상이어야 합니다',
|
||||
'studio.errMax': '‘{label}’은(는) {v} 이하여야 합니다',
|
||||
'studio.errOption': '‘{label}’ 값이 워크플로 옵션에 없습니다',
|
||||
'studio.reuseParams': '설정 재사용',
|
||||
'studio.reusedToast': '이 작업의 설정을 채웠습니다. 수정 후 다시 생성하세요',
|
||||
'studio.reuseMissingWf': '이 작업의 워크플로가 더 이상 없습니다',
|
||||
'studio.doneToast': '완료: 파일 {n}개 (ComfyUI 프롬프트 ID {id})',
|
||||
'studio.dryRunToast': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
|
||||
'studio.dryRunNote': 'Dry-run이 켜져 있습니다: 생성은 자리표시자만 쓰고 ComfyUI를 호출하지 않습니다. 설정에서 끌 수 있습니다.',
|
||||
// --- HEL-643: 가져오기 검사 / 실제 테스트 / 미디어 그룹 ---
|
||||
'studio.errVideoFormat': '«{name}»은(는) 지원하지 않는 동영상 형식입니다. MP4 / WebM / MOV를 사용하세요',
|
||||
'studio.errAudioFormat': '«{name}»은(는) 지원하지 않는 오디오 형식입니다. MP3 / WAV / M4A를 사용하세요',
|
||||
'studio.errMediaSize': '«{name}»이(가) 크기 제한을 초과합니다. 먼저 압축하세요',
|
||||
'studio.errNeedRefMedia': '«{label}»에 최소 {n}개가 필요합니다',
|
||||
'studio.refHintMedia': '필수 · 1–{max}개 · 선택 {n}',
|
||||
'studio.refAddMedia': '{kind} 추가',
|
||||
'studio.refAddSubMedia': '{n}개 더 · 최대 {max}',
|
||||
'studio.refRemoveMedia': '{kind} {n} 제거',
|
||||
'studio.refFullMedia': '최대 {max}개에 도달했습니다. 제거 후 추가하세요',
|
||||
'wfMedia.h3Limits': 'MiniMax H3 Ref2VA 공식 한도: 참조 이미지 최대 9장, 참조 영상 3개, 참조 오디오 3개. 클립당 2–15초이며 유형별 총합 15초 이하. 오디오만 단독 참조는 불가. 참조 파일은 총 12개까지.',
|
||||
'wfMedia.h3TotalOver': '참조 파일이 {n}개로 공식 한도 12를 초과했습니다. 생성이 실패할 수 있습니다',
|
||||
'wfMedia.h3AudioAlone': '오디오만 단독 참조가 될 수 없습니다. 참조 이미지 또는 영상을 1개 이상 추가하세요',
|
||||
'conn.wfCheckTitle': '연결 호환성 검사',
|
||||
'conn.wfCheckEndpoint': '대상: {name}',
|
||||
'conn.wfCheckBtn': '다시 검사',
|
||||
'conn.wfChecking': '검사 중…',
|
||||
'conn.wfCheckOk': '이 연결은 이 워크플로에 필요한 노드와 모델을 갖추고 있습니다',
|
||||
'conn.wfCheckProblems': '누락된 노드/모델 {n}개를 발견했습니다',
|
||||
'conn.wfCheckUnreachable': '확인할 수 없습니다(ComfyUI 연결 불가). 저장은 가능하며 연결을 고친 뒤 다시 검사하세요',
|
||||
'conn.wfCheckSaveHint': '초안은 저장할 수 있습니다(생성 가능으로 표시되지 않음). 누락 항목을 설치한 뒤 다시 검사하세요.',
|
||||
'conn.wfTestBtn': '테스트',
|
||||
'conn.wfLastTest': '마지막 테스트',
|
||||
'conn.wfTestPassed': '{time} 통과({n}개 파일)',
|
||||
'conn.wfTestRunning': '{time} 진행 중',
|
||||
'conn.wfTestFailed': '{time} 실패',
|
||||
'wft.title': '워크플로 테스트',
|
||||
'wft.connection': '대상 연결',
|
||||
'wft.costNote': '테스트는 선택한 ComfyUI에 실제 작업을 1건 보내 VRAM과 대기열을 사용합니다.',
|
||||
'wft.submit': '테스트 시작',
|
||||
'wft.confirmTitle': '실제 실행 확인',
|
||||
'wft.confirmBody': '«{endpoint}»에 실제 작업 1건을 제출합니다(현재 대기열 {n}건). 올바른 유형의 다운로드 가능한 출력이 1개 이상 돌아와야 통과입니다.',
|
||||
'wft.running': '테스트 중…',
|
||||
'wft.cancel': '테스트 취소',
|
||||
'wft.passed': '테스트 통과: 파일 {n}개 생성',
|
||||
'wft.passedShort': '통과({n}개 파일)',
|
||||
'wft.failed': '테스트 실패',
|
||||
'wft.failedShort': '실패',
|
||||
'wft.cancelledShort': '취소됨',
|
||||
'wft.reusedToast': '이 워크플로는 이미 테스트 진행 중입니다. 해당 작업에 연결했습니다',
|
||||
'wft.history': '테스트 기록({n}건)',
|
||||
'wft.clear': '테스트 기록 삭제',
|
||||
'wft.cleared': '테스트 기록을 삭제했습니다',
|
||||
'wft.never': '테스트 이력 없음',
|
||||
'wft.download': '다운로드',
|
||||
'wft.errRequired': '«{label}»을(를) 입력하세요',
|
||||
'wft.errListMin': '«{label}»에 최소 {n}{unit}이(가) 필요합니다',
|
||||
'tasks.testBadge': '테스트',
|
||||
'tasks.sourceWorkflowTest': '워크플로 테스트',
|
||||
};
|
||||
|
||||
+236
-17
@@ -164,6 +164,8 @@ export const zh: Dict = {
|
||||
|
||||
// --- API errors ---
|
||||
'api.unknownError': '出错了',
|
||||
'api.networkError': '无法连接到工作台后端,请确认服务在运行、网络正常后重试',
|
||||
'api.httpError': '请求失败(HTTP {status}):{detail}',
|
||||
'playground.openFailed': '无法打开演练场',
|
||||
'playground.opening': '正在打开演练场……',
|
||||
'agents.opening': '正在打开智能体……',
|
||||
@@ -964,6 +966,27 @@ export const zh: Dict = {
|
||||
'library.emptyFilterBody': '此筛选条件暂无匹配项 – 试试“全部”或生成新内容。',
|
||||
|
||||
// --- Settings ---
|
||||
'settings.pageSub': '生成队列、智能体运行与存储路径 · 大模型、ComfyUI 连接与 Skill 在各自页面管理',
|
||||
'settings.secQueue': '生成与队列',
|
||||
'settings.secQueueBlurb': '并发、重试、轮询与试运行',
|
||||
'settings.secAgent': '智能体运行',
|
||||
'settings.secAgentBlurb': '步数、上下文、提示词加固与 Shell',
|
||||
'settings.secStorage': '存储路径',
|
||||
'settings.secStorageBlurb': '数据、素材与智能体工作区目录',
|
||||
'settings.elsewhere': '其他设置入口',
|
||||
'settings.linkModelsBlurb': '大语言模型与智能体默认模型',
|
||||
'settings.linkConnectionsBlurb': '连接地址、连通测试与工作流库',
|
||||
'settings.linkAgentsBlurb': '五个智能体的 Skill 与独立会话',
|
||||
'settings.dryRunTitle': '试运行模式',
|
||||
'settings.dryRunHint': '开启后不调用 ComfyUI,生成任务直接产出占位图片 / 视频,用于走通流程;关闭后才会真实生成。',
|
||||
'settings.agentRunSection': '智能体运行',
|
||||
'settings.agentRunLead': '限制每次运行的工具调用步数与带入的历史上下文。',
|
||||
'settings.rangeHint': '范围 {min}–{max}',
|
||||
'settings.loadFailed': '设置加载失败,请检查工作台服务是否在运行',
|
||||
'skillName.character-consistency': '角色一致性',
|
||||
'skillName.h3-video-prompt-enhancer': 'H3 视频提示词增强',
|
||||
'skillName.scene-to-video': '分镜转视频',
|
||||
'skillName.shot-composer-blockout': '镜头构图预演',
|
||||
'settings.loading': '正在加载设置',
|
||||
'settings.allSaved': '所有更改已保存',
|
||||
'settings.unsaved': '未保存的更改',
|
||||
@@ -1010,7 +1033,7 @@ export const zh: Dict = {
|
||||
'settings.queueSection': '任务队列',
|
||||
'settings.queueLead': '生成任务和代理工具调用的并发数与超时设置。',
|
||||
'settings.concurrencyField': '并发任务数',
|
||||
'settings.pollIntervalField': '轮询间隔(毫秒)',
|
||||
'settings.pollIntervalField': '轮询间隔(秒)',
|
||||
'settings.pollTimeoutField': '任务超时(秒)',
|
||||
'settings.pollTimeoutHint': 'ComfyUI 任务或单次代理工具调用的最长时长。0 表示一直等到结束。',
|
||||
'settings.maxRetriesField': '最大重试次数',
|
||||
@@ -1143,7 +1166,7 @@ export const zh: Dict = {
|
||||
'models.title': '模型设置',
|
||||
'models.subtitle': '添加自定义大语言模型(LLM),供智能体选用 · 第一阶段仅开放 LLM,图片 / 视频模型后续接入',
|
||||
'models.statusCount': '{n} 个模型 · {m} 个可用',
|
||||
'models.add': '+ 添加模型',
|
||||
'models.add': '添加模型',
|
||||
'models.tabLlm': '大语言模型',
|
||||
'models.tabImage': '图片模型',
|
||||
'models.tabVideo': '视频模型',
|
||||
@@ -1227,6 +1250,7 @@ export const zh: Dict = {
|
||||
|
||||
// --- A shell + projects overview (HEL-608) ---
|
||||
'shell.brand': '影工坊',
|
||||
'shell.brandMark': '影',
|
||||
'shell.brandHome': '返回项目总览',
|
||||
'shell.navAria': '主导航',
|
||||
'shell.navProjects': '项目',
|
||||
@@ -1240,7 +1264,9 @@ export const zh: Dict = {
|
||||
'shell.navSettings': '设置',
|
||||
'shell.wip': '开发中',
|
||||
'shell.wipHint': '该页面正在开发中,稍后开放',
|
||||
'shell.comfyAt': 'ComfyUI · {host}',
|
||||
'shell.comfy_ok': '{name} 已连接',
|
||||
'shell.comfy_fail': '{name} 连接失败',
|
||||
'shell.comfy_testing': '{name} 测试中…',
|
||||
'shell.comfyUnset': 'ComfyUI · 未配置',
|
||||
'shell.comfyManage': '管理 ComfyUI 连接',
|
||||
'shell.mobileNotice': '请用桌面浏览器(宽 768px 以上)打开影工坊,手机端暂不适配。',
|
||||
@@ -1293,6 +1319,13 @@ export const zh: Dict = {
|
||||
'conn.loading': '正在加载连接…',
|
||||
'conn.loadFailed': '连接信息加载失败',
|
||||
'conn.empty': '还没有 ComfyUI 连接,点右上角「添加连接」登记第一个地址。',
|
||||
'conn.targetsTitle': '任务使用目标',
|
||||
'conn.targetImage': '独立生图',
|
||||
'conn.targetVideo': '独立生视频',
|
||||
'conn.targetFlow': '创作流程内任务',
|
||||
'conn.targetChange': '更改',
|
||||
'conn.targetMixed': '{n} 个连接',
|
||||
'conn.targetNote': '按工作流绑定调度,未绑定的工作流使用默认连接;在工作流库里改绑定。生图 / 生视频页也可在生成前临时切换。',
|
||||
'conn.defaultBadge': '默认',
|
||||
'conn.test': '测试连接',
|
||||
'conn.testing': '测试中…',
|
||||
@@ -1346,7 +1379,7 @@ export const zh: Dict = {
|
||||
'conn.noticeStrong': '连通性以服务器为准:',
|
||||
'conn.noticeBody': '地址在浏览器能打开不代表生成可用;测试与生成请求都由运行影工坊的服务器发起。令牌仅存服务端。',
|
||||
'conn.wfTitle': '工作流库',
|
||||
'conn.wfHint': '导入 ComfyUI 的 API 格式 JSON,分类为图片 / 视频,并绑定到某个连接',
|
||||
'conn.wfHint': '导入 ComfyUI 的 API 格式 JSON,自动判定五类用途,并绑定到某个连接',
|
||||
'conn.wfEmpty': '还没有导入工作流。在 ComfyUI 里启用开发者模式,用「导出(API 格式)」得到 JSON 后粘贴导入。',
|
||||
'conn.wfCount': '{n} 个工作流 · {m} 图片 · {k} 视频',
|
||||
'conn.wfKindImage': '图片',
|
||||
@@ -1356,27 +1389,87 @@ export const zh: Dict = {
|
||||
'conn.wfParams': '{n} 个可填参数',
|
||||
'conn.wfImport': '导入工作流',
|
||||
'conn.wfImporting': '导入中…',
|
||||
'conn.wfImported': '工作流已导入',
|
||||
'conn.wfImportFailed': '导入失败:{detail}',
|
||||
'conn.wfImportTitle': '导入 ComfyUI 工作流(API 格式 JSON)',
|
||||
'conn.wfImportHint': '粘贴或选择文件;名称必填,分类决定它出现在生图还是生视频窗口。',
|
||||
'conn.wfJsonInvalid': '不是有效的 API 格式 JSON(应为节点 id 到 class_type 的对象)',
|
||||
'conn.wfJsonPh': '在此粘贴 ComfyUI 导出的 API 格式 JSON,或点右侧按钮选择文件',
|
||||
'conn.wfDeleted': '工作流已删除',
|
||||
'conn.missingMaterial': 'Qwen Image 2.1 的专用工作流 JSON 仓库中暂缺:请从 ComfyUI 导出后在此导入,通用导入通道已就绪。',
|
||||
'conn.wfNodes': '{n} 个节点',
|
||||
'conn.wfImportedN': '工作流已导入,识别到 {n} 个可填参数',
|
||||
'conn.wfImportedNoParams': '工作流已导入,但没有识别到可填参数:生成时将按工作流内置设置运行',
|
||||
'conn.wfImportFailed': '导入失败:{detail}',
|
||||
'conn.wfImportTitle': '导入 ComfyUI 工作流(API 格式 JSON)',
|
||||
'conn.wfImportHint': '粘贴或选择文件并点「分析」;系统按输出节点和图片槽位判定五类用途,确认后保存。',
|
||||
'conn.wfJsonInvalid': '不是有效的 API 格式 JSON(应为节点 id 到 class_type 的对象)',
|
||||
'conn.wfJsonPh': '在此粘贴 ComfyUI 导出的 API 格式 JSON,或点右侧按钮选择文件',
|
||||
'conn.wfDeleted': '工作流已删除',
|
||||
'conn.wfImportedBroken': '已导入,但工作流文件不完整,生成前需重新导出:{detail}',
|
||||
'conn.wfBrokenBadge': '文件不完整,无法生成',
|
||||
// --- 五类用途与导入确认(HEL-636)---
|
||||
'usage.text2image': '文生图',
|
||||
'usage.image2image': '图生图',
|
||||
'usage.text2video': '文生视频',
|
||||
'usage.image2video': '图生视频',
|
||||
'usage.ref2video': '参考图生视频',
|
||||
'usage.pending': '用途待确认',
|
||||
'wfRole.image': '输入图',
|
||||
'wfRole.first_frame': '首帧',
|
||||
'wfRole.last_frame': '尾帧',
|
||||
'wfRole.reference_image': '参考图',
|
||||
'wfRole.mask': '遮罩',
|
||||
'conn.wfImportAction': '导入工作流',
|
||||
'conn.wfConfirmTitle': '确认识别结果',
|
||||
'conn.wfStepAnalyze': '粘贴分析',
|
||||
'conn.wfStepConfirm': '确认保存',
|
||||
'conn.wfChooseFile': '选择文件',
|
||||
'conn.wfAnalyze': '分析',
|
||||
'conn.wfAnalyzing': '分析中…',
|
||||
'conn.wfConfirmHint': '核对判定依据、输出节点与图片槽位;用途或槽位不对可在此纠正后再保存。',
|
||||
'conn.wfUsageLabel': '用途',
|
||||
'conn.wfUsageAuto': '自动判定:{usage}',
|
||||
'conn.wfUsageNeedPick': '需人工选择',
|
||||
'conn.wfPickUsage': '请先选择工作流用途',
|
||||
'conn.wfSlotsLabel': '图片槽位',
|
||||
'conn.wfSlotsHint': '槽位角色按连线推断;不符时可改为输入图 / 首帧 / 尾帧 / 参考图 / 遮罩,保存后以人工选择为准。',
|
||||
'conn.wfBack': '上一步',
|
||||
'conn.wfConfirmSave': '确认保存',
|
||||
'conn.wfImportedWithUsage': '已保存为「{usage}」,识别到 {n} 个可填参数',
|
||||
'conn.wfPendingCount': '{n} 个待确认',
|
||||
'conn.wfConfirmed': '已确认用途:{usage},工作流已可生成',
|
||||
'conn.wfSourceUser': '用途为人工确认,重新分析不会覆盖',
|
||||
'conn.wfSourceAuto': '用途为自动判定',
|
||||
'conn.wfDisabledBadge': '已停用',
|
||||
'conn.wfConfirmAria': '确认工作流用途',
|
||||
'conn.wfOutputLabel': '输出节点',
|
||||
'conn.wfOutputNeedPick': '存在无法识别的自定义输出节点,需人工指定',
|
||||
'conn.wfOutputHint': '勾选实际产生结果的节点,并为每个节点选择媒体类型;生成时只从这些节点提取图片/视频。',
|
||||
'conn.wfOutputPick': '请至少勾选一个输出节点',
|
||||
'conn.wfOutputKindAria': '输出节点媒体类型',
|
||||
'conn.wfOutputPendingBadge': '待指定输出',
|
||||
'conn.wfOutputNeedMap': '该工作流含无法识别的输出节点,指定输出节点及媒体类型后才能启用',
|
||||
'conn.wfOutputApply': '保存输出映射',
|
||||
'conn.wfMapCancel': '取消',
|
||||
'conn.wfOutputApplied': '已保存输出映射:节点 {nodes}',
|
||||
'conn.wfOutputCleared': '已清除输出映射,工作流回到待确认状态',
|
||||
'conn.missingMaterial': 'Qwen Image 2.1 的专用工作流 JSON 仓库中暂缺:请从 ComfyUI 导出后在此导入,通用导入通道已就绪。',
|
||||
// --- 独立生图 / 生视频(a3/a4,HEL-612)---
|
||||
'studio.image.title': '生图',
|
||||
'studio.image.subtitle': '用已导入的图片工作流生成图片 · 连接与队列和「ComfyUI 连接」共用',
|
||||
'studio.video.title': '生视频',
|
||||
'studio.video.subtitle': '用已导入的视频工作流生成视频 · 连接与队列和「ComfyUI 连接」共用',
|
||||
'studio.genParams': '生成参数',
|
||||
'studio.modeTextToImage': '文生图',
|
||||
'studio.modeTextToVideo': '文生视频',
|
||||
'studio.chipIdle': '{name} · 队列空闲',
|
||||
'studio.chipBusy': '{name} · 队列 {n} 个任务',
|
||||
'studio.video.resultsTitle': '成片预览',
|
||||
'studio.image.historyTitle': '生图历史',
|
||||
'studio.video.historyTitle': '生视频历史',
|
||||
'studio.historyCount': '共 {n} 次',
|
||||
'studio.doneAt': '完成于 {time}',
|
||||
'studio.kindImageText': '图片',
|
||||
'studio.kindVideoText': '视频',
|
||||
'studio.loading': '正在加载…',
|
||||
'studio.connLabel': '生成连接',
|
||||
'studio.connQueue': '队列 {n}',
|
||||
'studio.connUntested': '未测试',
|
||||
'studio.connOk': '上次测试通过 · {ms} ms',
|
||||
'studio.connFail': '上次测试失败',
|
||||
'studio.connUntested': '检测中…',
|
||||
'studio.connOk': '已连接 · {ms} ms',
|
||||
'studio.connFail': '连接失败',
|
||||
'studio.connManage': '管理连接',
|
||||
'studio.wfLabel': '工作流',
|
||||
'studio.wfParams': '{n} 个可填参数',
|
||||
@@ -1439,17 +1532,56 @@ export const zh: Dict = {
|
||||
'studio.sentToImg2ImgToast': '已填入为参考图,请检查参数后生成',
|
||||
'studio.noImg2ImgWf': '还没有声明参考图输入的图片工作流,请先在「ComfyUI 连接」导入图生图工作流',
|
||||
'studio.videoRefStrip': '首帧 / 尾帧参考',
|
||||
'studio.historyTitle': '历史',
|
||||
'studio.historyEmptyImage': '还没有生图记录,从左边填好参数点「开始生成」',
|
||||
'studio.historyEmptyVideo': '还没有生视频记录,从左边填好参数点「开始生成」',
|
||||
'studio.jobName': '任务 #{id}',
|
||||
'studio.retryLink': '重试',
|
||||
'studio.retryQueuedToast': '任务已重新排队',
|
||||
'studio.retryFailedToast': '重新排队失败:{detail}',
|
||||
'studio.errNoWorkflow': '请先选择工作流',
|
||||
'studio.errBlocked': '无法提交:{reason}',
|
||||
'studio.queuedToast': '已提交到「{name}」,排队中',
|
||||
'studio.submitFailedToast': '提交失败:{detail}',
|
||||
'studio.jobFailedToast': '生成失败:{detail}',
|
||||
'studio.wfNoParamsNote': '该工作流没有可填写的参数,将按工作流内置的提示词和设置直接生成。如需在这里填写,请在 ComfyUI 中把节点标题加上 (Input:prompt) 等标记后重新导入。',
|
||||
'studio.wfAutoParamsNote': '工作流未使用 (Input:…) 标题标记,已按节点类型自动识别出 {n} 个参数;留空的项沿用工作流原值。',
|
||||
'studio.promptNotDeclared': '该工作流没有提示词节点,无需填写',
|
||||
// --- 图片输入工作流(HEL-633,ref-upload-spec)---
|
||||
'studio.modeImageEdit': '图片编辑',
|
||||
'studio.modeImageToVideo': '图生视频',
|
||||
'studio.modeRefToVideo': '参考生视频',
|
||||
'studio.slotInput': '输入图',
|
||||
'studio.slotHintOne': '必填 · 1 张',
|
||||
'studio.slotEmptyInput': '点击或拖入输入图',
|
||||
'studio.slotEmptyFirst': '点击或拖入首帧图',
|
||||
'studio.slotEmptySub': 'PNG / JPG / WebP · 单张 ≤ 20MB',
|
||||
'studio.slotFromResultsShort': '从结果选',
|
||||
'studio.errImageFormat': '「{name}」格式不支持,请使用 PNG / JPG / WebP;该文件不会参与生成',
|
||||
'studio.errImageSize': '「{name}」超过 20MB,请压缩后再传',
|
||||
'studio.errNeedInputImage': '图片编辑需要上传输入图',
|
||||
'studio.errNeedFirstFrame': '图生视频需要上传首帧图',
|
||||
'studio.errNeedRef': '参考生视频需要至少 1 张参考图',
|
||||
'studio.errRefMax': '参考图最多 {max} 张:已选 {n} 张,本次的 {k} 张未添加',
|
||||
'studio.refHint': '必填 · 1–{max} 张 · 已选 {n}',
|
||||
'studio.refAdd': '添加参考图',
|
||||
'studio.refAddSub': '还可加 {n} 张 · 上限 {max}',
|
||||
'studio.refReplace': '替换',
|
||||
'studio.refRemove': '移除参考图 {n}',
|
||||
'studio.refRejectedShort': '未添加',
|
||||
'studio.refFull': '已达上限 {max} 张,移除后才能再添加',
|
||||
'studio.refStrip': '参考图({n} 张参与生成)',
|
||||
'studio.resolutionLabel': '输出分辨率',
|
||||
'studio.resolutionHint': '约 {v}×{v} 像素,按输入图比例;0 = 保持原尺寸',
|
||||
'studio.wfBrokenTitle': '该工作流文件不完整,ComfyUI 无法执行,已禁止生成',
|
||||
'studio.wfBrokenHint': '请在已安装对应自定义节点的 ComfyUI 中重新导出 API 格式 JSON,再到「ComfyUI 连接」重新导入。',
|
||||
'studio.errWfBroken': '工作流文件不完整,需重新导出后才能生成',
|
||||
'studio.cancelToast': '已发送取消请求',
|
||||
'studio.deleteBtn': '删除',
|
||||
'studio.delTitle': '删除任务记录',
|
||||
'studio.delConfirm': '删除后该任务的结果文件会一并移除,历史不可恢复。确定删除?',
|
||||
'studio.delTitleJob': '删除任务「{name}」',
|
||||
'studio.delConfirmMulti': '该任务共生成 {n} 个结果,删除将移除整条任务记录及其全部 {n} 个结果文件,历史不可恢复。确定删除?',
|
||||
'studio.deletedToast': '已删除该任务及其结果文件',
|
||||
'studio.wfEmptyTitle': '还没有{kind}工作流',
|
||||
'studio.wfEmptyBody': '在 ComfyUI 里启用开发者模式,用「导出(API 格式)」得到 JSON,再到「ComfyUI 连接」页导入并分类为{kind}。',
|
||||
'studio.wfEmptyGo': '去「ComfyUI 连接」导入',
|
||||
@@ -1491,8 +1623,11 @@ export const zh: Dict = {
|
||||
'agentsPage.title': '智能体',
|
||||
'agentsPage.subtitle': '与五个创作智能体独立对话,自定义并分配 Skill',
|
||||
'agentsPage.statusChip': '{n} 个 Skill · {m} 个启用',
|
||||
'agentsPage.agentsTitle': 'Agent',
|
||||
'agentsPage.agentsTitle': '五个 Agent',
|
||||
'agentsPage.skillLib': 'Skill 库',
|
||||
'agentsPage.skillLibCount': '{n} 个',
|
||||
'agentsPage.installedTitle': '{name} · 已装 Skill',
|
||||
'agentsPage.installedCount': '{n} 个 · {m} 启用',
|
||||
'agentsPage.newSkill': '新增 Skill',
|
||||
'agentsPage.skillLibEmpty': '还没有 Skill,点上方「新增 Skill」创建一个。',
|
||||
'agentsPage.skillsCount': '{n} 个 Skill',
|
||||
@@ -1705,4 +1840,88 @@ export const zh: Dict = {
|
||||
'home.errScriptShort': '剧本内容太短,请检查粘贴是否完整(至少 30 字)',
|
||||
'home.stageProgress': '五段进度',
|
||||
'tasks.sourceWorkbench': '创作流程',
|
||||
'jobst.idSuffix': ' · ComfyUI 任务 ID {id}',
|
||||
'jobst.done': '完成',
|
||||
'jobst.doneDetail': 'ComfyUI 已执行完成,结果已保存',
|
||||
'jobst.dryRun': '占位结果',
|
||||
'jobst.dryRunDetail': '占位结果:当前开启了 Dry-run,没有调用 ComfyUI,不是真实生成',
|
||||
'jobst.noOutput': '无产出',
|
||||
'jobst.noOutputDetail': '任务结束但没有任何结果文件,不算生成完成',
|
||||
'jobst.queued': '排队中',
|
||||
'jobst.queuedDetail': '在工作台队列中等待提交到 ComfyUI',
|
||||
'jobst.connecting': '连接中',
|
||||
'jobst.connectingDetail': '正在连接 ComfyUI 并上传素材',
|
||||
'jobst.comfyQueued': '已接收',
|
||||
'jobst.comfyQueuedDetail': 'ComfyUI 已接收,排队等待执行',
|
||||
'jobst.comfyQueuedAhead': 'ComfyUI 已接收,前面还有 {n} 个任务',
|
||||
'jobst.comfyRunning': '执行中',
|
||||
'jobst.comfyRunningDetail': 'ComfyUI 正在执行',
|
||||
'jobst.saving': '保存中',
|
||||
'jobst.savingDetail': 'ComfyUI 已出结果,正在下载保存',
|
||||
'jobst.failed': '失败',
|
||||
'jobst.cancelled': '已取消',
|
||||
'studio.ratioLabel': '画面比例',
|
||||
'studio.megapixelsLabel': '总像素(百万)',
|
||||
'studio.outputSize': '输出 {w} × {h}',
|
||||
'studio.denoiseLabel': '降噪',
|
||||
'studio.errNumber': '「{label}」必须是数字',
|
||||
'studio.errInteger': '「{label}」必须是整数',
|
||||
'studio.errMin': '「{label}」不能小于 {v}',
|
||||
'studio.errMax': '「{label}」不能大于 {v}',
|
||||
'studio.errOption': '「{label}」的取值不在工作流支持的选项中',
|
||||
'studio.reuseParams': '复用参数',
|
||||
'studio.reusedToast': '已回填该任务的参数,可修改后重新生成',
|
||||
'studio.reuseMissingWf': '该任务使用的工作流已不存在,无法回填',
|
||||
'studio.doneToast': '生成完成:{n} 个文件(ComfyUI 任务 ID {id})',
|
||||
'studio.dryRunToast': '占位完成:当前开启了 Dry-run,没有调用 ComfyUI,结果不是真实生成',
|
||||
'studio.dryRunNote': '已开启 Dry-run:点生成只会写占位文件,不会调用 ComfyUI。可在「设置」中关闭。',
|
||||
// --- HEL-643: workflow import check / real test / media groups ---
|
||||
'studio.errVideoFormat': '「{name}」不是支持的视频格式,请使用 MP4 / WebM / MOV',
|
||||
'studio.errAudioFormat': '「{name}」不是支持的音频格式,请使用 MP3 / WAV / M4A',
|
||||
'studio.errMediaSize': '「{name}」超过大小限制,请压缩后再传',
|
||||
'studio.errNeedRefMedia': '「{label}」需要至少 {n} 段',
|
||||
'studio.refHintMedia': '必填 · 1–{max} 段 · 已选 {n}',
|
||||
'studio.refAddMedia': '添加{kind}',
|
||||
'studio.refAddSubMedia': '还可加 {n} 段 · 上限 {max}',
|
||||
'studio.refRemoveMedia': '移除{kind} {n}',
|
||||
'studio.refFullMedia': '已达上限 {max} 段,移除后才能再添加',
|
||||
'wfMedia.h3Limits': 'MiniMax H3 Ref2VA 官方限额:参考图最多 9 张、参考视频 3 段、参考音频 3 段;单段音视频 2–15 秒,视频与音频各自总时长不超过 15 秒;音频不能单独作为唯一参考;参考文件总数最多 12。',
|
||||
'wfMedia.h3TotalOver': '参考文件总数已达 {n} 个,超过官方上限 12,生成可能失败',
|
||||
'wfMedia.h3AudioAlone': '音频不能单独作为唯一参考,请至少搭配一张参考图或一段参考视频',
|
||||
'conn.wfCheckTitle': '连接兼容性检查',
|
||||
'conn.wfCheckEndpoint': '目标:{name}',
|
||||
'conn.wfCheckBtn': '重新检查',
|
||||
'conn.wfChecking': '检查中…',
|
||||
'conn.wfCheckOk': '该连接具备运行此工作流的节点与模型',
|
||||
'conn.wfCheckProblems': '发现 {n} 个缺失的节点/模型',
|
||||
'conn.wfCheckUnreachable': '无法核查(连不上该 ComfyUI),不阻断保存;修好连接后可重新检查',
|
||||
'conn.wfCheckSaveHint': '仍可保存草稿(不会误称可生成);装好缺失项后回来重新检查即可。',
|
||||
'conn.wfTestBtn': '测试',
|
||||
'conn.wfLastTest': '上次测试',
|
||||
'conn.wfTestPassed': '{time} 通过({n} 个文件)',
|
||||
'conn.wfTestRunning': '{time} 进行中',
|
||||
'conn.wfTestFailed': '{time} 未通过',
|
||||
'wft.title': '测试工作流',
|
||||
'wft.connection': '目标连接',
|
||||
'wft.costNote': '测试会把一次真实任务加入所选 ComfyUI 的队列,占用其显存与排队时间。',
|
||||
'wft.submit': '开始测试',
|
||||
'wft.confirmTitle': '确认真实运行',
|
||||
'wft.confirmBody': '将向「{endpoint}」提交一次真实任务并占用其资源(当前队列 {n} 个任务)。只有拿到可下载且类型正确的输出文件才算测试通过。',
|
||||
'wft.running': '测试中…',
|
||||
'wft.cancel': '取消测试',
|
||||
'wft.passed': '测试通过:产出 {n} 个文件',
|
||||
'wft.passedShort': '通过({n} 个文件)',
|
||||
'wft.failed': '测试未通过',
|
||||
'wft.failedShort': '未通过',
|
||||
'wft.cancelledShort': '已取消',
|
||||
'wft.reusedToast': '该工作流已有进行中的测试,已连接到该任务',
|
||||
'wft.history': '测试记录({n} 条)',
|
||||
'wft.clear': '清除测试记录',
|
||||
'wft.cleared': '已清除测试记录',
|
||||
'wft.never': '尚未测试过',
|
||||
'wft.download': '下载',
|
||||
'wft.errRequired': '请填写「{label}」',
|
||||
'wft.errListMin': '「{label}」需要至少 {n} {unit}',
|
||||
'tasks.testBadge': '测试',
|
||||
'tasks.sourceWorkflowTest': '工作流测试',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Job } from "$lib/api";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
/** Where a generation job came from: 独立生图 / 独立生视频 / 创作流程 / Playground / 测试. */
|
||||
export function jobSourceLabel(job: Job): string {
|
||||
const src = job.payload?.source;
|
||||
if (src === "studio-image") return t("tasks.sourceStudioImage");
|
||||
if (src === "studio-video") return t("tasks.sourceStudioVideo");
|
||||
if (src === "workbench") return t("tasks.sourceWorkbench");
|
||||
if (src === "playground") return t("tasks.sourcePlayground");
|
||||
if (src === "workflow_test" || job.origin === "workflow_test")
|
||||
return t("tasks.sourceWorkflowTest");
|
||||
if (job.scene_id || job.clip_id) return t("tasks.sourceFlow");
|
||||
if (!src) return t("tasks.sourceFlow");
|
||||
return t("tasks.sourceOther");
|
||||
}
|
||||
|
||||
/** A real run launched from the workflow library's 测试 button (HEL-643). */
|
||||
export function isWorkflowTestJob(job: Job): boolean {
|
||||
return (
|
||||
job.origin === "workflow_test" || job.payload?.source === "workflow_test"
|
||||
);
|
||||
}
|
||||
|
||||
/** Jobs started outside any project (standalone windows / Playground / tests). */
|
||||
export function isStandaloneJob(job: Job): boolean {
|
||||
const src = job.payload?.source;
|
||||
return (
|
||||
src === "studio-image" ||
|
||||
src === "studio-video" ||
|
||||
src === "playground" ||
|
||||
isWorkflowTestJob(job)
|
||||
);
|
||||
}
|
||||
@@ -8,14 +8,14 @@ import { t } from './i18n.svelte';
|
||||
* Backend reality (see calliope-backend/src/calliope/queue/worker.py):
|
||||
* - `job.created` { job_id, kind, ... }
|
||||
* - `job.started` { job_id, kind, message } ← message = job label
|
||||
* - `job.progress` { prompt_id, message } ← NO job_id, NO percent
|
||||
* - `job.progress` { job_id, prompt_id, phase, queue_ahead, message } ← NO percent
|
||||
* - `job.completed` { job_id, outputs, message }
|
||||
* - `job.failed` { job_id, error, message }
|
||||
* - `job.deleted` { job_id }
|
||||
*
|
||||
* Because progress ticks carry neither a job id nor a percent, the store:
|
||||
* 1. Attributes a tick to the currently running job (tracked from job.started;
|
||||
* explicit `job_id` wins if a future backend sends one).
|
||||
* Because progress ticks carry no percent (and ticks without a job id exist), the store:
|
||||
* 1. Attributes a tick to its `job_id`, else to the currently running job
|
||||
* (tracked from job.started).
|
||||
* 2. Honors a numeric payload field (`progress` / `percent` / `value`, 0–1 or
|
||||
* 0–100) when present, otherwise synthesizes an asymptotic value that
|
||||
* approaches TICK_CAP but never reaches it — only job.completed sets 100.
|
||||
@@ -32,6 +32,10 @@ export type JobProgressEntry = {
|
||||
progress: number;
|
||||
/** Latest human-readable status line from the worker. */
|
||||
message?: string;
|
||||
/** Worker phase from job.progress (comfy_queued / comfy_running …). */
|
||||
phase?: string;
|
||||
/** Prompts ahead of this one in ComfyUI's own queue (comfy_queued only). */
|
||||
queueAhead?: number | null;
|
||||
/** True once the job reached a terminal state (done/failed). */
|
||||
final: boolean;
|
||||
/** Terminal status when final: 'done' | 'failed'. */
|
||||
@@ -115,6 +119,8 @@ class JobProgressStore {
|
||||
this.map.set(id, {
|
||||
progress,
|
||||
message: str(d.message) || prev?.message,
|
||||
phase: str(d.phase) || prev?.phase,
|
||||
queueAhead: d.queue_ahead === undefined ? prev?.queueAhead : num(d.queue_ahead),
|
||||
final: false,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
@@ -151,9 +157,8 @@ class JobProgressStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's job.progress payload is {prompt_id, message} with no job id.
|
||||
* Attribute the tick to the only running job, else the most recently
|
||||
* started one. Explicit job_id wins when a backend sends it.
|
||||
* Explicit job_id wins; a tick without one goes to the only running job,
|
||||
* else the most recently started one.
|
||||
*/
|
||||
private resolveJobId(d: Record<string, unknown>): number | null {
|
||||
const explicit = num(d.job_id);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Job } from './comfy/types';
|
||||
import { progressFor } from './jobProgress';
|
||||
import { t } from './i18n.svelte';
|
||||
|
||||
export type JobStatusTone = 'ok' | 'run' | 'queue' | 'fail';
|
||||
|
||||
export interface JobStatusInfo {
|
||||
tone: JobStatusTone;
|
||||
/** Short label for status columns. */
|
||||
label: string;
|
||||
/** Full sentence for the task card / tooltip. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a generation job really is — "done" only when ComfyUI returned files.
|
||||
* A dry-run placeholder or a done row without outputs never reads as done.
|
||||
*/
|
||||
export function jobStatusInfo(job: Job): JobStatusInfo {
|
||||
const live = progressFor(job.id);
|
||||
const phase = live?.phase ?? job.phase ?? null;
|
||||
const idSuffix = job.comfy_prompt_id ? t('jobst.idSuffix', { id: job.comfy_prompt_id }) : '';
|
||||
switch (job.status) {
|
||||
case 'done':
|
||||
if (phase === 'dry_run')
|
||||
return { tone: 'queue', label: t('jobst.dryRun'), detail: t('jobst.dryRunDetail') };
|
||||
if (!(job.output_paths?.length ?? 0))
|
||||
return { tone: 'fail', label: t('jobst.noOutput'), detail: t('jobst.noOutputDetail') };
|
||||
return { tone: 'ok', label: t('jobst.done'), detail: t('jobst.doneDetail') + idSuffix };
|
||||
case 'pending':
|
||||
return { tone: 'queue', label: t('jobst.queued'), detail: t('jobst.queuedDetail') };
|
||||
case 'running':
|
||||
if (phase === 'comfy_queued') {
|
||||
const ahead = live?.queueAhead;
|
||||
const detail =
|
||||
ahead != null && ahead > 0
|
||||
? t('jobst.comfyQueuedAhead', { n: ahead })
|
||||
: t('jobst.comfyQueuedDetail');
|
||||
return { tone: 'queue', label: t('jobst.comfyQueued'), detail: detail + idSuffix };
|
||||
}
|
||||
if (phase === 'comfy_running')
|
||||
return { tone: 'run', label: t('jobst.comfyRunning'), detail: t('jobst.comfyRunningDetail') + idSuffix };
|
||||
if (phase === 'saving')
|
||||
return { tone: 'run', label: t('jobst.saving'), detail: t('jobst.savingDetail') + idSuffix };
|
||||
return { tone: 'run', label: t('jobst.connecting'), detail: t('jobst.connectingDetail') };
|
||||
default:
|
||||
return {
|
||||
tone: 'fail',
|
||||
label: job.error === 'cancelled' ? t('jobst.cancelled') : t('jobst.failed'),
|
||||
detail: job.error ?? t('jobst.failed'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* ≥1440px is the full-width breakpoint of the spec. There the add form
|
||||
* of 模型设置 / ComfyUI 连接 stays open in the right column (a6 / a7);
|
||||
* below it the form is a drawer that only opens on demand.
|
||||
*/
|
||||
export function isWideLayout(): boolean {
|
||||
return typeof window !== 'undefined' && window.matchMedia('(min-width: 1440px)').matches;
|
||||
}
|
||||
@@ -5,14 +5,19 @@
|
||||
// the sample progress rendered in the mockup.
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { jobsApi, projects, workbenchApi, type Project } from '$lib/api';
|
||||
import { jobsApi, projects, workbenchApi, workflows, type Job, type Project } from '$lib/api';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import AProjectCard from '$lib/components/home/AProjectCard.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ProgressBar from '$lib/components/ui/ProgressBar.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { classifyEndpoint, endpointTypeLabel } from '$lib/endpointKind';
|
||||
import { isStandaloneJob, jobSourceLabel } from '$lib/jobLabels';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
@@ -27,8 +32,20 @@
|
||||
refetchInterval: 8000,
|
||||
});
|
||||
|
||||
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.list });
|
||||
|
||||
const allProjects = $derived($projectsQuery.data ?? []);
|
||||
const inProgressCount = $derived(allProjects.filter((p) => p.status === 'in_progress').length);
|
||||
// 进行中 = started the five-stage flow but not all five confirmed yet.
|
||||
const inProgressCount = $derived(
|
||||
allProjects.filter((p) => {
|
||||
const st = p.stats;
|
||||
if (!st || st.stage_confirmed >= 5) return false;
|
||||
return (
|
||||
st.stage_confirmed > 0 ||
|
||||
Object.values(st.stage_status ?? {}).some((v) => v === 'running' || v === 'awaiting_confirm')
|
||||
);
|
||||
}).length,
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Entry flows — both open the five-stage creation workbench (a2):
|
||||
@@ -195,11 +212,46 @@
|
||||
});
|
||||
|
||||
const KIND_KEY: Record<string, string> = {
|
||||
image: 'home.kindImage',
|
||||
video: 'home.kindVideo',
|
||||
image: 'shell.navImage',
|
||||
video: 'shell.navVideo',
|
||||
export: 'home.kindExport',
|
||||
};
|
||||
|
||||
// a1 task row: 「本机 · 生图」 tag + the project (or workflow) name.
|
||||
function jobTag(job: Job): { cls: string; label: string } {
|
||||
const kind = KIND_KEY[job.kind] ? t(KIND_KEY[job.kind]) : job.kind;
|
||||
const url = (job.payload?.endpoint as { base_url?: string } | undefined)?.base_url;
|
||||
if (!url) return { cls: 'tag-local', label: kind };
|
||||
const where = classifyEndpoint(url);
|
||||
return {
|
||||
cls: where === 'cloud' ? 'tag-cloud' : 'tag-local',
|
||||
label: `${endpointTypeLabel(where)} · ${kind}`,
|
||||
};
|
||||
}
|
||||
|
||||
function workflowName(job: Job): string {
|
||||
return ($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)?.name ?? '';
|
||||
}
|
||||
|
||||
function jobTitle(job: Job): string {
|
||||
const project = isStandaloneJob(job) ? undefined : projectTitles.get(job.project_id);
|
||||
return project ? `《${project}》` : workflowName(job) || jobSourceLabel(job);
|
||||
}
|
||||
|
||||
function jobMeta(job: Job): string {
|
||||
const wf = workflowName(job);
|
||||
const parts = [wf === jobTitle(job) ? '' : wf, jobSourceLabel(job), formatJobTime(job.created_at)];
|
||||
if (job.status === 'failed' && job.error && job.error !== 'cancelled') parts.push(job.error);
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function jobBar(job: Job): { value: number; tone: 'run' | 'success' | 'error' } {
|
||||
if (job.status === 'done') return { value: 100, tone: 'success' };
|
||||
if (job.status === 'failed') return { value: 100, tone: 'error' };
|
||||
if (job.status === 'running') return { value: progressFor(job.id)?.progress ?? 3, tone: 'run' };
|
||||
return { value: 0, tone: 'run' };
|
||||
}
|
||||
|
||||
function jobStatusLabel(status: string): string {
|
||||
if (status === 'done') return t('home.jobDone');
|
||||
if (status === 'running') return t('home.jobRunning');
|
||||
@@ -208,10 +260,10 @@
|
||||
return status;
|
||||
}
|
||||
|
||||
function formatJobTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
function formatJobTime(raw: string): string {
|
||||
const ts = parseDbTs(raw);
|
||||
if (!Number.isFinite(ts)) return '';
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// English values are the backend contract; display labels come from the
|
||||
@@ -441,7 +493,7 @@
|
||||
<aside class="tasks">
|
||||
<div class="tasks-head">
|
||||
<h2>{t('home.tasksTitle')}</h2>
|
||||
<span class="chip" class:amber={activeJobCount > 0}>
|
||||
<span class="chip" class:run={activeJobCount > 0}>
|
||||
{t('home.activeJobs', { n: activeJobCount })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -457,45 +509,38 @@
|
||||
{:else}
|
||||
<ul class="job-list">
|
||||
{#each recentJobs as job (job.id)}
|
||||
{@const kindKey = KIND_KEY[job.kind] ?? null}
|
||||
{@const tag = jobTag(job)}
|
||||
{@const bar = jobBar(job)}
|
||||
<li class="job-row" class:failed={job.status === 'failed'}>
|
||||
<span class="job-main">
|
||||
{#if kindKey}<span class="tag">{t(kindKey)}</span>{/if}
|
||||
<span
|
||||
class="job-name"
|
||||
title={projectTitles.get(job.project_id) ?? `#${job.project_id}`}
|
||||
>
|
||||
{projectTitles.get(job.project_id) ??
|
||||
t('home.jobProject', { id: job.project_id })}
|
||||
<div class="job-top">
|
||||
<span class="tag {tag.cls}">{tag.label}</span>
|
||||
<span class="job-name" title={jobTitle(job)}>{jobTitle(job)}</span>
|
||||
<span class="job-side">
|
||||
{#if job.status === 'failed' && job.error !== 'cancelled'}
|
||||
<span class="job-status mono failed-text">{jobStatusLabel(job.status)}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="retry-link"
|
||||
onclick={() => $retryMutation.mutate(job.id)}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="job-status mono {job.status === 'done'
|
||||
? 'done-text'
|
||||
: job.status === 'running'
|
||||
? 'running-text'
|
||||
: 'queued-text'}"
|
||||
>
|
||||
{#if job.status === 'running'}<span class="dot breathe" aria-hidden="true"
|
||||
></span>{Math.round(bar.value)}%{:else}{jobStatusLabel(job.status)}{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
<span class="job-side">
|
||||
{#if job.status === 'failed' && job.error && job.error !== 'cancelled'}
|
||||
<span class="job-status mono failed-text" title={job.error}
|
||||
>{jobStatusLabel(job.status)}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="retry-link"
|
||||
onclick={() => $retryMutation.mutate(job.id)}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="job-status mono {job.status === 'done'
|
||||
? 'done-text'
|
||||
: job.status === 'running'
|
||||
? 'running-text'
|
||||
: 'queued-text'}"
|
||||
>
|
||||
{#if job.status === 'running'}<span class="dot breathe" aria-hidden="true"
|
||||
></span>{/if}
|
||||
{jobStatusLabel(job.status)}
|
||||
</span>
|
||||
<span class="job-time mono">{formatJobTime(job.created_at)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar bare size="sm" value={bar.value} tone={bar.tone} />
|
||||
<p class="job-meta mono" title={jobMeta(job)}>{jobMeta(job)}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -535,9 +580,9 @@
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip.amber {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
.chip.run {
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
}
|
||||
|
||||
.entry-btn {
|
||||
@@ -571,7 +616,7 @@
|
||||
color: var(--text);
|
||||
}
|
||||
.entry-btn.secondary:hover {
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.entry-btn:active {
|
||||
transform: scale(0.98);
|
||||
@@ -606,7 +651,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.entry-card h2 {
|
||||
margin: 4px 0 0;
|
||||
@@ -691,10 +736,10 @@
|
||||
}
|
||||
.f-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.f-input.invalid {
|
||||
border-color: rgba(229, 72, 77, 0.65);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.f-hint {
|
||||
display: block;
|
||||
@@ -724,31 +769,6 @@
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* restyle reused ui/Button inside the darkroom scope (§5) */
|
||||
.home :global(.btn-primary) {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
.home :global(.btn-primary:hover:not(:disabled)) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.home :global(.btn-secondary) {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
.home :global(.btn-secondary:hover:not(:disabled)) {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.home :global(.btn-ghost) {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.home :global(.btn-ghost:hover:not(:disabled)) {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---------------- project cards ---------------- */
|
||||
.projects-head {
|
||||
display: flex;
|
||||
@@ -866,18 +886,16 @@
|
||||
}
|
||||
.job-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 0;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 44px;
|
||||
}
|
||||
.job-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.job-main {
|
||||
display: inline-flex;
|
||||
.job-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
@@ -885,14 +903,21 @@
|
||||
.tag {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--info-dim);
|
||||
color: var(--info);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-local,
|
||||
.tag-cloud {
|
||||
background: var(--bg-4);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.job-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -904,6 +929,14 @@
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.job-meta {
|
||||
margin: 0;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.job-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -914,7 +947,7 @@
|
||||
color: var(--success);
|
||||
}
|
||||
.running-text {
|
||||
color: var(--accent);
|
||||
color: var(--run);
|
||||
}
|
||||
.queued-text {
|
||||
color: var(--text-3);
|
||||
@@ -930,8 +963,8 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
background: var(--run);
|
||||
box-shadow: 0 0 10px var(--run-soft);
|
||||
}
|
||||
.dot.breathe {
|
||||
animation: breathe 2s ease-in-out infinite;
|
||||
@@ -945,11 +978,7 @@
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
.job-time {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.retry-link {
|
||||
.retry-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
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 Switch from '$lib/components/ui/Switch.svelte';
|
||||
import AgentChat from '$lib/components/agent/AgentChat.svelte';
|
||||
import {
|
||||
agentApi,
|
||||
@@ -17,7 +18,6 @@
|
||||
type NamedAgent,
|
||||
type SkillAdminEntry,
|
||||
type SkillVersion,
|
||||
type Settings,
|
||||
canvasApi,
|
||||
} from '$lib/api';
|
||||
import { AGENT_TASK_PROMPTS, AGENT_TASK_TITLE_KEYS, isAgentTaskKind } from '$lib/agentTasks';
|
||||
@@ -377,10 +377,10 @@
|
||||
},
|
||||
});
|
||||
|
||||
function setAgentModel(s: Settings, role: string, value: string) {
|
||||
const merged: Record<string, string | null> = { ...(s.agent_llm_assignments ?? {}) };
|
||||
merged[role] = value === '' ? null : value;
|
||||
$assignMutation.mutate(merged);
|
||||
function setAgentModel(role: string, value: string) {
|
||||
// Single-role delta only (HEL-619): the whole-snapshot replay races
|
||||
// concurrent switches; the server merges per role.
|
||||
$assignMutation.mutate({ [role]: value === '' ? null : value });
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -544,6 +544,14 @@
|
||||
: t('agentsPage.skillTypeTemplate');
|
||||
}
|
||||
|
||||
// Built-in skills ship with English slugs as their directory; show a
|
||||
// localized name when one exists, otherwise the user's own name.
|
||||
function skillLabel(dir: string): string {
|
||||
const key = `skillName.${dir}`;
|
||||
const label = t(key);
|
||||
return label === key ? dir : label;
|
||||
}
|
||||
|
||||
function formatUpdatedAt(ts: number | null): string {
|
||||
if (!ts) return '';
|
||||
return new Date(ts * 1000).toLocaleString([], {
|
||||
@@ -570,6 +578,8 @@
|
||||
});
|
||||
}
|
||||
|
||||
const installedSkills = $derived(skills.filter((s) => s.bindings?.[activeKey]));
|
||||
|
||||
const editingVersion = $derived(
|
||||
editorMode === 'edit' && editingDir
|
||||
? (skills.find((s) => s.dir === editingDir)?.version ?? '')
|
||||
@@ -581,6 +591,40 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet skillRow(skill: SkillAdminEntry, detail: string)}
|
||||
<div class="skill-row" class:disabled={!skill.is_enabled}>
|
||||
<div class="skill-row-main">
|
||||
<button
|
||||
type="button"
|
||||
class="skill-name"
|
||||
title={`${skill.dir} · ${skill.description}`}
|
||||
onclick={() => startEditSkill(skill)}
|
||||
>
|
||||
{skillLabel(skill.dir)}
|
||||
</button>
|
||||
<span class="skill-tags">
|
||||
<span class="skill-type">{typeLabel(skill.type)}</span>
|
||||
<span class="skill-assign">{detail}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="skill-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('agentsPage.edit')}
|
||||
onclick={() => startEditSkill(skill)}
|
||||
>
|
||||
<Icon name="edit" size={14} />
|
||||
</button>
|
||||
<Switch
|
||||
checked={skill.is_enabled}
|
||||
label={t('agentsPage.toggleSkill')}
|
||||
onchange={(next) => $toggleSkillMutation.mutate({ dir: skill.dir, next })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if legacyRedirect === 'pending'}
|
||||
<div class="redirecting">{t('agents.opening')}</div>
|
||||
{:else if legacyRedirect === 'failed'}
|
||||
@@ -629,6 +673,7 @@
|
||||
|
||||
<div class="lib-head">
|
||||
<p class="col-title">{t('agentsPage.skillLib')}</p>
|
||||
<span class="col-hint">{t('agentsPage.skillLibCount', { n: skills.length })}</span>
|
||||
<button type="button" class="a-btn primary sm" onclick={startAddSkill}>
|
||||
<Icon name="plus" size={13} />
|
||||
{t('agentsPage.newSkill')}
|
||||
@@ -639,44 +684,7 @@
|
||||
<p class="lib-empty">{t('agentsPage.skillLibEmpty')}</p>
|
||||
{/if}
|
||||
{#each skills as skill (skill.dir)}
|
||||
<div class="skill-row" class:disabled={!skill.is_enabled}>
|
||||
<div class="skill-row-main">
|
||||
<button
|
||||
type="button"
|
||||
class="skill-name"
|
||||
title={skill.description}
|
||||
onclick={() => startEditSkill(skill)}
|
||||
>
|
||||
{skill.dir}
|
||||
</button>
|
||||
<span class="skill-tags">
|
||||
<span class="skill-type">{typeLabel(skill.type)}</span>
|
||||
<span class="skill-assign">{t('agentsPage.assigned', { n: skill.assigned_count })}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="skill-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('agentsPage.edit')}
|
||||
onclick={() => startEditSkill(skill)}
|
||||
>
|
||||
<Icon name="edit" size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="switch"
|
||||
class:on={skill.is_enabled}
|
||||
role="switch"
|
||||
aria-checked={skill.is_enabled}
|
||||
aria-label={t('agentsPage.toggleSkill')}
|
||||
onclick={() =>
|
||||
$toggleSkillMutation.mutate({ dir: skill.dir, next: !skill.is_enabled })}
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{@render skillRow(skill, t('agentsPage.assigned', { n: skill.assigned_count }))}
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -709,7 +717,7 @@
|
||||
<select
|
||||
class="a-select"
|
||||
value={s.agent_llm_assignments?.[activeAgent.llm_role] ?? ''}
|
||||
onchange={(e) => setAgentModel(s, activeAgent.llm_role, e.currentTarget.value)}
|
||||
onchange={(e) => setAgentModel(activeAgent.llm_role, e.currentTarget.value)}
|
||||
>
|
||||
<option value="">{t('agentsPage.modelDefault')}</option>
|
||||
{#each s.llm_profiles as p (p.id)}
|
||||
@@ -801,7 +809,15 @@
|
||||
{/if}
|
||||
<aside class="right" class:open={editorMode !== 'idle' || drawerOpen} aria-label={t('agentsPage.skillManage')}>
|
||||
<div class="right-head">
|
||||
<p class="col-title">{t('agentsPage.skillManage')}</p>
|
||||
<p class="col-title">
|
||||
{activeAgent ? t('agentsPage.installedTitle', { name: activeAgent.name }) : t('agentsPage.skillManage')}
|
||||
</p>
|
||||
<span class="col-hint">
|
||||
{t('agentsPage.installedCount', {
|
||||
n: installedSkills.length,
|
||||
m: installedSkills.filter((s) => s.is_enabled).length,
|
||||
})}
|
||||
</span>
|
||||
<div class="right-head-actions">
|
||||
{#if editorMode === 'edit' && editingDir}
|
||||
<button type="button" class="link-btn" onclick={() => loadVersions(editingDir!)}>
|
||||
@@ -809,14 +825,16 @@
|
||||
{t('agentsPage.versions')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('agentsPage.close')}
|
||||
onclick={resetEditor}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
{#if editorMode !== 'idle' || drawerOpen}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('agentsPage.close')}
|
||||
onclick={resetEditor}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -846,6 +864,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if installedSkills.length}
|
||||
<div class="skill-lib installed">
|
||||
{#each installedSkills as skill (skill.dir)}
|
||||
{@render skillRow(
|
||||
skill,
|
||||
[`v${skill.version}`, formatUpdatedAt(skill.updated_at)].filter(Boolean).join(' · '),
|
||||
)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if editorMode === 'idle'}
|
||||
<div class="form-idle">
|
||||
<Icon name="sparkle" size={20} />
|
||||
@@ -1038,14 +1066,6 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-2);
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cols {
|
||||
display: flex;
|
||||
@@ -1067,13 +1087,16 @@
|
||||
padding-right: 2px;
|
||||
}
|
||||
.col-title {
|
||||
font-size: 11.5px;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
.col-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1110,11 +1133,11 @@
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #edeef0;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar.dark {
|
||||
color: #16130a;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.avatar.lg {
|
||||
width: 42px;
|
||||
@@ -1145,10 +1168,12 @@
|
||||
.lib-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.lib-head .a-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
.skill-lib {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1220,37 +1245,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Toggle switch (spec §5: 34×19 capsule; on = amber track, dark knob) */
|
||||
.switch {
|
||||
width: 34px;
|
||||
height: 19px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background-color 150ms, border-color 150ms;
|
||||
}
|
||||
.switch .knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 999px;
|
||||
background: var(--text-3);
|
||||
transition: transform 150ms cubic-bezier(0.16, 1, 0.3, 1), background-color 150ms;
|
||||
}
|
||||
.switch.on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.switch.on .knob {
|
||||
transform: translateX(15px);
|
||||
background: var(--accent-contrast);
|
||||
}
|
||||
|
||||
/* ── 中栏 ─────────────────────────────────────────────── */
|
||||
.mid {
|
||||
flex: 1;
|
||||
@@ -1300,8 +1294,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-tag.busy {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
}
|
||||
.chat-head-actions {
|
||||
display: flex;
|
||||
@@ -1382,12 +1376,13 @@
|
||||
.right-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.right-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.link-btn {
|
||||
display: inline-flex;
|
||||
@@ -1583,58 +1578,6 @@
|
||||
.editor-actions .link-danger {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── 通用 ─────────────────────────────────────────────── */
|
||||
.a-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
transform 100ms;
|
||||
}
|
||||
.a-btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
.a-btn.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.a-btn.secondary {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
.a-btn.secondary:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.a-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.a-btn:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.a-btn.sm {
|
||||
min-height: 30px;
|
||||
padding: 0 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.a-btn.xs {
|
||||
min-height: 24px;
|
||||
padding: 0 9px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1675,7 +1618,7 @@
|
||||
background: var(--success);
|
||||
}
|
||||
.dot.amber {
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
}
|
||||
.dot.amber.breathing {
|
||||
animation: breathe 2s ease-in-out infinite;
|
||||
@@ -1691,22 +1634,10 @@
|
||||
box-shadow: 0 0 0 4px var(--accent-dim);
|
||||
}
|
||||
}
|
||||
|
||||
.a-select {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
padding: 5px 8px;
|
||||
.chat-head-actions .a-select {
|
||||
max-width: 220px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
.a-select:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.manage-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+535
-126
@@ -3,167 +3,567 @@
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg-primary: #0a0a0c;
|
||||
--bg-surface: #141418;
|
||||
--bg-elevated: #1e1e24;
|
||||
--border: #2a2a32;
|
||||
--text-primary: #f4f4f5;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-muted: #71717a;
|
||||
--accent: #8b5cf6;
|
||||
--accent-hover: #7c3aed;
|
||||
--accent-glow: rgba(139, 92, 246, 0.25);
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--info: #3b82f6;
|
||||
/* ------------------------------------------------------------------
|
||||
* 融合视觉 design tokens — HEL-649 整站施工规范 §一(流水台结构 ×
|
||||
* 放映厅配色)。Single source of truth for the whole app: pages and
|
||||
* components reference these variables only — no hardcoded colors,
|
||||
* no ad-hoc overrides. Gold is for key actions and the current
|
||||
* selection only; status colors each carry exactly one meaning.
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/* Omni Composer pill tokens */
|
||||
--pill-bg: var(--bg-elevated);
|
||||
--pill-border: var(--border);
|
||||
--pill-radius: 9999px;
|
||||
--pill-height: 36px;
|
||||
--pill-padding: 0 14px;
|
||||
--pill-font-size: 13px;
|
||||
/* 底色:蓝黑四阶,逐层浮起 (§1.1) */
|
||||
--bg: #07090e;
|
||||
--bg-2: #0c0f16;
|
||||
--bg-3: #121724;
|
||||
--bg-4: #1a2133;
|
||||
--line: #1b2233;
|
||||
--line-2: #2a3450;
|
||||
--page-glow: radial-gradient(1200px 500px at 70% -10%, rgba(38, 52, 92, 0.25), transparent 60%);
|
||||
--float-bg: linear-gradient(180deg, #10152a, var(--bg-2));
|
||||
--overlay: rgba(4, 6, 10, 0.6);
|
||||
--media-scrim: rgba(7, 9, 14, 0.65);
|
||||
--on-media: #fff;
|
||||
--on-media-line: rgba(255, 255, 255, 0.05);
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
/* 放映金:主按钮、当前选中、改过的参数、聚焦框、Toast 动作 (§1.2) */
|
||||
--accent: #f2c784;
|
||||
--accent-ink: #23170a;
|
||||
--accent-soft: rgba(242, 199, 132, 0.13);
|
||||
--accent-hi: #f7d9a2;
|
||||
--accent-lo: #ecaf63;
|
||||
--accent-glow: rgba(236, 175, 99, 0.28);
|
||||
--accent-line: rgba(242, 199, 132, 0.55);
|
||||
--accent-grad: linear-gradient(180deg, var(--accent-hi), var(--accent-lo));
|
||||
--accent-grad-hover: linear-gradient(180deg, #fbe3b6, #f0b968);
|
||||
--accent-ring: 0 0 0 1px rgba(242, 199, 132, 0.7), 0 0 22px var(--accent-glow);
|
||||
--btn-glow: 0 2px 14px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
--btn-glow-hover: 0 4px 18px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
/* 信号色:绿=完成/已确认/通过,青=进行中/在线,红=失败/危险,
|
||||
琥珀=待确认/文件缺失/限额 (§1.3) */
|
||||
--ok: #49cb90;
|
||||
--ok-soft: rgba(73, 203, 144, 0.13);
|
||||
--run: #54dfd2;
|
||||
--run-soft: rgba(84, 223, 210, 0.13);
|
||||
--run-grad: linear-gradient(90deg, #3bbfb4, var(--run));
|
||||
--err: #f0645a;
|
||||
--err-soft: rgba(240, 100, 90, 0.13);
|
||||
--err-line: rgba(240, 100, 90, 0.6);
|
||||
--warn: #f0b45c;
|
||||
--warn-soft: rgba(240, 180, 92, 0.13);
|
||||
|
||||
--font-display: 'Outfit', system-ui, sans-serif;
|
||||
--font-body: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
/* 文字与字体 (§1.4);--text-2 是深底可读性下限,不得再暗 */
|
||||
--text: #f4f6fa;
|
||||
--text-2: #aab2c5;
|
||||
--text-3: #6d7690;
|
||||
--font: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
--mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
--fs-title: 15px;
|
||||
--fs-section: 14px;
|
||||
--fs-body: 13px;
|
||||
--fs-body-sm: 12.5px;
|
||||
--fs-aux: 12px;
|
||||
--fs-mini: 11px;
|
||||
--lh-body: 1.55;
|
||||
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
/* 间距阶梯 4/6/8/10/12/16/20/28,不得出现阶梯外数值 (§1.5) */
|
||||
--sp-1: 4px;
|
||||
--sp-2: 6px;
|
||||
--sp-3: 8px;
|
||||
--sp-4: 10px;
|
||||
--sp-5: 12px;
|
||||
--sp-6: 16px;
|
||||
--sp-7: 20px;
|
||||
--sp-8: 28px;
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* A「片场暗房」scoped design tokens — spec-v2 §2 (HEL-603 confirmed).
|
||||
* Adopted incrementally by new A-style pages via the `.a-darkroom`
|
||||
* wrapper; graduates to :root when the full A shell (216px nav) lands.
|
||||
* Values are the single source of truth for A pages — do not hardcode
|
||||
* colors inside pages, and do not override these with ad-hoc CSS.
|
||||
* ------------------------------------------------------------------ */
|
||||
.a-darkroom {
|
||||
--bg: #0d0e11;
|
||||
--surface: #14161b;
|
||||
--surface-2: #1b1e25;
|
||||
--surface-3: #22262f;
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--text: #edeef0;
|
||||
--text-2: #9ba1ab;
|
||||
--text-3: #5c626c;
|
||||
--accent: #e8a33d;
|
||||
--accent-hover: #f1b155; /* +8% brightness (§5 主按钮) */
|
||||
--accent-contrast: #16130a;
|
||||
--accent-dim: rgba(232, 163, 61, 0.14);
|
||||
--accent-glow: rgba(232, 163, 61, 0.35);
|
||||
--success: #3fb97f;
|
||||
--success-dim: rgba(63, 185, 127, 0.14);
|
||||
--info: #5b8def;
|
||||
--info-dim: rgba(91, 141, 239, 0.14);
|
||||
--error: #e5484d;
|
||||
--error-dim: rgba(229, 72, 77, 0.14);
|
||||
--cloud: #b98be0;
|
||||
--cloud-dim: rgba(185, 139, 224, 0.14);
|
||||
/* 圆角与唯一一级阴影 (§1.5);卡片不投影,用底色差分层 */
|
||||
--r-sm: 6px;
|
||||
--r-md: 10px;
|
||||
--r-lg: 14px;
|
||||
--r-pill: 999px;
|
||||
--shadow-float: 0 14px 44px rgba(0, 0, 0, 0.6), 0 0 0 1px var(--line-2);
|
||||
|
||||
/* Typography (§2.2) */
|
||||
--font-ui: 'Noto Sans SC', 'Noto Sans CJK SC', var(--font-body);
|
||||
--font-a-mono: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, monospace;
|
||||
/* 专属符号 (§1.7):齿孔只在记录流/输入台分隔带,光晕只在播放舞台 */
|
||||
--sprocket: radial-gradient(circle 2px, var(--line-2) 98%, transparent);
|
||||
--stage-bg:
|
||||
radial-gradient(60% 45% at 50% 38%, rgba(242, 199, 132, 0.07), transparent 70%),
|
||||
radial-gradient(80% 70% at 50% 50%, #0d1220, #05070c);
|
||||
|
||||
/* Avatar five-color set (§2.1) — five agents only */
|
||||
/* 动效 (§1.6,取 HEL-648 实测值);只动 transform / opacity */
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.45, 0.44, 1);
|
||||
--d-press: 120ms;
|
||||
--d-state: 150ms;
|
||||
--d-pop: 180ms;
|
||||
--d-leave: 200ms;
|
||||
--d-enter: 220ms;
|
||||
--d-drawer: 280ms;
|
||||
--d-flash: 900ms;
|
||||
--d-progress: 400ms;
|
||||
--press-scale: 0.97;
|
||||
|
||||
/* Avatar five-color set — five agents only; the one colorful gradient
|
||||
family the spec keeps (§3.6). */
|
||||
--avatar-1: linear-gradient(135deg, #e8a33d, #b96a1b);
|
||||
--avatar-2: linear-gradient(135deg, #5b8def, #3a5fa9);
|
||||
--avatar-3: linear-gradient(135deg, #3fb97f, #2a7a54);
|
||||
--avatar-4: linear-gradient(135deg, #b98be0, #7a4fa9);
|
||||
--avatar-5: linear-gradient(135deg, #d98e6a, #a95535);
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Legacy token names (§六 映射表) — aliases only, never new values,
|
||||
* so every existing component renders in the fusion palette.
|
||||
* ------------------------------------------------------------------ */
|
||||
--surface: var(--bg-2);
|
||||
--surface-2: var(--bg-3);
|
||||
--surface-3: var(--bg-4);
|
||||
--border: var(--line);
|
||||
--border-strong: var(--line-2);
|
||||
--border-hover: var(--line-2);
|
||||
--accent-hover: var(--accent-hi);
|
||||
--accent-contrast: var(--accent-ink);
|
||||
--accent-dim: var(--accent-soft);
|
||||
--accent-focus: var(--accent-line);
|
||||
--success: var(--ok);
|
||||
--success-dim: var(--ok-soft);
|
||||
--info: var(--run);
|
||||
--info-dim: var(--run-soft);
|
||||
--error: var(--err);
|
||||
--error-dim: var(--err-soft);
|
||||
--error-border: var(--err-line);
|
||||
--warning: var(--warn);
|
||||
--bg-primary: var(--bg);
|
||||
--bg-surface: var(--bg-2);
|
||||
--bg-elevated: var(--bg-3);
|
||||
--text-primary: var(--text);
|
||||
--text-secondary: var(--text-2);
|
||||
--text-muted: var(--text-3);
|
||||
--font-ui: var(--font);
|
||||
--font-a-mono: var(--mono);
|
||||
--font-display: var(--font);
|
||||
--font-body: var(--font);
|
||||
--font-mono: var(--mono);
|
||||
--radius-sm: var(--r-sm);
|
||||
--radius-md: var(--r-md);
|
||||
--radius-lg: var(--r-lg);
|
||||
--space-xs: var(--sp-1);
|
||||
--space-sm: var(--sp-3);
|
||||
--space-md: var(--sp-6);
|
||||
--space-lg: var(--sp-7);
|
||||
--space-xl: var(--sp-8);
|
||||
|
||||
/* Omni Composer pill tokens */
|
||||
--pill-bg: var(--bg-3);
|
||||
--pill-border: var(--line-2);
|
||||
--pill-radius: var(--r-pill);
|
||||
--pill-height: 36px;
|
||||
--pill-padding: 0 14px;
|
||||
--pill-font-size: var(--fs-body);
|
||||
}
|
||||
|
||||
/* 减少动态效果 (§五):系统设置自动生效,顶栏开关写 data-reduce="1"。
|
||||
位移/缩放/弹入/光晕全部取消,只留 ≤120ms 透明度,进度条直接跳变。 */
|
||||
:root[data-reduce='1'] {
|
||||
--d-press: 0ms;
|
||||
--d-state: 120ms;
|
||||
--d-pop: 120ms;
|
||||
--d-leave: 120ms;
|
||||
--d-enter: 120ms;
|
||||
--d-drawer: 120ms;
|
||||
--d-flash: 0ms;
|
||||
--d-progress: 0ms;
|
||||
--press-scale: 1;
|
||||
--ease-spring: ease;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--d-press: 0ms;
|
||||
--d-state: 120ms;
|
||||
--d-pop: 120ms;
|
||||
--d-leave: 120ms;
|
||||
--d-enter: 120ms;
|
||||
--d-drawer: 120ms;
|
||||
--d-flash: 0ms;
|
||||
--d-progress: 0ms;
|
||||
--press-scale: 1;
|
||||
--ease-spring: ease;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--page-glow), var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: var(--fs-body);
|
||||
line-height: var(--lh-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.a-darkroom {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.a-darkroom .mono,
|
||||
.a-darkroom code,
|
||||
.a-darkroom .kh {
|
||||
font-family: var(--font-a-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
.logo {
|
||||
font-family: var(--font-display);
|
||||
font-family: var(--font);
|
||||
font-size: var(--fs-body);
|
||||
line-height: var(--lh-body);
|
||||
}
|
||||
|
||||
code,
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
.mono,
|
||||
.kh {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: var(--space-md);
|
||||
/* ------------------------------------------------------------------
|
||||
* Shared controls (§四 组件状态通用规范). The only definitions of these
|
||||
* looks — `ui/Button.svelte` renders `.a-btn`, pages use the classes
|
||||
* directly for links / selects. Page styles may only tweak layout
|
||||
* (width / margin), never re-declare colors.
|
||||
* ------------------------------------------------------------------ */
|
||||
.a-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
min-height: 34px;
|
||||
padding: 0 var(--sp-6);
|
||||
border-radius: var(--r-md);
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
transform var(--d-press) var(--ease-out),
|
||||
background-color var(--d-state) ease,
|
||||
border-color var(--d-state) ease,
|
||||
box-shadow var(--d-state) ease,
|
||||
color var(--d-state) ease,
|
||||
opacity var(--d-state) ease;
|
||||
}
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
.a-btn.lg {
|
||||
min-height: 38px;
|
||||
padding: 0 var(--sp-7);
|
||||
}
|
||||
.a-btn.sm {
|
||||
min-height: 28px;
|
||||
padding: 0 var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.a-btn.xs {
|
||||
min-height: 24px;
|
||||
padding: 0 var(--sp-3);
|
||||
font-size: var(--fs-mini);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.a-btn.block {
|
||||
width: 100%;
|
||||
}
|
||||
.a-btn.primary {
|
||||
background: var(--accent-grad);
|
||||
color: var(--accent-ink);
|
||||
box-shadow: var(--btn-glow);
|
||||
}
|
||||
.a-btn.secondary {
|
||||
background: var(--bg-3);
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
.a-btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
}
|
||||
/* 危险两级制:列表行内只用描边红 danger;实心红 danger-solid 只在确认弹层内。 */
|
||||
.a-btn.danger {
|
||||
background: transparent;
|
||||
border-color: var(--err-line);
|
||||
color: var(--err);
|
||||
}
|
||||
.a-btn.danger-solid {
|
||||
background: var(--err);
|
||||
color: var(--on-media);
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.a-btn.primary:hover:not(:disabled) {
|
||||
background: var(--accent-grad-hover);
|
||||
box-shadow: var(--btn-glow-hover);
|
||||
}
|
||||
.a-btn.secondary:hover:not(:disabled) {
|
||||
border-color: var(--line-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.a-btn.ghost:hover:not(:disabled) {
|
||||
background: var(--bg-4);
|
||||
color: var(--text);
|
||||
}
|
||||
.a-btn.danger:hover:not(:disabled) {
|
||||
background: var(--err-soft);
|
||||
}
|
||||
.a-btn.danger-solid:hover:not(:disabled) {
|
||||
box-shadow:
|
||||
0 0 0 1px var(--err-line),
|
||||
0 0 18px var(--err-soft);
|
||||
}
|
||||
}
|
||||
.a-btn:active:not(:disabled) {
|
||||
transform: scale(var(--press-scale));
|
||||
}
|
||||
/* 禁用必须在旁边给出中文原因(§1.2),由调用方渲染。 */
|
||||
.a-btn:disabled,
|
||||
.a-btn[aria-disabled='true'] {
|
||||
opacity: 0.45;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Top-bar status capsule (queue / connection / save state) — §二 顶栏胶囊. */
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
height: 30px;
|
||||
padding: 0 var(--sp-5);
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.status-chip.accent {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent-line);
|
||||
color: var(--accent);
|
||||
}
|
||||
.status-chip-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: currentColor;
|
||||
}
|
||||
.status-chip-dot.ok {
|
||||
background: var(--ok);
|
||||
}
|
||||
.status-chip-dot.run {
|
||||
background: var(--run);
|
||||
}
|
||||
.status-chip-dot.fail {
|
||||
background: var(--err);
|
||||
}
|
||||
.status-chip-dot.idle {
|
||||
background: var(--text-3);
|
||||
}
|
||||
|
||||
/* Status label (§四.2):语义色文字 + 13% 软底;默认为中性(排队 / 未开始)。 */
|
||||
.a-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
height: 20px;
|
||||
padding: 0 var(--sp-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-4);
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.a-tag.accent {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.a-tag.ok {
|
||||
color: var(--ok);
|
||||
background: var(--ok-soft);
|
||||
}
|
||||
.a-tag.run {
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.a-tag.err {
|
||||
color: var(--err);
|
||||
background: var(--err-soft);
|
||||
}
|
||||
.a-tag.warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
|
||||
/* Real-progress bar (§1.3):<div class="a-progress" style="--p:.42"><i></i></div>.
|
||||
Only for true progress values — never fake progress. */
|
||||
.a-progress {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-4);
|
||||
overflow: hidden;
|
||||
}
|
||||
.a-progress > i {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--run-grad);
|
||||
box-shadow: 0 0 8px var(--run-soft);
|
||||
transform-origin: left;
|
||||
transform: scaleX(var(--p, 0));
|
||||
transition: transform var(--d-progress) linear;
|
||||
}
|
||||
|
||||
/* Film sprocket divider — only between the take feed and the composer (§1.7). */
|
||||
.a-sprocket {
|
||||
height: 6px;
|
||||
background-image: var(--sprocket);
|
||||
background-size: 18px 6px;
|
||||
background-repeat: repeat-x;
|
||||
background-position: center;
|
||||
opacity: 0.55;
|
||||
}
|
||||
:root[data-reduce='1'] .a-sprocket {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Projection stage — only behind media in the inspector (§1.7). */
|
||||
.a-stage {
|
||||
position: relative;
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
background: var(--stage-bg);
|
||||
box-shadow: 0 0 0 1px var(--line);
|
||||
}
|
||||
|
||||
/* Floating surface for pop / drawer / confirm / toast / lightbox (§四.3). */
|
||||
.a-float {
|
||||
background: var(--float-bg);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
/* Card / panel surface (§1.5:卡片不投影,用底色差分层). */
|
||||
.a-panel {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
padding: var(--sp-7);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.a-panel-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-section);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.a-panel-lead {
|
||||
margin: var(--sp-1) 0 0;
|
||||
font-size: var(--fs-aux);
|
||||
line-height: var(--lh-body);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.a-select,
|
||||
.a-input,
|
||||
.a-textarea,
|
||||
.field-input,
|
||||
.field-textarea,
|
||||
.field-select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-body-sm);
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.a-select {
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
cursor: pointer;
|
||||
}
|
||||
.a-input,
|
||||
.field-input,
|
||||
.field-select {
|
||||
width: 100%;
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
}
|
||||
.a-textarea,
|
||||
.field-textarea {
|
||||
width: 100%;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
resize: vertical;
|
||||
min-height: 96px;
|
||||
line-height: 1.5;
|
||||
line-height: var(--lh-body);
|
||||
}
|
||||
.a-input::placeholder,
|
||||
.a-textarea::placeholder,
|
||||
.field-input::placeholder,
|
||||
.field-textarea::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.a-select:hover,
|
||||
.a-input:hover,
|
||||
.a-textarea:hover,
|
||||
.field-input:hover,
|
||||
.field-textarea:hover,
|
||||
.field-select:hover {
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
}
|
||||
.a-select:focus,
|
||||
.a-input:focus,
|
||||
.a-textarea:focus,
|
||||
.field-input:focus,
|
||||
.field-textarea:focus,
|
||||
.field-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.a-input.invalid,
|
||||
.a-textarea.invalid,
|
||||
.field-input.invalid {
|
||||
border-color: var(--err);
|
||||
}
|
||||
.a-select option.unavailable {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: var(--fs-aux);
|
||||
font-weight: 500;
|
||||
color: var(--text-2);
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
.field-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: var(--sp-2) 0 0;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.field-error {
|
||||
margin: var(--sp-2) 0 0;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
/* Dark theme scrollbars (Firefox + Chromium) */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3f3f46 transparent;
|
||||
scrollbar-color: var(--bg-4) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
@@ -176,14 +576,14 @@ code,
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-4);
|
||||
border-radius: var(--r-pill);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
background: var(--text-3);
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
@@ -192,17 +592,19 @@ code,
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Global keyboard focus fallback: every natively focusable element gets a ring,
|
||||
even before the ui/ primitives are adopted. Component-scoped :focus-visible
|
||||
rules (e.g. .field-input) override this via higher specificity. */
|
||||
/* Global keyboard focus (§四.8): every natively focusable element gets the
|
||||
gold ring. Component-scoped :focus-visible rules override this via higher
|
||||
specificity. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
/* Global reduced-motion baseline. Components that need a styled static fallback
|
||||
(Skeleton shimmer, ProgressBar indeterminate, StatusChip pulse) also handle
|
||||
this in their own scoped styles. */
|
||||
/* Reduced-motion baseline for components that still carry their own
|
||||
durations. Components that need a styled static fallback (Skeleton,
|
||||
ProgressBar indeterminate, StatusChip pulse) also handle this in their
|
||||
scoped styles. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
@@ -212,3 +614,10 @@ code,
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
:root[data-reduce='1'] *,
|
||||
:root[data-reduce='1'] *::before,
|
||||
:root[data-reduce='1'] *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
redirect(308, '/');
|
||||
}
|
||||
@@ -1,2 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
redirect(308, '/');
|
||||
}
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
redirect(308, '/');
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,5 @@
|
||||
<script lang="ts">
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import StudioGenerate from '$lib/components/studio/StudioGenerate.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
</script>
|
||||
|
||||
<AppShell active="image" title={t('studio.image.title')} subtitle={t('studio.image.subtitle')}>
|
||||
<StudioGenerate kind="image" />
|
||||
</AppShell>
|
||||
<StudioGenerate kind="image" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user