Compare commits

..
Author SHA1 Message Date
60c043db7c fix(HEL-627): 补齐真实工作流参数并修正虚假完成
- 解析 ResolutionSelector 比例/总像素、KSampler/BasicScheduler 降噪,附带 ComfyUI 取值范围
- 已导入的自动识别工作流读取时按最新规则重新解析,无需重导
- 提交前后端校验参数(中文原因),非法值 400 拒绝不入队
- 任务新增 phase / comfy_prompt_id:连接中→已接收(前方排队)→执行中→保存中→完成
- 空产出、队列与历史都丢失、执行中断均判失败;Dry-run 结果标为占位,不算完成
- ComfyUI 缺模型报错写明缺哪个文件
- 生图/生视频/任务页统一用 jobStatusInfo 展示真实状态,结果区支持复用参数

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 23:05:34 +08:00
24 changed files with 1499 additions and 206 deletions
@@ -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)
+107 -4
View File
@@ -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
+10 -1
View File
@@ -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
+17 -2
View File
@@ -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,),
)
+88 -11
View File
@@ -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
+41
View File
@@ -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;
}
+22 -1
View File
@@ -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;
+35
View File
@@ -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.',
};
+35
View File
@@ -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;
+35
View File
@@ -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.',
};
+35
View File
@@ -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.',
};
+35
View File
@@ -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 を呼び出しません。設定でオフにできます。',
};
+35
View File
@@ -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를 호출하지 않습니다. 설정에서 끌 수 있습니다.',
};
+35
View File
@@ -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。可在「设置」中关闭。',
};
+12 -7
View File
@@ -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);
+53
View File
@@ -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'),
};
}
}
+6 -15
View File
@@ -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)}>