Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60c043db7c |
@@ -41,12 +41,24 @@ 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_id}:{msg}")
|
||||
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}")
|
||||
@@ -217,6 +229,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,
|
||||
@@ -226,7 +269,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)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.patcher import FIELD_KEY_SEP, resolve_patch_field
|
||||
@@ -67,6 +68,20 @@ def parse_dynamic_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return results
|
||||
|
||||
|
||||
def effective_input_schema(
|
||||
stored: list[dict[str, Any]] | None, workflow: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""The schema a stored workflow exposes now.
|
||||
|
||||
Auto-detected (and empty) schemas are re-derived from the workflow JSON
|
||||
on every read, so parser upgrades reach workflows imported earlier
|
||||
without a re-import. Tagged ``(Input:…)`` schemas are kept as stored.
|
||||
"""
|
||||
if not stored or any(isinstance(e, dict) and e.get("auto") for e in stored):
|
||||
return parse_dynamic_inputs(workflow)
|
||||
return stored
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Untagged detection
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -185,9 +200,15 @@ def _duration_hint(
|
||||
|
||||
|
||||
def _entry(
|
||||
key: str, label: str, role: str | None, kind: str, default: Any, required: bool
|
||||
key: str,
|
||||
label: str,
|
||||
role: str | None,
|
||||
kind: str,
|
||||
default: Any,
|
||||
required: bool,
|
||||
**extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
entry = {
|
||||
"nodeId": key,
|
||||
"label": label,
|
||||
"role": role,
|
||||
@@ -196,6 +217,47 @@ def _entry(
|
||||
"required": required,
|
||||
"auto": True,
|
||||
}
|
||||
entry.update({k: v for k, v in extra.items() if v is not None})
|
||||
return entry
|
||||
|
||||
|
||||
# ComfyUI's own widget limits (nodes.py / comfy_extras) for the widgets we
|
||||
# expose; the studio validates against them before a job is queued. Seeds
|
||||
# stop at 2**53-1 (ComfyUI allows 2**64-1): larger values lose precision in
|
||||
# the browser's JSON numbers.
|
||||
_NUMBER_LIMITS: dict[str, dict[str, Any]] = {
|
||||
"seed": {"min": 0, "max": 2**53 - 1, "step": 1, "integer": True},
|
||||
"steps": {"min": 1, "max": 10000, "step": 1, "integer": True},
|
||||
"cfg": {"min": 0.0, "max": 100.0, "step": 0.1},
|
||||
"denoise": {"min": 0.0, "max": 1.0, "step": 0.01},
|
||||
"width": {"min": 16, "max": 16384, "step": 8, "integer": True},
|
||||
"height": {"min": 16, "max": 16384, "step": 8, "integer": True},
|
||||
"batch": {"min": 1, "max": 4096, "step": 1, "integer": True},
|
||||
"megapixels": {"min": 0.1, "max": 16.0, "step": 0.1},
|
||||
}
|
||||
|
||||
# comfy_extras/nodes_resolution.py — option strings must match exactly.
|
||||
RESOLUTION_ASPECT_RATIOS: dict[str, tuple[int, int]] = {
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
def resolution_selector_size(aspect_ratio: str, megapixels: float, multiple: int) -> tuple[int, int]:
|
||||
"""Width/height exactly as ComfyUI's ResolutionSelector computes them."""
|
||||
w_ratio, h_ratio = RESOLUTION_ASPECT_RATIOS[aspect_ratio]
|
||||
scale = math.sqrt(megapixels * 1024 * 1024 / (w_ratio * h_ratio))
|
||||
multiple = max(1, int(multiple))
|
||||
return (
|
||||
round(w_ratio * scale / multiple) * multiple,
|
||||
round(h_ratio * scale / multiple) * multiple,
|
||||
)
|
||||
|
||||
|
||||
def _key(node_id: str, node: dict[str, Any], field: str) -> str:
|
||||
@@ -232,7 +294,12 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if not _is_scalar(value) or isinstance(value, str):
|
||||
return
|
||||
taken_roles.add(role)
|
||||
numbers.append(_entry(_key(nid, node, field), label, role, "number", value, False))
|
||||
numbers.append(
|
||||
_entry(
|
||||
_key(nid, node, field), label, role, "number", value, False,
|
||||
**_NUMBER_LIMITS.get(role, {}),
|
||||
)
|
||||
)
|
||||
|
||||
for nid, node in nodes:
|
||||
ct = str(node.get("class_type") or "")
|
||||
@@ -285,12 +352,40 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
add_number(nid, node, "seed" if "seed" in inputs else "noise_seed", "seed", "随机种子")
|
||||
add_number(nid, node, "steps", "steps", "采样步数")
|
||||
add_number(nid, node, "cfg", "cfg", "CFG")
|
||||
add_number(nid, node, "denoise", "denoise", "降噪强度")
|
||||
continue
|
||||
if ct == "RandomNoise":
|
||||
add_number(nid, node, "noise_seed", "seed", "随机种子")
|
||||
continue
|
||||
if ct == "BasicScheduler":
|
||||
add_number(nid, node, "steps", "steps", "采样步数")
|
||||
add_number(nid, node, "denoise", "denoise", "降噪强度")
|
||||
continue
|
||||
if ct == "ResolutionSelector" and nid in consumers:
|
||||
ratio = inputs.get("aspect_ratio")
|
||||
if (
|
||||
"aspect_ratio" not in taken_roles
|
||||
and isinstance(ratio, str)
|
||||
and ratio in RESOLUTION_ASPECT_RATIOS
|
||||
):
|
||||
taken_roles.add("aspect_ratio")
|
||||
multiple = inputs.get("multiple")
|
||||
numbers.append(
|
||||
_entry(
|
||||
_key(nid, node, "aspect_ratio"),
|
||||
"画面比例",
|
||||
"aspect_ratio",
|
||||
"select",
|
||||
ratio,
|
||||
False,
|
||||
options=list(RESOLUTION_ASPECT_RATIOS),
|
||||
resolution={
|
||||
"megapixels": _key(nid, node, "megapixels"),
|
||||
"multiple": multiple if isinstance(multiple, int) else 8,
|
||||
},
|
||||
)
|
||||
)
|
||||
add_number(nid, node, "megapixels", "megapixels", "总像素(百万)")
|
||||
continue
|
||||
if ct == "CFGGuider":
|
||||
add_number(nid, node, "cfg", "cfg", "CFG")
|
||||
@@ -316,8 +411,16 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
role = None
|
||||
if role:
|
||||
taken_roles.add(role)
|
||||
limits = dict(_NUMBER_LIMITS.get(role or "", {}))
|
||||
if ct in ("PrimitiveInt", "INT"):
|
||||
limits["integer"] = True
|
||||
if role == "duration":
|
||||
limits.setdefault("min", 0.1)
|
||||
numbers.append(
|
||||
_entry(_key(nid, node, "value"), label, role, "number", inputs["value"], False)
|
||||
_entry(
|
||||
_key(nid, node, "value"), label, role, "number", inputs["value"], False,
|
||||
**limits,
|
||||
)
|
||||
)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Check and coerce studio form values against a workflow's input schema."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
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).
|
||||
Only an auto-detected positive prompt must be present — tagged workflows
|
||||
mark every input required, and blank ones fall back to the workflow's value.
|
||||
"""
|
||||
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 == "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_prompt = entry.get("auto") and entry.get("required") and entry.get("role") == "prompt"
|
||||
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
|
||||
if required_prompt and key not in clean and not has_fallback:
|
||||
errors.append(f"请填写「{entry.get('label') or key}」")
|
||||
return clean, errors
|
||||
@@ -138,7 +138,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 (
|
||||
@@ -420,6 +422,13 @@ 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")
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
@@ -105,6 +106,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 +157,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,6 +8,8 @@ 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
|
||||
@@ -26,12 +28,22 @@ 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 isinstance(msg, (list, tuple)) and len(msg) == 2 and msg[0] == "execution_error":
|
||||
body = msg[1] if isinstance(msg[1], dict) else {}
|
||||
node = body.get("node_type") or body.get("node_id") or "?"
|
||||
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()}")
|
||||
return ";".join(parts) or str(messages)[:500]
|
||||
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):
|
||||
@@ -91,6 +103,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",
|
||||
{
|
||||
@@ -102,6 +115,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)",
|
||||
},
|
||||
)
|
||||
@@ -167,8 +183,10 @@ class QueueWorker:
|
||||
client = ComfyUIClient(base_url, headers=headers)
|
||||
try:
|
||||
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(
|
||||
@@ -191,21 +209,42 @@ 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(
|
||||
"等待 ComfyUI 出结果超时:可在「设置 → 队列」调大超时,或到 ComfyUI 检查该任务"
|
||||
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 执行出错:{_comfy_messages(messages)}")
|
||||
raise RuntimeError(
|
||||
f"ComfyUI 执行出错(任务 ID {prompt_id}):{_comfy_messages(messages)}"
|
||||
)
|
||||
|
||||
outputs_meta = client.extract_outputs(history)
|
||||
saved = [m for m in outputs_meta if m.get("type") == "output"]
|
||||
outputs_meta = saved or outputs_meta
|
||||
if not outputs_meta:
|
||||
raise RuntimeError(
|
||||
f"ComfyUI 显示任务已结束(任务 ID {prompt_id}),但没有返回任何图片或视频,"
|
||||
"不算生成完成。请确认工作流带有保存/预览节点,并到 ComfyUI 历史中核对该任务。"
|
||||
)
|
||||
queue_manager.set_phase(job["id"], "saving")
|
||||
dest_dir = config.settings.assets_dir / str(project_id) / kind
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
paths: list[str] = []
|
||||
@@ -221,6 +260,7 @@ class QueueWorker:
|
||||
paths.append(str(dest))
|
||||
|
||||
self._apply_outputs_to_entities(job, payload, paths)
|
||||
queue_manager.set_phase(job["id"], "finished")
|
||||
return paths
|
||||
finally:
|
||||
await client.close()
|
||||
@@ -360,6 +400,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():
|
||||
@@ -370,17 +413,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:
|
||||
|
||||
@@ -13,6 +13,8 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from calliope import config
|
||||
from calliope.agent.prompts import item_reference_prompt
|
||||
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
|
||||
@@ -332,7 +334,7 @@ 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 FROM workflows WHERE id = ?",
|
||||
(payload.workflow_id,),
|
||||
).fetchone()
|
||||
if not wf:
|
||||
@@ -344,16 +346,24 @@ async def generate(payload: PlaygroundGenerate) -> dict[str, Any]:
|
||||
status_code=400, detail="该工作流已停用,请到「ComfyUI 连接」页启用后再生成"
|
||||
)
|
||||
kind = wf["kind"]
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [],
|
||||
json.loads(wf["workflow_json"]),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
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 {}),
|
||||
},
|
||||
|
||||
@@ -5,7 +5,11 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs, parse_dynamic_outputs
|
||||
from calliope.comfyui.parser import (
|
||||
effective_input_schema,
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
from calliope.comfyui.profiles import detect_prompt_profile
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, row_to_dict
|
||||
@@ -17,10 +21,8 @@ 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 []
|
||||
if not data["input_schema"]:
|
||||
# An empty stored schema is re-derived so untagged workflows still expose inputs.
|
||||
data["input_schema"] = parse_dynamic_inputs(data["workflow_json"])
|
||||
stored = json.loads(data["input_schema"]) if data.get("input_schema") else []
|
||||
data["input_schema"] = effective_input_schema(stored, data["workflow_json"])
|
||||
data["output_schema"] = json.loads(data["output_schema"]) if data.get("output_schema") else []
|
||||
data["is_enabled"] = bool(data.get("is_enabled"))
|
||||
return data
|
||||
|
||||
@@ -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
|
||||
@@ -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,4 +1,12 @@
|
||||
export type ComfyInputKind = 'text' | 'textarea' | 'number' | 'image' | 'image_url' | 'audio' | 'video';
|
||||
export type ComfyInputKind =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'number'
|
||||
| 'select'
|
||||
| 'image'
|
||||
| 'image_url'
|
||||
| 'audio'
|
||||
| 'video';
|
||||
export type ComfyOutputKind = 'image' | 'video' | 'other';
|
||||
|
||||
export interface ComfyDynamicInput {
|
||||
@@ -11,6 +19,15 @@ export interface ComfyDynamicInput {
|
||||
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 };
|
||||
}
|
||||
|
||||
export interface ComfyDynamicOutput {
|
||||
@@ -57,6 +74,10 @@ 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;
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
import { createUploadManager } from '$lib/comfy/useUpload.svelte';
|
||||
import { connectEvents } from '$lib/events';
|
||||
import { handleJobEvent, progressFor } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { ratioShort, resolutionSelectorSize } from '$lib/comfy/resolution';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
@@ -67,13 +69,19 @@
|
||||
return $jobsQuery.data ?? [];
|
||||
});
|
||||
const kindJobs = $derived(allJobs.filter((j) => j.kind === kind));
|
||||
// Jobs are listed newest first; the worker runs the oldest pending job next.
|
||||
const activeJob = $derived(
|
||||
kindJobs.find((j) => j.status === 'running') ?? kindJobs.find((j) => j.status === 'pending') ?? null,
|
||||
kindJobs.find((j) => j.status === 'running') ?? kindJobs.findLast((j) => j.status === 'pending') ?? null,
|
||||
);
|
||||
const jobsAhead = $derived(
|
||||
activeJob?.status === 'pending'
|
||||
? allJobs.filter((j) => j.status === 'running' || (j.status === 'pending' && j.id < activeJob.id)).length
|
||||
: 0,
|
||||
);
|
||||
const queueCount = $derived(kindJobs.filter((j) => j.status === 'pending' || j.status === 'running').length);
|
||||
const doneEntries = $derived(
|
||||
kindJobs
|
||||
.filter((j) => j.status === 'done' && (j.output_paths?.length ?? 0) > 0)
|
||||
.filter((j) => jobStatusInfo(j).tone === 'ok')
|
||||
.flatMap((j) => j.output_paths.map((p) => ({ job: j, path: p, key: `${j.id}:${p}` }))),
|
||||
);
|
||||
|
||||
@@ -87,10 +95,20 @@
|
||||
handleJobEvent(ev);
|
||||
if (ev.type === 'job.failed' && !ev.data.cancelled) {
|
||||
const id = Number(ev.data.job_id);
|
||||
if (submittedIds.has(id) || kindJobs.some((j) => j.id === id)) {
|
||||
// The SSE backlog replays old failures on every connect.
|
||||
const known = kindJobs.find((j) => j.id === id);
|
||||
if (submittedIds.has(id) || (known && known.status !== 'failed')) {
|
||||
toast.error(t('studio.jobFailedToast', { detail: String(ev.data.error ?? '') }));
|
||||
}
|
||||
}
|
||||
if (ev.type === 'job.completed' && submittedIds.has(Number(ev.data.job_id))) {
|
||||
if (ev.data.dry_run) {
|
||||
toast.info(t('studio.dryRunToast'));
|
||||
} else {
|
||||
const outputs = Array.isArray(ev.data.outputs) ? ev.data.outputs.length : 0;
|
||||
toast.success(t('studio.doneToast', { n: outputs, id: String(ev.data.comfy_prompt_id ?? '—') }));
|
||||
}
|
||||
}
|
||||
if (ev.type.startsWith('job.') || ev.type === 'events.resync') {
|
||||
void client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
void client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
@@ -180,8 +198,21 @@
|
||||
const cfgInp = $derived(
|
||||
schema.find((i) => /\bcfg\b|引导/.test(hay(i)) && !isMedia(i)) ?? null,
|
||||
);
|
||||
const denoiseInp = $derived(schema.find((i) => !isMedia(i) && role(i) === 'denoise') ?? null);
|
||||
const batchInp = $derived(schema.find((i) => /批次|batch/.test(hay(i)) && !isMedia(i)) ?? null);
|
||||
const fpsInp = $derived(schema.find((i) => /帧率|fps|frame.?rate/.test(hay(i)) && !isMedia(i)) ?? null);
|
||||
// ComfyUI ResolutionSelector: the ratio + megapixels pair decides width/height.
|
||||
const ratioInp = $derived(
|
||||
schema.find((i) => i.kind === 'select' && role(i) === 'aspect_ratio' && i.resolution) ?? null,
|
||||
);
|
||||
const megapixelsInp = $derived(
|
||||
ratioInp ? (schema.find((i) => i.nodeId === ratioInp.resolution!.megapixels) ?? null) : null,
|
||||
);
|
||||
const outputSize = $derived.by(() => {
|
||||
if (!ratioInp?.resolution) return null;
|
||||
const mp = Number(values[megapixelsInp?.nodeId ?? ''] ?? megapixelsInp?.defaultValue);
|
||||
return resolutionSelectorSize(String(values[ratioInp.nodeId] ?? ratioInp.defaultValue ?? ''), mp, ratioInp.resolution.multiple);
|
||||
});
|
||||
|
||||
interface SlotDef {
|
||||
inp: ComfyDynamicInput;
|
||||
@@ -232,8 +263,11 @@
|
||||
durationInp,
|
||||
stepsInp,
|
||||
cfgInp,
|
||||
denoiseInp,
|
||||
batchInp,
|
||||
fpsInp,
|
||||
ratioInp,
|
||||
megapixelsInp,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((i) => i!.nodeId),
|
||||
@@ -307,9 +341,32 @@
|
||||
errs[inp.nodeId] = t('studio.errFill', { label: inp.label });
|
||||
}
|
||||
}
|
||||
for (const inp of schema) {
|
||||
const raw = values[inp.nodeId];
|
||||
if (raw === undefined || String(raw).trim() === '' || errs[inp.nodeId]) continue;
|
||||
const msg = valueError(inp, raw);
|
||||
if (msg) errs[inp.nodeId] = msg;
|
||||
}
|
||||
return errs;
|
||||
});
|
||||
|
||||
/** Same rules the backend enforces before queueing (comfyui/validate.py). */
|
||||
function valueError(inp: ComfyDynamicInput, raw: string | number): string | null {
|
||||
const label = inp.label;
|
||||
if (inp.kind === 'select') {
|
||||
return inp.options?.length && !inp.options.includes(String(raw))
|
||||
? t('studio.errOption', { label })
|
||||
: null;
|
||||
}
|
||||
if (inp.kind !== 'number') return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return t('studio.errNumber', { label });
|
||||
if (inp.integer && !Number.isInteger(n)) return t('studio.errInteger', { label });
|
||||
if (inp.min != null && n < inp.min) return t('studio.errMin', { label, v: inp.min });
|
||||
if (inp.max != null && n > inp.max) return t('studio.errMax', { label, v: inp.max });
|
||||
return null;
|
||||
}
|
||||
|
||||
function setVal(nodeId: string, v: string | number) {
|
||||
values = { ...values, [nodeId]: v };
|
||||
}
|
||||
@@ -319,7 +376,7 @@
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (!Number.isNaN(n)) setVal(nodeId, n);
|
||||
setVal(nodeId, Number.isNaN(n) ? raw : n);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -332,7 +389,7 @@
|
||||
|
||||
const imageResults = $derived(
|
||||
allJobs
|
||||
.filter((j) => j.kind === 'image' && j.status === 'done' && (j.output_paths?.length ?? 0) > 0)
|
||||
.filter((j) => j.kind === 'image' && jobStatusInfo(j).tone === 'ok')
|
||||
.flatMap((j) => j.output_paths.map((p) => ({ job: j, path: p }))),
|
||||
);
|
||||
|
||||
@@ -415,9 +472,13 @@
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
const activeStatus = $derived(activeJob ? jobStatusInfo(activeJob) : null);
|
||||
// ComfyUI reports no percent: the bar only moves while ComfyUI is really
|
||||
// executing and never fills (job.completed ends the card instead).
|
||||
const progress = $derived.by(() => {
|
||||
if (!activeJob) return 0;
|
||||
if (activeJob.status === 'pending') return 0;
|
||||
if (!activeJob || activeJob.status !== 'running') return 0;
|
||||
const phase = progressFor(activeJob.id)?.phase ?? activeJob.phase;
|
||||
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
|
||||
return progressFor(activeJob.id)?.progress ?? 3;
|
||||
});
|
||||
|
||||
@@ -457,22 +518,6 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
const frameTotal = $derived.by(() => {
|
||||
if (kind !== 'video' || !activeJob) return null;
|
||||
const dur = Number(jobValueByRole(activeJob, 'duration'));
|
||||
const fps = Number(
|
||||
(() => {
|
||||
const wf = ($workflowsQuery.data ?? []).find((w) => w.id === activeJob.workflow_id);
|
||||
const inp = (wf?.input_schema ?? []).find((i) => /帧率|fps|frame.?rate/i.test(`${i.role ?? ''} ${i.label}`));
|
||||
if (!inp) return null;
|
||||
const vals = (activeJob.payload?.input_values ?? {}) as Record<string, unknown>;
|
||||
return vals[inp.nodeId] ?? inp.defaultValue ?? null;
|
||||
})(),
|
||||
);
|
||||
if (Number.isFinite(dur) && dur > 0 && Number.isFinite(fps) && fps > 0) return Math.round(dur * fps);
|
||||
return null;
|
||||
});
|
||||
|
||||
const endpointName = $derived(
|
||||
((activeJob?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string) ??
|
||||
endpoints.find((e) => e.id === endpointId)?.name ??
|
||||
@@ -502,14 +547,25 @@
|
||||
|
||||
function summaryOf(job: Job): string {
|
||||
const parts: string[] = [];
|
||||
const wf = ($workflowsQuery.data ?? []).find((x) => x.id === job.workflow_id);
|
||||
const w = jobValueByRole(job, 'width');
|
||||
const h = jobValueByRole(job, 'height');
|
||||
const ratio = (wf?.input_schema ?? []).find((i) => normalizeInputRole(i.role ?? null) === 'aspect_ratio' && i.resolution);
|
||||
if (w && h) parts.push(`${w}×${h}`);
|
||||
else if (ratio?.resolution) {
|
||||
const vals = (job.payload?.input_values ?? {}) as Record<string, unknown>;
|
||||
const mpDefault = wf?.input_schema.find((i) => i.nodeId === ratio.resolution!.megapixels)?.defaultValue;
|
||||
const size = resolutionSelectorSize(
|
||||
String(vals[ratio.nodeId] ?? ratio.defaultValue ?? ''),
|
||||
Number(vals[ratio.resolution.megapixels] ?? mpDefault),
|
||||
ratio.resolution.multiple,
|
||||
);
|
||||
if (size) parts.push(`${size.width}×${size.height}`);
|
||||
}
|
||||
const seed = jobValueByRole(job, 'seed');
|
||||
if (seed) parts.push(t('studio.seedLabel') + ' ' + seed);
|
||||
const dur = jobValueByRole(job, 'duration');
|
||||
if (dur) parts.push(`${dur}s`);
|
||||
const wf = ($workflowsQuery.data ?? []).find((x) => x.id === job.workflow_id);
|
||||
if (wf) parts.push(wf.name);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -587,6 +643,28 @@
|
||||
// ------------------------------------------------------------------
|
||||
// History actions
|
||||
// ------------------------------------------------------------------
|
||||
/** Fill the form back with a past job's values (on its own workflow). */
|
||||
function reuseParams(job: Job) {
|
||||
const wf = kindWorkflows.find((w) => w.id === job.workflow_id);
|
||||
if (!wf) {
|
||||
toast.error(t('studio.reuseMissingWf'));
|
||||
return;
|
||||
}
|
||||
const next: Record<string, string | number> = {};
|
||||
for (const inp of wf.input_schema ?? []) {
|
||||
if (inp.defaultValue !== undefined && inp.defaultValue !== '') next[inp.nodeId] = inp.defaultValue;
|
||||
}
|
||||
const past = (job.payload?.input_values ?? {}) as Record<string, unknown>;
|
||||
for (const [k, v] of Object.entries(past)) {
|
||||
if (typeof v === 'string' || typeof v === 'number') next[k] = v;
|
||||
}
|
||||
workflowId = wf.id;
|
||||
lastWfId = wf.id;
|
||||
values = next;
|
||||
showErrors = false;
|
||||
toast.success(t('studio.reusedToast'));
|
||||
}
|
||||
|
||||
async function retryJob(job: Job) {
|
||||
if (retryingId != null) return;
|
||||
retryingId = job.id;
|
||||
@@ -642,6 +720,10 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet fieldError(inp: ComfyDynamicInput)}
|
||||
{#if showErrors && errors[inp.nodeId]?.trim()}<span class="f-error">{errors[inp.nodeId]}</span>{/if}
|
||||
{/snippet}
|
||||
|
||||
<AppShell
|
||||
active={kind}
|
||||
title={kind === 'image' ? t('studio.image.title') : t('studio.video.title')}
|
||||
@@ -691,6 +773,9 @@
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if dryRun}
|
||||
<p class="missing-note">{t('studio.dryRunNote')}</p>
|
||||
{/if}
|
||||
<div class="f-field">
|
||||
<span class="f-label">
|
||||
{t('studio.connLabel')}
|
||||
@@ -849,122 +934,190 @@
|
||||
<span class="f-label">{t('studio.seedLabel')}</span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
class:invalid={showErrors && errors[seedInp.nodeId]}
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={String(values[seedInp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(seedInp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
{@render fieldError(seedInp)}
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if kind === 'image'}
|
||||
{#if widthInp || heightInp || batchInp}
|
||||
<div class="pair-row">
|
||||
{#if widthInp && heightInp}
|
||||
<div class="f-field">
|
||||
<span class="f-label">{t('studio.sizeLabel')}</span>
|
||||
<div class="size-row">
|
||||
<input
|
||||
class="f-input mono"
|
||||
type="number"
|
||||
placeholder="W"
|
||||
value={String(values[widthInp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(widthInp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
<span class="size-x">×</span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
type="number"
|
||||
placeholder="H"
|
||||
value={String(values[heightInp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(heightInp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if batchInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('studio.batchLabel')}</span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
type="number"
|
||||
min="1"
|
||||
value={String(values[batchInp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(batchInp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if stepsInp || cfgInp}
|
||||
<div class="pair-row">
|
||||
{#if stepsInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">
|
||||
{t('studio.stepsLabel')}
|
||||
<span class="f-hint-inline mono">{values[stepsInp.nodeId] ?? '—'}</span>
|
||||
{#if ratioInp}
|
||||
<div class="pair-row">
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('studio.ratioLabel')}</span>
|
||||
<select
|
||||
class="f-input"
|
||||
class:invalid={showErrors && errors[ratioInp.nodeId]}
|
||||
value={String(values[ratioInp.nodeId] ?? ratioInp.defaultValue ?? '')}
|
||||
onchange={(e) => setVal(ratioInp.nodeId, e.currentTarget.value)}
|
||||
>
|
||||
{#each ratioInp.options ?? [] as opt (opt)}
|
||||
<option value={opt}>{ratioShort(opt)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{@render fieldError(ratioInp)}
|
||||
</label>
|
||||
{#if megapixelsInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('studio.megapixelsLabel')}</span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
class:invalid={showErrors && errors[megapixelsInp.nodeId]}
|
||||
type="number"
|
||||
min={megapixelsInp.min}
|
||||
max={megapixelsInp.max}
|
||||
step={megapixelsInp.step ?? 0.1}
|
||||
value={String(values[megapixelsInp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(megapixelsInp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
{#if outputSize}
|
||||
<span class="panel-hint mono">
|
||||
{t('studio.outputSize', { w: outputSize.width, h: outputSize.height })}
|
||||
</span>
|
||||
<input
|
||||
class="a-range"
|
||||
type="range"
|
||||
min="1"
|
||||
max="150"
|
||||
step="1"
|
||||
value={Number(values[stepsInp.nodeId] ?? 20)}
|
||||
oninput={(e) => setVal(stepsInp.nodeId, Number(e.currentTarget.value))}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
{#if cfgInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">
|
||||
{t('studio.cfgLabel')}
|
||||
<span class="f-hint-inline mono">{values[cfgInp.nodeId] ?? '—'}</span>
|
||||
</span>
|
||||
<input
|
||||
class="a-range"
|
||||
type="range"
|
||||
min="1"
|
||||
max="20"
|
||||
step="0.5"
|
||||
value={Number(values[cfgInp.nodeId] ?? 7)}
|
||||
oninput={(e) => setVal(cfgInp.nodeId, Number(e.currentTarget.value))}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
{/if}
|
||||
{@render fieldError(megapixelsInp)}
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if widthInp && heightInp}
|
||||
<div class="f-field">
|
||||
<span class="f-label">{t('studio.sizeLabel')}</span>
|
||||
<div class="size-row">
|
||||
<input
|
||||
class="f-input mono"
|
||||
class:invalid={showErrors && errors[widthInp.nodeId]}
|
||||
type="number"
|
||||
placeholder="W"
|
||||
min={widthInp.min}
|
||||
max={widthInp.max}
|
||||
step={widthInp.step}
|
||||
value={String(values[widthInp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(widthInp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
<span class="size-x">×</span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
class:invalid={showErrors && errors[heightInp.nodeId]}
|
||||
type="number"
|
||||
placeholder="H"
|
||||
min={heightInp.min}
|
||||
max={heightInp.max}
|
||||
step={heightInp.step}
|
||||
value={String(values[heightInp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(heightInp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if durationInp || fpsInp}
|
||||
<div class="pair-row">
|
||||
{#if durationInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('studio.durationLabel')}</span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
value={String(values[durationInp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(durationInp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
{#if fpsInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('studio.fpsLabel')}</span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
type="number"
|
||||
min="1"
|
||||
value={String(values[fpsInp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(fpsInp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{@render fieldError(widthInp)}
|
||||
{@render fieldError(heightInp)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stepsInp || cfgInp || denoiseInp || batchInp || durationInp || fpsInp}
|
||||
<div class="pair-row">
|
||||
{#if durationInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('studio.durationLabel')}</span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
class:invalid={showErrors && errors[durationInp.nodeId]}
|
||||
type="number"
|
||||
min={durationInp.min ?? 0}
|
||||
step="0.5"
|
||||
value={String(values[durationInp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(durationInp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
{@render fieldError(durationInp)}
|
||||
</label>
|
||||
{/if}
|
||||
{#if fpsInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('studio.fpsLabel')}</span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
class:invalid={showErrors && errors[fpsInp.nodeId]}
|
||||
type="number"
|
||||
min="1"
|
||||
value={String(values[fpsInp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(fpsInp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
{@render fieldError(fpsInp)}
|
||||
</label>
|
||||
{/if}
|
||||
{#if batchInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('studio.batchLabel')}</span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
class:invalid={showErrors && errors[batchInp.nodeId]}
|
||||
type="number"
|
||||
min={batchInp.min ?? 1}
|
||||
max={batchInp.max}
|
||||
step="1"
|
||||
value={String(values[batchInp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(batchInp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
{@render fieldError(batchInp)}
|
||||
</label>
|
||||
{/if}
|
||||
{#if stepsInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">
|
||||
{t('studio.stepsLabel')}
|
||||
<span class="f-hint-inline mono">{values[stepsInp.nodeId] ?? '—'}</span>
|
||||
</span>
|
||||
<input
|
||||
class="a-range"
|
||||
type="range"
|
||||
min={stepsInp.min ?? 1}
|
||||
max={Math.max(150, Number(values[stepsInp.nodeId] ?? stepsInp.defaultValue ?? 0))}
|
||||
step="1"
|
||||
value={Number(values[stepsInp.nodeId] ?? stepsInp.defaultValue ?? 20)}
|
||||
oninput={(e) => setVal(stepsInp.nodeId, Number(e.currentTarget.value))}
|
||||
/>
|
||||
{@render fieldError(stepsInp)}
|
||||
</label>
|
||||
{/if}
|
||||
{#if cfgInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">
|
||||
{t('studio.cfgLabel')}
|
||||
<span class="f-hint-inline mono">{values[cfgInp.nodeId] ?? '—'}</span>
|
||||
</span>
|
||||
<input
|
||||
class="a-range"
|
||||
type="range"
|
||||
min={cfgInp.min ?? 0}
|
||||
max={Math.max(20, Number(values[cfgInp.nodeId] ?? cfgInp.defaultValue ?? 0))}
|
||||
step="0.1"
|
||||
value={Number(values[cfgInp.nodeId] ?? cfgInp.defaultValue ?? 7)}
|
||||
oninput={(e) => setVal(cfgInp.nodeId, Number(e.currentTarget.value))}
|
||||
/>
|
||||
{@render fieldError(cfgInp)}
|
||||
</label>
|
||||
{/if}
|
||||
{#if denoiseInp}
|
||||
<label class="f-field">
|
||||
<span class="f-label">
|
||||
{t('studio.denoiseLabel')}
|
||||
<span class="f-hint-inline mono">{values[denoiseInp.nodeId] ?? '—'}</span>
|
||||
</span>
|
||||
<input
|
||||
class="a-range"
|
||||
type="range"
|
||||
min={denoiseInp.min ?? 0}
|
||||
max={denoiseInp.max ?? 1}
|
||||
step={denoiseInp.step ?? 0.01}
|
||||
value={Number(values[denoiseInp.nodeId] ?? denoiseInp.defaultValue ?? 1)}
|
||||
oninput={(e) => setVal(denoiseInp.nodeId, Number(e.currentTarget.value))}
|
||||
/>
|
||||
{@render fieldError(denoiseInp)}
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if restInputs.length}
|
||||
@@ -988,9 +1141,23 @@
|
||||
class="f-input mono"
|
||||
class:invalid={showErrors && errors[inp.nodeId]}
|
||||
type="number"
|
||||
min={inp.min}
|
||||
max={inp.max}
|
||||
step={inp.step ?? 'any'}
|
||||
value={String(values[inp.nodeId] ?? '')}
|
||||
oninput={(e) => numVal(inp.nodeId, e.currentTarget.value)}
|
||||
/>
|
||||
{:else if inp.kind === 'select'}
|
||||
<select
|
||||
class="f-input"
|
||||
class:invalid={showErrors && errors[inp.nodeId]}
|
||||
value={String(values[inp.nodeId] ?? inp.defaultValue ?? '')}
|
||||
onchange={(e) => setVal(inp.nodeId, e.currentTarget.value)}
|
||||
>
|
||||
{#each inp.options ?? [] as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
class="f-input"
|
||||
@@ -1056,25 +1223,18 @@
|
||||
{#if activeJob}
|
||||
<div class="task-body">
|
||||
{#if activeJob.status === 'pending'}
|
||||
<div class="pct mono">—</div>
|
||||
<div class="pct">{activeStatus?.label}</div>
|
||||
<div class="task-meta mono">
|
||||
{t('studio.taskQueued')} ·
|
||||
{t('studio.queuedBehind', { n: kindJobs.filter((j) => j.status === 'pending').indexOf(activeJob) })}
|
||||
{activeStatus?.detail} ·
|
||||
{t('studio.queuedBehind', { n: jobsAhead })}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="pct mono">{Math.round(progress)}<span class="pct-sign">%</span></div>
|
||||
<div class="progress" role="progressbar" aria-valuenow={Math.round(progress)} aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="pct">{activeStatus?.label}</div>
|
||||
<div class="progress" role="progressbar" aria-valuetext={activeStatus?.detail}>
|
||||
<div class="fill" style={`width:${progress}%`}></div>
|
||||
</div>
|
||||
<div class="task-meta mono">
|
||||
{#if kind === 'video' && frameTotal}
|
||||
{t('studio.renderFrames', {
|
||||
done: Math.round((progress / 100) * frameTotal),
|
||||
total: frameTotal,
|
||||
})} ·
|
||||
{:else if stepsInp && values[stepsInp.nodeId]}
|
||||
{t('studio.stepsShort', { v: values[stepsInp.nodeId] })} ·
|
||||
{/if}
|
||||
{activeStatus?.detail} ·
|
||||
{t('studio.usedShort', { v: elapsedOf(activeJob) })}
|
||||
{#if etaSec != null} · {t('studio.etaShort', { v: fmtDur(etaSec) })}{/if}
|
||||
· {t('studio.targetShort', { name: endpointName })}
|
||||
@@ -1114,6 +1274,10 @@
|
||||
<Icon name="image" size={13} />
|
||||
{t('studio.zoom')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onclick={() => reuseParams(selectedEntry!.job)}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('studio.reuseParams')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onclick={() => sendToImg2Img(selectedEntry!.path)}>
|
||||
<Icon name="upload" size={13} />
|
||||
{t('studio.sendToImg2Img')}
|
||||
@@ -1195,6 +1359,10 @@
|
||||
<Icon name="check" size={13} />
|
||||
{t('studio.copyPrompt')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onclick={() => reuseParams(selectedEntry!.job)}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('studio.reuseParams')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -1231,6 +1399,7 @@
|
||||
{:else}
|
||||
<div class="history-list">
|
||||
{#each kindJobs as job (job.id)}
|
||||
{@const st = jobStatusInfo(job)}
|
||||
<div class="hist-row" class:failed={job.status === 'failed'} class:video={kind === 'video'}>
|
||||
<div class="hist-thumb" class:video={kind === 'video'}>
|
||||
{#if job.output_paths?.[0]}
|
||||
@@ -1257,12 +1426,8 @@
|
||||
<div class="hist-name" title={wfName(job)}>{wfName(job)}</div>
|
||||
<div class="hist-meta mono">{summaryOf(job)}</div>
|
||||
<div class="hist-status">
|
||||
{#if job.status === 'done'}
|
||||
<span class="st ok">{t('home.jobDone')}</span>
|
||||
{:else if job.status === 'running'}
|
||||
<span class="st run mono">{Math.round(progressFor(job.id)?.progress ?? 3)}%</span>
|
||||
{:else if job.status === 'pending'}
|
||||
<span class="st queue">{t('studio.taskQueued')}</span>
|
||||
{#if job.status !== 'failed'}
|
||||
<span class="st {st.tone}" title={st.detail}>{st.label}</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1287,6 +1452,8 @@
|
||||
</div>
|
||||
{#if job.status === 'failed' && job.error}
|
||||
<div class="hist-error">{job.error}</div>
|
||||
{:else if st.tone === 'fail' || (job.status === 'done' && job.phase === 'dry_run')}
|
||||
<div class="hist-error">{st.detail}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1594,11 +1761,6 @@
|
||||
color: var(--text);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.pct-sign {
|
||||
font-size: 16px;
|
||||
color: var(--text-2);
|
||||
margin-left: 2px;
|
||||
}
|
||||
.progress {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
@@ -2004,6 +2166,9 @@
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
.st.fail {
|
||||
color: var(--error);
|
||||
}
|
||||
.fail-link {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -1761,4 +1761,39 @@ 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.',
|
||||
};
|
||||
|
||||
@@ -1760,6 +1760,41 @@ 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.',
|
||||
};
|
||||
|
||||
export type Dict = typeof en;
|
||||
|
||||
@@ -1761,4 +1761,39 @@ 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.',
|
||||
};
|
||||
|
||||
@@ -1761,4 +1761,39 @@ 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.',
|
||||
};
|
||||
|
||||
@@ -1761,4 +1761,39 @@ 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 を呼び出しません。設定でオフにできます。',
|
||||
};
|
||||
|
||||
@@ -1761,4 +1761,39 @@ 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를 호출하지 않습니다. 설정에서 끌 수 있습니다.',
|
||||
};
|
||||
|
||||
@@ -1760,4 +1760,39 @@ 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。可在「设置」中关闭。',
|
||||
};
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@
|
||||
} from '$lib/api';
|
||||
import { connectEvents } from '$lib/events';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { handleJobEvent, progressFor } from '$lib/jobProgress';
|
||||
import { handleJobEvent } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { isStandaloneJob, jobSourceLabel } from '$lib/jobLabels';
|
||||
@@ -75,18 +76,6 @@
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function stInfo(job: Job): { cls: string; label: string; mono?: boolean } {
|
||||
switch (job.status) {
|
||||
case 'done':
|
||||
return { cls: 'ok', label: t('tasks.done') };
|
||||
case 'running':
|
||||
return { cls: 'run', label: `${Math.round(progressFor(job.id)?.progress ?? 3)}%`, mono: true };
|
||||
case 'pending':
|
||||
return { cls: 'queue', label: t('tasks.queued') };
|
||||
default:
|
||||
return { cls: 'fail', label: t('tasks.failed') };
|
||||
}
|
||||
}
|
||||
|
||||
let busyId = $state<number | null>(null);
|
||||
async function retry(job: Job) {
|
||||
@@ -164,7 +153,7 @@
|
||||
{:else}
|
||||
<div class="task-table">
|
||||
{#each shown as job (job.id)}
|
||||
{@const st = stInfo(job)}
|
||||
{@const st = jobStatusInfo(job)}
|
||||
<div class="trow" class:failed={job.status === 'failed'}>
|
||||
<span class="tkind {job.kind}">
|
||||
<Icon name={job.kind === 'image' ? 'image' : 'film'} size={14} />
|
||||
@@ -188,6 +177,8 @@
|
||||
</div>
|
||||
{#if job.status === 'failed' && job.error}
|
||||
<div class="terror">{job.error}</div>
|
||||
{:else if st.tone === 'fail' || (job.status === 'done' && job.phase === 'dry_run')}
|
||||
<div class="terror">{st.detail}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tout">
|
||||
@@ -205,7 +196,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="tst {st.cls}" class:mono={st.mono}>{st.label}</span>
|
||||
<span class="tst {st.tone}" title={st.detail}>{st.label}</span>
|
||||
<span class="tactions">
|
||||
{#if job.status === 'failed'}
|
||||
<button type="button" class="link-accent" disabled={busyId === job.id} onclick={() => retry(job)}>
|
||||
|
||||
Reference in New Issue
Block a user