Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
545f929d27 | ||
|
|
8d2df11919 | ||
|
|
f5f19f0366 | ||
|
|
bfe7b20b73 | ||
|
|
2ed9d25c24 | ||
|
|
48e16ae443 |
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -18,6 +19,17 @@ logger = logging.getLogger("calliope.comfyui")
|
||||
# bad endpoint hangs every queued job for two minutes before failing.
|
||||
HEALTH_TIMEOUT_SEC = 5.0
|
||||
|
||||
# /object_info fetch is a few MB on big instances; the preflight check
|
||||
# (HEL-642/643) caches it per endpoint for this long. Manual refresh
|
||||
# (`force_refresh=True`) bypasses the TTL.
|
||||
OBJECT_INFO_TTL_SEC = 300.0
|
||||
_object_info_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def clear_object_info_cache() -> None:
|
||||
"""Drop cached /object_info payloads (tests swap fake endpoints in)."""
|
||||
_object_info_cache.clear()
|
||||
|
||||
|
||||
def _surface_error(prefix: str, resp: httpx.Response) -> RuntimeError:
|
||||
"""Raise with ComfyUI's own error body instead of an opaque status line.
|
||||
@@ -88,6 +100,33 @@ class ComfyUIClient:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _cache_key(self) -> str:
|
||||
auth = self._headers.get("Authorization") or next(
|
||||
(v for k, v in self._headers.items() if k.lower() not in ("authorization",)), ""
|
||||
)
|
||||
return f"{self.base_url}|{auth}"
|
||||
|
||||
async def object_info(self, force_refresh: bool = False) -> dict[str, Any]:
|
||||
"""The server's full ``/object_info`` node catalogue, cached per endpoint.
|
||||
|
||||
Cached for OBJECT_INFO_TTL_SEC; ``force_refresh`` re-fetches (the
|
||||
connections page "重新检查" button after the user installs nodes).
|
||||
Raises on transport/HTTP errors so callers can report honestly.
|
||||
"""
|
||||
key = self._cache_key()
|
||||
now = time.monotonic()
|
||||
if not force_refresh:
|
||||
hit = _object_info_cache.get(key)
|
||||
if hit and now - hit[0] < OBJECT_INFO_TTL_SEC:
|
||||
return hit[1]
|
||||
resp = await self._http.get(f"{self.base_url}/object_info", timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("ComfyUI 返回的 /object_info 不是对象")
|
||||
_object_info_cache[key] = (now, data)
|
||||
return data
|
||||
|
||||
async def upload_image(self, path: Path, subfolder: str = "calliope") -> str:
|
||||
data = path.read_bytes()
|
||||
files = {"image": (path.name, data, "application/octet-stream")}
|
||||
|
||||
@@ -281,17 +281,45 @@ def resolution_selector_size(aspect_ratio: str, megapixels: float, multiple: int
|
||||
)
|
||||
|
||||
|
||||
# ComfyUI Autogrow image inputs a studio form fills as one 1…max list.
|
||||
# The workflow wires ``<group>.<prefix>N``; extra images are cloned LoadImage
|
||||
# nodes added at patch time (patcher.py). This registry only beautifies known
|
||||
# classes (label + real max from /object_info); detection itself is generic —
|
||||
# any ``<group>.<prefix>N`` run of image-loader links folds into a group.
|
||||
IMAGE_GROUPS: dict[str, dict[str, dict[str, Any]]] = {
|
||||
# ComfyUI Autogrow media inputs a studio form fills as one 1…max list
|
||||
# (HEL-643 generalizes the original image-only registry to three media
|
||||
# kinds). The workflow wires ``<group>.<prefix>N``; extra items are cloned
|
||||
# loader nodes added at patch time (patcher.py). This registry only
|
||||
# beautifies known classes (label + real max + hint key); detection itself
|
||||
# is generic — any ``<group>.<prefix>N`` run of same-family media-loader
|
||||
# links folds into a group, image / video / audio alike.
|
||||
MEDIA_GROUPS: dict[str, dict[str, dict[str, Any]]] = {
|
||||
"MiniMaxH3ReferenceToVideo": {
|
||||
"ref_images": {"prefix": "ref_image_", "max": 9, "label": "参考图"},
|
||||
"ref_images": {
|
||||
"prefix": "ref_image_",
|
||||
"max": 9,
|
||||
"label": "参考图",
|
||||
"kind": "image",
|
||||
},
|
||||
"ref_videos": {
|
||||
"prefix": "ref_video_",
|
||||
"max": 3,
|
||||
"label": "参考视频",
|
||||
"kind": "video",
|
||||
},
|
||||
"ref_audios": {
|
||||
"prefix": "ref_audio_",
|
||||
"max": 3,
|
||||
"label": "参考音频",
|
||||
"kind": "audio",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Backwards-compatible alias for the original image-only view.
|
||||
IMAGE_GROUPS = MEDIA_GROUPS
|
||||
|
||||
# Consuming-node classes whose groups carry official usage limits worth a
|
||||
# hint in the UI. Purely cosmetic — detection never depends on it.
|
||||
_MEDIA_HINTS: dict[str, str] = {
|
||||
"MiniMaxH3ReferenceToVideo": "minimax_h3_ref2va",
|
||||
}
|
||||
|
||||
# ``ref_images.ref_image_0`` / ``images.image_1`` style Autogrow input names.
|
||||
_GROUP_INPUT_RE = re.compile(
|
||||
r"^(?P<group>[A-Za-z0-9_+-]+)\.(?P<rest>[A-Za-z0-9_+-]+?)(?P<n>\d+)$"
|
||||
@@ -305,9 +333,27 @@ _FRAME_SLOT_ROLES: dict[str, tuple[str, str]] = {
|
||||
"end_image": ("last_frame", "尾帧"),
|
||||
}
|
||||
|
||||
# Group media kind → (schema kind, role, default label, default max).
|
||||
_GROUP_KIND_META: dict[str, tuple[str, str, str, int]] = {
|
||||
"image": ("image_list", "reference_image", "参考图", 9),
|
||||
"video": ("video_list", "reference_video", "参考视频", 9),
|
||||
"audio": ("audio_list", "reference_audio", "参考音频", 9),
|
||||
}
|
||||
|
||||
# Loader class family for each media kind (registry.py sets).
|
||||
_GROUP_SOURCE_CLASSES: dict[str, frozenset[str]] = {
|
||||
"image": IMAGE_CLASSES,
|
||||
"video": VIDEO_CLASSES,
|
||||
"audio": AUDIO_CLASSES,
|
||||
}
|
||||
|
||||
|
||||
def media_group_spec(class_type: str, group: str) -> dict[str, Any] | None:
|
||||
return MEDIA_GROUPS.get(class_type, {}).get(group)
|
||||
|
||||
|
||||
def image_group_spec(class_type: str, group: str) -> dict[str, Any] | None:
|
||||
return IMAGE_GROUPS.get(class_type, {}).get(group)
|
||||
return media_group_spec(class_type, group)
|
||||
|
||||
|
||||
def image_group_prefix(node: dict[str, Any], group: str) -> str | None:
|
||||
@@ -346,14 +392,14 @@ def image_group_slots(
|
||||
|
||||
def _detected_group_runs(
|
||||
workflow: dict[str, Any], node: dict[str, Any]
|
||||
) -> list[tuple[str, str, list[str]]]:
|
||||
"""``[(group, prefix, source LoadImage ids)]`` for generic Autogrow inputs.
|
||||
) -> list[tuple[str, str, list[str], str]]:
|
||||
"""``[(group, prefix, source loader ids, media kind)]`` for Autogrow inputs.
|
||||
|
||||
A run qualifies when the numbers are consecutive, every slot links to an
|
||||
image-loader class, and the run either starts at 0 (single-slot Autogrow
|
||||
exports like H3's ``ref_image_0``) or has ≥2 slots. Single runs numbered
|
||||
from 1 (e.g. TextEncodeQwenImage21's ``images.image_1``) stay ordinary
|
||||
image slots.
|
||||
A run qualifies when the numbers are consecutive, every slot links to a
|
||||
loader of the SAME media family (image / video / audio), and the run
|
||||
either starts at 0 (single-slot Autogrow exports like H3's
|
||||
``ref_image_0``) or has ≥2 slots. Single runs numbered from 1 (e.g.
|
||||
TextEncodeQwenImage21's ``images.image_1``) stay ordinary image slots.
|
||||
"""
|
||||
runs: dict[tuple[str, str], dict[int, Any]] = {}
|
||||
for name, val in (node.get("inputs") or {}).items():
|
||||
@@ -361,7 +407,7 @@ def _detected_group_runs(
|
||||
if not m or not (isinstance(val, list) and len(val) == 2):
|
||||
continue
|
||||
runs.setdefault((m.group("group"), m.group("rest")), {})[int(m.group("n"))] = val
|
||||
out: list[tuple[str, str, list[str]]] = []
|
||||
out: list[tuple[str, str, list[str], str]] = []
|
||||
for (group, prefix), slots in sorted(runs.items()):
|
||||
ns = sorted(slots)
|
||||
if any(b - a != 1 for a, b in zip(ns, ns[1:])):
|
||||
@@ -369,41 +415,57 @@ def _detected_group_runs(
|
||||
if not (ns[0] == 0 or len(ns) >= 2):
|
||||
continue
|
||||
sources: list[str] = []
|
||||
family: str | None = None
|
||||
for n in ns:
|
||||
link = slots[n]
|
||||
src = workflow.get(str(link[0]))
|
||||
if not (isinstance(src, dict) and src.get("class_type") in IMAGE_CLASSES):
|
||||
sources = []
|
||||
if not isinstance(src, dict):
|
||||
family = None
|
||||
break
|
||||
ct = str(src.get("class_type") or "")
|
||||
kinds = [
|
||||
kind
|
||||
for kind, classes in _GROUP_SOURCE_CLASSES.items()
|
||||
if ct in classes
|
||||
]
|
||||
if len(kinds) != 1 or (family is not None and kinds[0] != family):
|
||||
family = None
|
||||
break
|
||||
family = kinds[0]
|
||||
sources.append(str(link[0]))
|
||||
if sources:
|
||||
out.append((group, prefix, sources))
|
||||
if family and sources:
|
||||
out.append((group, prefix, sources, family))
|
||||
return out
|
||||
|
||||
|
||||
def _image_groups(
|
||||
def _media_groups(
|
||||
workflow: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], set[str]]:
|
||||
"""Group entries for Autogrow reference inputs + the LoadImage ids they absorb."""
|
||||
"""Group entries for Autogrow reference inputs (image/video/audio) plus
|
||||
the loader node ids they absorb."""
|
||||
entries: list[dict[str, Any]] = []
|
||||
absorbed: set[str] = set()
|
||||
for nid, node in sorted(
|
||||
((str(k), v) for k, v in workflow.items() if isinstance(v, dict)),
|
||||
key=lambda kv: _node_sort_key(kv[0]),
|
||||
):
|
||||
for group, _prefix, sources in _detected_group_runs(workflow, node):
|
||||
spec = image_group_spec(str(node.get("class_type") or ""), group) or {}
|
||||
class_type = str(node.get("class_type") or "")
|
||||
for group, _prefix, sources, media_kind in _detected_group_runs(workflow, node):
|
||||
spec = media_group_spec(class_type, group) or {}
|
||||
kind, role, default_label, default_max = _GROUP_KIND_META[media_kind]
|
||||
absorbed.update(sources)
|
||||
hint = _MEDIA_HINTS.get(class_type)
|
||||
entries.append(
|
||||
_entry(
|
||||
f"{nid}{FIELD_KEY_SEP}{group}",
|
||||
str(spec.get("label") or "参考图"),
|
||||
"reference_image",
|
||||
"image_list",
|
||||
str(spec.get("label") or default_label),
|
||||
role,
|
||||
kind,
|
||||
None,
|
||||
True,
|
||||
min=1,
|
||||
max=spec.get("max", 9),
|
||||
max=spec.get("max", default_max),
|
||||
media_hint=hint,
|
||||
)
|
||||
)
|
||||
return entries, absorbed
|
||||
@@ -449,7 +511,7 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
)
|
||||
|
||||
texts: list[tuple[str, dict[str, Any], str, bool]] = [] # (id, node, field, negative)
|
||||
groups, grouped_images = _image_groups(workflow)
|
||||
groups, grouped_loaders = _media_groups(workflow)
|
||||
media: list[dict[str, Any]] = []
|
||||
numbers: list[dict[str, Any]] = []
|
||||
taken_roles: set[str] = set()
|
||||
@@ -475,7 +537,7 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
lowered = title.lower()
|
||||
|
||||
if ct in IMAGE_CLASSES or ct in _MASK_IMAGE_CLASSES:
|
||||
if nid in grouped_images:
|
||||
if nid in grouped_loaders:
|
||||
continue
|
||||
is_mask = ct in _MASK_IMAGE_CLASSES or "mask" in lowered or "遮罩" in lowered
|
||||
slot = _image_slot(nid, workflow, consumers)
|
||||
@@ -504,6 +566,10 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
)
|
||||
continue
|
||||
if ct in VIDEO_CLASSES or ct in AUDIO_CLASSES:
|
||||
if nid in grouped_loaders:
|
||||
# Absorbed into an Autogrow media group (HEL-643) — the
|
||||
# group entry owns its upload slots.
|
||||
continue
|
||||
kind = "video" if ct in VIDEO_CLASSES else "audio"
|
||||
field = resolve_patch_field(node)
|
||||
has_default = isinstance(inputs.get(field), str) and bool(inputs.get(field))
|
||||
|
||||
@@ -5,12 +5,44 @@ from __future__ import annotations
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.registry import IMAGE_CLASSES, class_to_patch_field
|
||||
from calliope.comfyui.registry import (
|
||||
AUDIO_CLASSES,
|
||||
IMAGE_CLASSES,
|
||||
VIDEO_CLASSES,
|
||||
VIDEO_FILE_CLASSES,
|
||||
class_to_patch_field,
|
||||
)
|
||||
|
||||
# Input keys of the form "<nodeId>@<widget>" target one named widget on a
|
||||
# node (auto-detected inputs: KSampler seed/steps/cfg, latent width/height…).
|
||||
FIELD_KEY_SEP = "@"
|
||||
|
||||
# Media-loader families and the Chinese slot label used on cloned nodes.
|
||||
_GROUP_LABELS: tuple[tuple[frozenset[str], str], ...] = (
|
||||
(IMAGE_CLASSES, "参考图"),
|
||||
(VIDEO_CLASSES, "参考视频"),
|
||||
(AUDIO_CLASSES, "参考音频"),
|
||||
)
|
||||
|
||||
|
||||
def _loader_label(class_type: str) -> str:
|
||||
for classes, label in _GROUP_LABELS:
|
||||
if class_type in classes:
|
||||
return label
|
||||
return "参考素材"
|
||||
|
||||
|
||||
def _loader_field(class_type: str, inputs: dict[str, Any]) -> str:
|
||||
"""The widget a loader node of this class stores its file value in."""
|
||||
if class_type in IMAGE_CLASSES:
|
||||
return "image"
|
||||
if class_type in VIDEO_CLASSES:
|
||||
return "file" if class_type in VIDEO_FILE_CLASSES else "video"
|
||||
if class_type in AUDIO_CLASSES:
|
||||
# VHS_LoadAudio names its widget "audio:" (with the colon).
|
||||
return "audio" if "audio" in inputs else "audio:"
|
||||
return "image"
|
||||
|
||||
|
||||
def _resolve_field(field: str, inputs: dict[str, Any]) -> str:
|
||||
"""Map the computed patch field onto a key that exists on this node.
|
||||
@@ -43,15 +75,16 @@ def _next_node_id(workflow: dict[str, Any]) -> str:
|
||||
return str(max(numeric, default=0) + 1)
|
||||
|
||||
|
||||
def apply_image_group(
|
||||
def apply_media_group(
|
||||
workflow: dict[str, Any], node_id: str, group: str, paths: list[str]
|
||||
) -> None:
|
||||
"""Rewire an Autogrow image group to exactly ``paths``, in order.
|
||||
"""Rewire an Autogrow media group (image/video/audio) to exactly ``paths``.
|
||||
|
||||
``<group>.<prefix>0 … N-1`` each get their own LoadImage: the workflow's
|
||||
existing ones are reused in slot order, extra ones are clones of the
|
||||
first, and slots/loaders left over from the export are removed so no
|
||||
stale image reaches ComfyUI.
|
||||
``<group>.<prefix>0 … N-1`` each get their own loader node: the
|
||||
workflow's existing ones are reused in slot order, extra ones are clones
|
||||
of the first, and slots/loaders left over from the export are removed so
|
||||
no stale media reaches ComfyUI. File values are local paths —
|
||||
``prepare_media_inputs`` uploads them right before queueing.
|
||||
"""
|
||||
from calliope.comfyui.parser import image_group_prefix, image_group_slots
|
||||
|
||||
@@ -61,7 +94,6 @@ def apply_image_group(
|
||||
prefix = image_group_prefix(node, group)
|
||||
if prefix is None:
|
||||
return
|
||||
label = "参考图"
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
slots = image_group_slots(node, group, prefix)
|
||||
loaders: list[str] = []
|
||||
@@ -69,11 +101,14 @@ def apply_image_group(
|
||||
inputs.pop(name, None)
|
||||
if isinstance(link, list) and len(link) == 2:
|
||||
src = workflow.get(str(link[0]))
|
||||
if isinstance(src, dict) and src.get("class_type") in IMAGE_CLASSES:
|
||||
if isinstance(src, dict) and str(src.get("class_type") or "") in (
|
||||
IMAGE_CLASSES | VIDEO_CLASSES | AUDIO_CLASSES
|
||||
):
|
||||
loaders.append(str(link[0]))
|
||||
if not loaders:
|
||||
return
|
||||
template = copy.deepcopy(workflow[loaders[0]])
|
||||
label = _loader_label(str(template.get("class_type") or ""))
|
||||
|
||||
still_used: set[str] = set()
|
||||
for nid, other in workflow.items():
|
||||
@@ -94,7 +129,8 @@ def apply_image_group(
|
||||
nid = _next_node_id(workflow)
|
||||
loader = copy.deepcopy(template)
|
||||
loader_inputs = dict(loader.get("inputs") or {})
|
||||
loader_inputs["image"] = path
|
||||
field = _loader_field(str(loader.get("class_type") or ""), loader_inputs)
|
||||
loader_inputs[field] = path
|
||||
loader["inputs"] = loader_inputs
|
||||
loader["_meta"] = {**(loader.get("_meta") or {}), "title": f"{label} {i + 1}"}
|
||||
workflow[nid] = loader
|
||||
@@ -102,6 +138,10 @@ def apply_image_group(
|
||||
node["inputs"] = inputs
|
||||
|
||||
|
||||
# Original name kept: image groups were the first (and most common) family.
|
||||
apply_image_group = apply_media_group
|
||||
|
||||
|
||||
def patch_workflow(
|
||||
base: dict[str, Any],
|
||||
values_by_node_id: dict[str, Any],
|
||||
@@ -125,7 +165,7 @@ def patch_workflow(
|
||||
if isinstance(paths, list):
|
||||
clean = [p for p in paths if isinstance(p, str) and p.strip()]
|
||||
if clean:
|
||||
apply_image_group(patched, key, field, clean)
|
||||
apply_media_group(patched, key, field, clean)
|
||||
continue
|
||||
if field is None:
|
||||
field = resolve_patch_field(node)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""L2 connection preflight: check a workflow against a live /object_info (HEL-642/643).
|
||||
|
||||
Layer 1 (lint.py) judges the JSON on its own; this module judges the JSON
|
||||
against what the target ComfyUI actually has installed. Two generic rules,
|
||||
zero hard-coding for any specific workflow or model name:
|
||||
|
||||
1. Node existence — every ``class_type`` in the JSON must exist as a key of
|
||||
the server's /object_info catalogue; missing ones are custom nodes the
|
||||
user has not installed.
|
||||
2. Combo value legality — for every combo input the server declares (an
|
||||
input whose spec carries a candidate list — unet_name/clip_name/
|
||||
sampler/scheduler/…), the JSON's current widget value must be one of the
|
||||
candidates; a miss usually means a model file or component is missing.
|
||||
|
||||
File-upload widgets (LoadImage.image and friends) are skipped: their combo
|
||||
lists are the server's input directory at export time and the value is
|
||||
replaced by an upload before every submit, so a miss there says nothing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from calliope.comfyui.registry import AUDIO_CLASSES, IMAGE_CLASSES, VIDEO_CLASSES
|
||||
|
||||
# Media-loader classes and the widget names that hold their file values —
|
||||
# upload-backed combos that cannot be judged before an upload happens.
|
||||
_MEDIA_LOADER_FIELDS: tuple[frozenset[str], frozenset[str]] = (
|
||||
(IMAGE_CLASSES, frozenset({"image"})),
|
||||
(AUDIO_CLASSES, frozenset({"audio", "audio:"})),
|
||||
(VIDEO_CLASSES, frozenset({"file", "video"})),
|
||||
)
|
||||
|
||||
# object_info widget-config keys that mark an input as file-upload backed.
|
||||
_UPLOAD_CONFIG_KEYS = ("image_upload", "audio_upload", "video_upload", "upload")
|
||||
|
||||
_MAX_CANDIDATES_SHOWN = 6
|
||||
|
||||
|
||||
def _node_title(node: dict[str, Any]) -> str:
|
||||
title = (node.get("_meta") or {}).get("title")
|
||||
return str(title).strip() if title else ""
|
||||
|
||||
|
||||
def _is_upload_backed(class_type: str, input_name: str, config: Any) -> bool:
|
||||
if isinstance(config, dict) and any(config.get(k) for k in _UPLOAD_CONFIG_KEYS):
|
||||
return True
|
||||
for classes, fields in _MEDIA_LOADER_FIELDS:
|
||||
if class_type in classes and input_name in fields:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _combo_candidates(spec: Any) -> list[str] | None:
|
||||
"""The candidate list when ``spec`` is a combo input, else None.
|
||||
|
||||
A combo spec is ``[["a", "b"], {config}]`` — first element a list of
|
||||
plain strings. Typed inputs (``["FLOAT", {…}]``) return None.
|
||||
"""
|
||||
if not (isinstance(spec, list) and len(spec) >= 1):
|
||||
return None
|
||||
values = spec[0]
|
||||
if not isinstance(values, list) or not values:
|
||||
return None
|
||||
if not all(isinstance(v, str) for v in values):
|
||||
return None
|
||||
return values
|
||||
|
||||
|
||||
def check_workflow_against(
|
||||
object_info: dict[str, Any], workflow: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Findings for every problem the target server's catalogue reveals.
|
||||
|
||||
Each finding is ``{level, code, node_id, node_title, message, hint}``
|
||||
(``level`` is always ``"error"`` for now — both rules are hard misses).
|
||||
"""
|
||||
findings: list[dict[str, Any]] = []
|
||||
if not isinstance(object_info, dict) or not isinstance(workflow, dict):
|
||||
return findings
|
||||
for node_id, node in workflow.items():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
class_type = str(node.get("class_type") or "")
|
||||
title = _node_title(node)
|
||||
label = f"节点 {node_id}「{title}」" if title else f"节点 {node_id}"
|
||||
spec = object_info.get(class_type)
|
||||
if not isinstance(spec, dict):
|
||||
findings.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "missing_node",
|
||||
"node_id": str(node_id),
|
||||
"node_title": title,
|
||||
"message": (
|
||||
f"缺少自定义节点 {class_type or '(未知类名)'}({label})——"
|
||||
"该 ComfyUI 未安装此节点,需先安装对应的节点包"
|
||||
),
|
||||
"hint": "安装节点包后回到本页点「重新检查」即可复查,无需重新导入。",
|
||||
}
|
||||
)
|
||||
continue
|
||||
declared = (spec.get("input") or {}) if isinstance(spec.get("input"), dict) else {}
|
||||
inputs = node.get("inputs") or {}
|
||||
for section in ("required", "optional"):
|
||||
entries = declared.get(section)
|
||||
if not isinstance(entries, dict):
|
||||
continue
|
||||
for input_name, in_spec in entries.items():
|
||||
value = inputs.get(input_name)
|
||||
# Wired inputs (links) and absent widgets are not combos to judge.
|
||||
if isinstance(value, (list, dict)) or value is None:
|
||||
continue
|
||||
config = in_spec[1] if isinstance(in_spec, list) and len(in_spec) > 1 else None
|
||||
if _is_upload_backed(class_type, str(input_name), config):
|
||||
continue
|
||||
candidates = _combo_candidates(in_spec)
|
||||
if candidates is None:
|
||||
continue
|
||||
if value in candidates:
|
||||
continue
|
||||
shown = "、".join(candidates[:_MAX_CANDIDATES_SHOWN])
|
||||
if len(candidates) > _MAX_CANDIDATES_SHOWN:
|
||||
shown += f" 等 {len(candidates)} 项"
|
||||
findings.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "value_not_in_list",
|
||||
"node_id": str(node_id),
|
||||
"node_title": title,
|
||||
"message": (
|
||||
f"{label}:值「{value}」不在 ComfyUI 的可选列表"
|
||||
f"(参数 {input_name})——多为对应的模型文件或组件未安装"
|
||||
),
|
||||
"hint": f"该 ComfyUI 上可选:{shown}" if shown else None,
|
||||
}
|
||||
)
|
||||
return findings
|
||||
@@ -63,20 +63,24 @@ def validate_input_values(
|
||||
errors.append(f"「{label}」不能大于 {_fmt(hi)}")
|
||||
continue
|
||||
clean[key] = value
|
||||
elif kind == "image_list":
|
||||
elif kind in ("image_list", "video_list", "audio_list"):
|
||||
paths = [raw] if isinstance(raw, str) else raw
|
||||
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
|
||||
errors.append(f"「{label}」的取值格式不对,请重新上传")
|
||||
continue
|
||||
paths = [p for p in paths if p.strip()]
|
||||
unit = _LIST_UNITS[kind]
|
||||
noun = _LIST_NOUNS[kind]
|
||||
hi = entry.get("max")
|
||||
if hi is not None and len(paths) > hi:
|
||||
errors.append(f"{label}最多 {hi} 张(当前 {len(paths)} 张),请删除多余的图片")
|
||||
errors.append(
|
||||
f"{label}最多 {hi} {unit}(当前 {len(paths)} {unit}),请删除多余的{noun}"
|
||||
)
|
||||
continue
|
||||
bad = [
|
||||
f"{label} {i}:{msg}"
|
||||
for i, p in enumerate(paths, 1)
|
||||
if (msg := _image_file_problem(p))
|
||||
if (msg := _media_file_problem(p, kind))
|
||||
]
|
||||
if bad:
|
||||
errors.extend(bad)
|
||||
@@ -98,10 +102,11 @@ def validate_input_values(
|
||||
has_fallback = str(entry.get("defaultValue") or "").strip() != ""
|
||||
if key not in clean and not has_fallback:
|
||||
errors.append(f"请填写「{label}」")
|
||||
elif required and entry.get("kind") == "image_list":
|
||||
elif required and entry.get("kind") in ("image_list", "video_list", "audio_list"):
|
||||
lo = entry.get("min") or 1
|
||||
unit = _LIST_UNITS[entry.get("kind")]
|
||||
if len(clean.get(key) or []) < lo and not any(label in e for e in errors):
|
||||
errors.append(f"需要至少 {lo} 张{label}")
|
||||
errors.append(f"需要至少 {lo} {unit}{label}")
|
||||
elif required and entry.get("kind") == "image" and entry.get("role") != "mask":
|
||||
if key not in clean:
|
||||
errors.append(f"请上传{label}")
|
||||
@@ -109,19 +114,33 @@ def validate_input_values(
|
||||
|
||||
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".webp"})
|
||||
_VIDEO_EXTS = frozenset({".mp4", ".webm", ".mov"})
|
||||
_AUDIO_EXTS = frozenset({".mp3", ".wav", ".m4a"})
|
||||
_LIST_UNITS = {"image_list": "张", "video_list": "段", "audio_list": "段"}
|
||||
_LIST_NOUNS = {"image_list": "图片", "video_list": "视频", "audio_list": "音频"}
|
||||
_LIST_EXTS = {"image_list": _IMAGE_EXTS, "video_list": _VIDEO_EXTS, "audio_list": _AUDIO_EXTS}
|
||||
_LIST_FORMAT_HINT = {
|
||||
"image_list": "格式不支持,请使用 PNG / JPG / WebP",
|
||||
"video_list": "格式不支持,请使用 MP4 / WebM / MOV",
|
||||
"audio_list": "格式不支持,请使用 MP3 / WAV / M4A",
|
||||
}
|
||||
|
||||
|
||||
def _image_file_problem(path: str) -> str | None:
|
||||
"""Why ``path`` cannot be a studio reference image (None = fine)."""
|
||||
def _media_file_problem(path: str, kind: str) -> str | None:
|
||||
"""Why ``path`` cannot fill a media-list slot of this kind (None = fine)."""
|
||||
from calliope import config
|
||||
|
||||
target = Path(path)
|
||||
if target.suffix.lower() not in _IMAGE_EXTS:
|
||||
return "格式不支持,请使用 PNG / JPG / WebP"
|
||||
if target.suffix.lower() not in _LIST_EXTS[kind]:
|
||||
return _LIST_FORMAT_HINT[kind]
|
||||
try:
|
||||
target.resolve().relative_to(config.settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return "只能使用在本页上传或从生成结果选入的图片"
|
||||
return f"只能使用在本页上传或从生成结果选入的{_LIST_NOUNS[kind]}"
|
||||
if not target.is_file():
|
||||
return "文件已不存在,请重新上传"
|
||||
return None
|
||||
|
||||
|
||||
def _image_file_problem(path: str) -> str | None:
|
||||
return _media_file_problem(path, "image_list")
|
||||
|
||||
@@ -518,6 +518,11 @@ async def migrate_db(db_path: Path) -> None:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN comfy_prompt_id TEXT")
|
||||
if "phase" not in job_cols:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN phase TEXT")
|
||||
# HEL-643: where a job came from — 'user' (every normal generation) or
|
||||
# 'workflow_test' (L3 试运行). Existing rows default to 'user', semantics
|
||||
# unchanged; test jobs get isolated artifacts and no entity write-back.
|
||||
if "origin" not in job_cols:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN origin TEXT NOT NULL DEFAULT 'user'")
|
||||
# Build Scene's per-scene sessions: origin stamps WHERE a blind session was
|
||||
# born ('chat' = user-created in AI Canvas, 'scene' = auto-created by the
|
||||
# Build Scene rail). The session list excludes origin='scene' so scene
|
||||
|
||||
@@ -247,6 +247,23 @@ class WorkflowUpdate(BaseModel):
|
||||
|
||||
class WorkflowAnalyze(BaseModel):
|
||||
workflow_json: dict[str, Any]
|
||||
# HEL-643: optional target connection — given, the analyze response also
|
||||
# carries an L2 connection check (node/model availability via /object_info).
|
||||
endpoint_id: str | None = None
|
||||
# Bypass the /object_info cache (user re-checks after installing nodes).
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class WorkflowCheckRequest(BaseModel):
|
||||
"""L2 re-check of a saved workflow against a live ComfyUI (HEL-643)."""
|
||||
endpoint_id: str | None = None
|
||||
refresh: bool = False
|
||||
|
||||
|
||||
class WorkflowTestRequest(BaseModel):
|
||||
"""Launch one real test run of a workflow (HEL-643 L3)."""
|
||||
endpoint_id: str | None = None
|
||||
input_values: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class JobCreate(BaseModel):
|
||||
|
||||
@@ -36,16 +36,18 @@ class QueueManager:
|
||||
scene_id: int | None = None,
|
||||
clip_id: int | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
origin: str = "user",
|
||||
) -> dict[str, Any]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
payload = self._stamp_endpoint(conn, workflow_id, dict(payload or {}))
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id, status, payload_json)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?)
|
||||
INSERT INTO jobs (project_id, scene_id, clip_id, kind, workflow_id,
|
||||
status, payload_json, origin)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||
""",
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload)),
|
||||
(project_id, scene_id, clip_id, kind, workflow_id, json.dumps(payload), origin),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
|
||||
|
||||
@@ -21,6 +21,11 @@ from calliope.db import get_db
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.export.runner import run_export
|
||||
from calliope.queue.manager import queue_manager
|
||||
from calliope.queue.workflow_test import (
|
||||
TEST_ORIGIN,
|
||||
prune_workflow_tests,
|
||||
workflow_test_dir,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("calliope.worker")
|
||||
|
||||
@@ -164,6 +169,16 @@ class QueueWorker:
|
||||
"message": f"{self._job_label(job)} failed",
|
||||
},
|
||||
)
|
||||
if str(job.get("origin") or "") == TEST_ORIGIN and job.get("workflow_id"):
|
||||
# Retention: keep only the newest records (and their
|
||||
# artifacts) per workflow (HEL-643).
|
||||
try:
|
||||
prune_workflow_tests(int(job["workflow_id"]))
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Pruning workflow-test records for workflow %s failed",
|
||||
job["workflow_id"],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Worker loop error")
|
||||
await asyncio.sleep(config.settings.queue_poll_interval_sec)
|
||||
@@ -173,6 +188,7 @@ class QueueWorker:
|
||||
payload = json.loads(job["payload_json"] or "{}")
|
||||
project_id = job["project_id"]
|
||||
kind = job["kind"]
|
||||
is_test = str(job.get("origin") or "") == TEST_ORIGIN
|
||||
use_dry = bool(config.settings.dry_run)
|
||||
|
||||
if kind == "export":
|
||||
@@ -183,6 +199,13 @@ class QueueWorker:
|
||||
base_url, headers, endpoint_label = self._resolve_endpoint(payload)
|
||||
client = ComfyUIClient(base_url, headers=headers)
|
||||
try:
|
||||
# A workflow test must be a REAL run — a dry-run placeholder that
|
||||
# passes as "测试通过" is exactly the false positive HEL-643 bans.
|
||||
if use_dry and is_test:
|
||||
raise RuntimeError(
|
||||
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
|
||||
"请到「设置」关闭 Dry-run 后重新测试"
|
||||
)
|
||||
if use_dry:
|
||||
queue_manager.set_phase(job["id"], "dry_run")
|
||||
return await self._dry_run(job, payload)
|
||||
@@ -277,7 +300,12 @@ class QueueWorker:
|
||||
"请到「ComfyUI 连接」页修正该节点的媒体类型。"
|
||||
)
|
||||
queue_manager.set_phase(job["id"], "saving")
|
||||
dest_dir = config.settings.assets_dir / str(project_id) / kind
|
||||
if is_test:
|
||||
# Test artifacts live in a scratch dir, isolated from project
|
||||
# media and never written back to entities (HEL-643).
|
||||
dest_dir = workflow_test_dir(int(workflow_id))
|
||||
else:
|
||||
dest_dir = config.settings.assets_dir / str(project_id) / kind
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
paths: list[str] = []
|
||||
for meta in outputs_meta:
|
||||
@@ -291,7 +319,8 @@ class QueueWorker:
|
||||
)
|
||||
paths.append(str(dest))
|
||||
|
||||
self._apply_outputs_to_entities(job, payload, paths)
|
||||
if not is_test:
|
||||
self._apply_outputs_to_entities(job, payload, paths)
|
||||
queue_manager.set_phase(job["id"], "finished")
|
||||
return paths
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Workflow-test job helpers (HEL-643).
|
||||
|
||||
A test job (jobs.origin = 'workflow_test') runs the FULL real pipeline —
|
||||
queue → ComfyUI → outputs — but its artifacts are isolated from project
|
||||
entities: files land under ``assets/_workflow_test/{workflow_id}/`` and are
|
||||
never written back to characters/locations/scenes/clips.
|
||||
|
||||
This module owns the artifact directory, the retention policy (keep the
|
||||
newest N records per workflow) and the purge paths used by "清除测试记录"
|
||||
and workflow deletion.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from calliope import config
|
||||
from calliope.db import get_db
|
||||
|
||||
logger = logging.getLogger("calliope.queue")
|
||||
|
||||
TEST_ORIGIN = "workflow_test"
|
||||
TEST_SOURCE = "workflow_test"
|
||||
# Records (and artifacts) kept per workflow before older ones are pruned.
|
||||
TEST_KEEP = 5
|
||||
|
||||
|
||||
def workflow_test_dir(workflow_id: int) -> Path:
|
||||
return config.settings.assets_dir / "_workflow_test" / str(workflow_id)
|
||||
|
||||
|
||||
def _safe_unlink(path_str: str) -> bool:
|
||||
"""Delete a file if it resolves under assets_dir (missing = already gone)."""
|
||||
try:
|
||||
target = Path(path_str).resolve()
|
||||
target.relative_to(config.settings.assets_dir.resolve())
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
if not target.is_file():
|
||||
return False
|
||||
try:
|
||||
target.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _unlink_job_outputs(job: dict[str, Any]) -> None:
|
||||
try:
|
||||
paths = json.loads(job.get("output_paths_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
if not isinstance(paths, list):
|
||||
return
|
||||
for p in paths:
|
||||
if isinstance(p, str) and p.strip():
|
||||
_safe_unlink(p)
|
||||
|
||||
|
||||
def _remove_test_dir_if_empty(workflow_id: int) -> None:
|
||||
d = workflow_test_dir(workflow_id)
|
||||
try:
|
||||
if d.is_dir() and not any(d.iterdir()):
|
||||
d.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _test_job_public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(job)
|
||||
out["payload"] = json.loads(job.get("payload_json") or "{}")
|
||||
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
|
||||
out.pop("payload_json", None)
|
||||
out.pop("output_paths_json", None)
|
||||
return out
|
||||
|
||||
|
||||
def list_test_jobs(workflow_id: int, limit: int = 20) -> list[dict[str, Any]]:
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" ORDER BY id DESC LIMIT ?",
|
||||
(workflow_id, TEST_ORIGIN, limit),
|
||||
).fetchall()
|
||||
return [_test_job_public(dict(r)) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def find_active_test_job(workflow_id: int, endpoint_id: str | None) -> dict[str, Any] | None:
|
||||
"""The unfinished test job for (workflow, endpoint), if one exists.
|
||||
|
||||
Idempotency guard for the "测试工作流" button: a second click re-uses the
|
||||
in-flight job instead of enqueuing a duplicate.
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" AND status IN ('pending', 'running') ORDER BY id DESC",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
for r in rows:
|
||||
job = dict(r)
|
||||
try:
|
||||
payload = json.loads(job.get("payload_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
payload = {}
|
||||
snap = payload.get("endpoint") or {}
|
||||
if not isinstance(snap, dict):
|
||||
snap = {}
|
||||
if (snap.get("id") or None) == (endpoint_id or None):
|
||||
return _test_job_public(job)
|
||||
return None
|
||||
|
||||
|
||||
def prune_workflow_tests(workflow_id: int, keep: int = TEST_KEEP) -> int:
|
||||
"""Keep only the newest ``keep`` test records (older rows + files go)."""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" ORDER BY id DESC LIMIT -1 OFFSET ?",
|
||||
(workflow_id, TEST_ORIGIN, max(keep, 0)),
|
||||
).fetchall()
|
||||
removed = 0
|
||||
for r in rows:
|
||||
job = dict(r)
|
||||
if job.get("status") in ("pending", "running"):
|
||||
continue
|
||||
_unlink_job_outputs(job)
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (job["id"],))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_remove_test_dir_if_empty(workflow_id)
|
||||
if removed:
|
||||
logger.info("Pruned %s old workflow-test records for workflow %s", removed, workflow_id)
|
||||
return removed
|
||||
|
||||
|
||||
def purge_workflow_tests(workflow_id: int, *, allow_active: bool = False) -> int:
|
||||
"""Delete EVERY test record and artifact for a workflow.
|
||||
|
||||
Returns the removed row count; raises RuntimeError when an unfinished
|
||||
test job is still in flight (unless ``allow_active``).
|
||||
"""
|
||||
conn = get_db(config.settings.db_path)
|
||||
try:
|
||||
if not allow_active:
|
||||
active = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM jobs WHERE workflow_id = ? AND origin = ?"
|
||||
" AND status IN ('pending', 'running')",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchone()["n"]
|
||||
if active:
|
||||
raise RuntimeError(
|
||||
f"该工作流还有 {active} 个进行中的测试任务,请先等待完成或取消,再清除测试记录"
|
||||
)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE workflow_id = ? AND origin = ?",
|
||||
(workflow_id, TEST_ORIGIN),
|
||||
).fetchall()
|
||||
removed = 0
|
||||
for r in rows:
|
||||
_unlink_job_outputs(dict(r))
|
||||
conn.execute("DELETE FROM jobs WHERE id = ?", (r["id"],))
|
||||
removed += 1
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
shutil.rmtree(workflow_test_dir(workflow_id), ignore_errors=True)
|
||||
return removed
|
||||
@@ -197,7 +197,14 @@ async def list_playground_jobs(limit: int = 50) -> list[dict[str, Any]]:
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
return [_job_public(j) for j in queue_manager.list_jobs(pid, None, limit)]
|
||||
# Workflow-test jobs (HEL-643) ride the same scratch project but must not
|
||||
# surface in the studio windows' history — they carry their own dialog.
|
||||
jobs = [
|
||||
j
|
||||
for j in queue_manager.list_jobs(pid, None, limit)
|
||||
if str(j.get("origin") or "user") != "workflow_test"
|
||||
]
|
||||
return [_job_public(j) for j in jobs]
|
||||
|
||||
|
||||
@router.delete("/jobs/{job_id}")
|
||||
|
||||
@@ -5,7 +5,8 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from calliope.comfyui.lint import workflow_warnings
|
||||
from calliope.comfyui.endpoints import client_for, endpoint_snapshot
|
||||
from calliope.comfyui.lint import blocking_problem, workflow_warnings
|
||||
from calliope.comfyui.output_map import (
|
||||
auto_output_map,
|
||||
load_output_map,
|
||||
@@ -19,11 +20,27 @@ from calliope.comfyui.parser import (
|
||||
parse_dynamic_inputs,
|
||||
parse_dynamic_outputs,
|
||||
)
|
||||
from calliope.comfyui.preflight import check_workflow_against
|
||||
from calliope.comfyui.profiles import detect_prompt_profile
|
||||
from calliope.comfyui.roles import usage_to_kind
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from calliope.db import get_db, row_to_dict
|
||||
from calliope.models.schemas import WorkflowAnalyze, WorkflowCreate, WorkflowUpdate
|
||||
from calliope.events.bus import event_bus
|
||||
from calliope.models.schemas import (
|
||||
WorkflowAnalyze,
|
||||
WorkflowCheckRequest,
|
||||
WorkflowCreate,
|
||||
WorkflowTestRequest,
|
||||
WorkflowUpdate,
|
||||
)
|
||||
from calliope.queue.manager import queue_manager
|
||||
from calliope.queue.workflow_test import (
|
||||
TEST_SOURCE,
|
||||
find_active_test_job,
|
||||
list_test_jobs,
|
||||
purge_workflow_tests,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -47,12 +64,53 @@ def _serialize_workflow(row: Any) -> dict[str, Any]:
|
||||
return data
|
||||
|
||||
|
||||
async def _connection_check(
|
||||
endpoint_id: str | None, workflow_json: dict[str, Any], refresh: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""L2 preflight (HEL-643): workflow vs the target ComfyUI's /object_info.
|
||||
|
||||
An unreachable connection degrades to ``reachable: False`` with an honest
|
||||
「无法核查」 message — it never blocks and never pretends to pass.
|
||||
"""
|
||||
endpoint = settings.resolve_comfyui_endpoint(endpoint_id)
|
||||
if endpoint is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"endpoint_id": endpoint_id,
|
||||
"endpoint_name": None,
|
||||
"findings": [],
|
||||
"message": "没有可用的 ComfyUI 连接,无法核查节点与模型",
|
||||
}
|
||||
base = {
|
||||
"endpoint_id": endpoint.get("id"),
|
||||
"endpoint_name": endpoint.get("name"),
|
||||
"base_url": endpoint.get("base_url"),
|
||||
}
|
||||
client = client_for(endpoint)
|
||||
try:
|
||||
object_info = await client.object_info(force_refresh=refresh)
|
||||
except Exception as exc:
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"reachable": False,
|
||||
"findings": [],
|
||||
"message": f"无法核查(连不上该 ComfyUI):{type(exc).__name__}",
|
||||
}
|
||||
finally:
|
||||
await client.close()
|
||||
findings = check_workflow_against(object_info, workflow_json)
|
||||
ok = not any(f.get("level") == "error" for f in findings)
|
||||
return {**base, "ok": ok, "reachable": True, "findings": findings}
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
|
||||
inputs = parse_dynamic_inputs(payload.workflow_json)
|
||||
outputs = parse_dynamic_outputs(payload.workflow_json)
|
||||
judgment = classify_usage(payload.workflow_json)
|
||||
return {
|
||||
result: dict[str, Any] = {
|
||||
"inputs": inputs,
|
||||
"outputs": outputs,
|
||||
"suggested_profile": detect_prompt_profile(payload.workflow_json),
|
||||
@@ -64,6 +122,13 @@ async def analyze_workflow(payload: WorkflowAnalyze) -> dict[str, Any]:
|
||||
"usage_evidence": judgment["evidence"],
|
||||
"usable_usage": judgment["usable_usage"],
|
||||
}
|
||||
# L2 (optional): with a target connection the analyze response also
|
||||
# reports which nodes/models that ComfyUI actually has (HEL-643).
|
||||
if payload.endpoint_id:
|
||||
result["connection_check"] = await _connection_check(
|
||||
payload.endpoint_id, payload.workflow_json, payload.refresh
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _stored_schema(payload_schema: list[dict[str, Any]] | None, workflow: dict[str, Any]) -> str:
|
||||
@@ -133,16 +198,64 @@ async def reanalyze_workflow(workflow_id: int) -> dict[str, Any]:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _last_test_summary(job: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Slim "上次测试" record for a workflow row (queried live, never stored)."""
|
||||
try:
|
||||
outputs = json.loads(job.get("output_paths_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
outputs = []
|
||||
return {
|
||||
"id": job["id"],
|
||||
"status": job.get("status"),
|
||||
"phase": job.get("phase"),
|
||||
"error": job.get("error"),
|
||||
"created_at": job.get("created_at"),
|
||||
"completed_at": job.get("completed_at"),
|
||||
"output_count": len(outputs) if isinstance(outputs, list) else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_workflows() -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
rows = conn.execute("SELECT * FROM workflows ORDER BY id DESC").fetchall()
|
||||
return [_serialize_workflow(r) for r in rows]
|
||||
out = [_serialize_workflow(r) for r in rows]
|
||||
# 上次测试 state is read live from the newest test job per workflow —
|
||||
# no cached column that could go stale (HEL-643).
|
||||
latest: dict[int, dict[str, Any]] = {}
|
||||
for jr in conn.execute(
|
||||
"SELECT * FROM jobs WHERE origin = 'workflow_test' AND id IN"
|
||||
" (SELECT MAX(id) FROM jobs WHERE origin = 'workflow_test' GROUP BY workflow_id)"
|
||||
).fetchall():
|
||||
latest[int(jr["workflow_id"])] = _last_test_summary(dict(jr))
|
||||
for data in out:
|
||||
data["last_test"] = latest.get(int(data["id"]))
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/check")
|
||||
async def check_workflow(workflow_id: int, payload: WorkflowCheckRequest) -> dict[str, Any]:
|
||||
"""L2 re-check of a saved workflow against its (or a chosen) connection.
|
||||
|
||||
The user installs missing nodes/models in ComfyUI, comes back here and
|
||||
re-checks — no re-import needed.
|
||||
"""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT workflow_json, endpoint_id FROM workflows WHERE id = ?", (workflow_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
endpoint_id = payload.endpoint_id or row["endpoint_id"]
|
||||
return await _connection_check(endpoint_id, json.loads(row["workflow_json"]), payload.refresh)
|
||||
|
||||
|
||||
@router.post("/dedupe")
|
||||
async def dedupe_workflows() -> dict[str, Any]:
|
||||
"""Delete byte-identical duplicate workflows, keeping the lowest id.
|
||||
@@ -341,6 +454,119 @@ async def update_workflow(workflow_id: int, payload: WorkflowUpdate) -> dict[str
|
||||
conn.close()
|
||||
|
||||
|
||||
def _job_public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(job)
|
||||
out["payload"] = json.loads(job.get("payload_json") or "{}")
|
||||
out["output_paths"] = json.loads(job.get("output_paths_json") or "[]")
|
||||
out.pop("payload_json", None)
|
||||
out.pop("output_paths_json", None)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/test")
|
||||
async def test_workflow(workflow_id: int, payload: WorkflowTestRequest) -> dict[str, Any]:
|
||||
"""Launch (or re-attach to) one REAL test run of a workflow (HEL-643 L3).
|
||||
|
||||
The job goes through the ordinary queue → ComfyUI → outputs pipeline;
|
||||
only its artifacts and bookkeeping are isolated (origin='workflow_test').
|
||||
A dry-run setting is refused outright — placeholder output must never
|
||||
count as a test pass. Repeat clicks reuse the in-flight job.
|
||||
"""
|
||||
from calliope import config
|
||||
|
||||
if config.settings.dry_run:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"测试工作流必须真实运行:当前设置开启了 Dry-run(占位模式),"
|
||||
"请到「设置」关闭 Dry-run 后再测试"
|
||||
),
|
||||
)
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
wf = conn.execute(
|
||||
"SELECT id, kind, workflow_json, input_schema, output_map, output_map_source,"
|
||||
" endpoint_id FROM workflows WHERE id = ?",
|
||||
(workflow_id,),
|
||||
).fetchone()
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
workflow_json = json.loads(wf["workflow_json"])
|
||||
broken = blocking_problem(workflow_json)
|
||||
if broken:
|
||||
raise HTTPException(status_code=400, detail=broken)
|
||||
map_problem = mapping_problem(workflow_json, wf["output_map"], wf["output_map_source"])
|
||||
if map_problem:
|
||||
raise HTTPException(status_code=400, detail=map_problem)
|
||||
schema = effective_input_schema(
|
||||
json.loads(wf["input_schema"]) if wf["input_schema"] else [], workflow_json
|
||||
)
|
||||
input_values, problems = validate_input_values(schema, payload.input_values)
|
||||
if problems:
|
||||
raise HTTPException(status_code=400, detail="参数有误:" + ";".join(problems))
|
||||
|
||||
endpoint_id = payload.endpoint_id or wf["endpoint_id"] or None
|
||||
snapshot = endpoint_snapshot(endpoint_id)
|
||||
active = find_active_test_job(workflow_id, (snapshot or {}).get("id"))
|
||||
if active is not None:
|
||||
return {"ok": True, "job": _job_public(active), "reused": True}
|
||||
|
||||
from calliope.routers.playground import ensure_playground_project
|
||||
|
||||
pid = ensure_playground_project(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
job = queue_manager.enqueue(
|
||||
project_id=pid,
|
||||
kind=wf["kind"],
|
||||
workflow_id=workflow_id,
|
||||
scene_id=None,
|
||||
payload={
|
||||
"input_values": input_values,
|
||||
"source": TEST_SOURCE,
|
||||
**({"endpoint_id": payload.endpoint_id} if payload.endpoint_id else {}),
|
||||
},
|
||||
origin="workflow_test",
|
||||
)
|
||||
await event_bus.publish(
|
||||
"job.created",
|
||||
{"job_id": job["id"], "kind": job["kind"], "source": TEST_SOURCE,
|
||||
"project_id": job["project_id"], "workflow_test": True},
|
||||
)
|
||||
return {"ok": True, "job": _job_public(job), "reused": False}
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/tests")
|
||||
async def list_workflow_tests(workflow_id: int) -> list[dict[str, Any]]:
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
return list_test_jobs(workflow_id)
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/clear-tests")
|
||||
async def clear_workflow_tests(workflow_id: int) -> dict[str, Any]:
|
||||
"""Delete every test record and artifact for this workflow."""
|
||||
conn = get_db(settings.db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT id FROM workflows WHERE id = ?", (workflow_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
try:
|
||||
removed = purge_workflow_tests(workflow_id)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
await event_bus.publish("job.deleted", {"workflow_id": workflow_id, "source": TEST_SOURCE})
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}")
|
||||
async def delete_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
conn = get_db(settings.db_path)
|
||||
@@ -366,6 +592,9 @@ async def delete_workflow(workflow_id: int) -> dict[str, bool]:
|
||||
"请先等任务完成或取消任务,再删除该工作流"
|
||||
),
|
||||
)
|
||||
# Test records/artifacts belong to the workflow — cascade (HEL-643).
|
||||
# The 409 above guarantees none are in flight here.
|
||||
purge_workflow_tests(workflow_id, allow_active=True)
|
||||
conn.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""媒体组泛化(HEL-643 批次 3):参考视频/参考音频按 JSON 实际连线进入
|
||||
input_schema 与提交流程;无连线则无入口;05_ref2va 只保留参考图。
|
||||
|
||||
视频/音频参考的真实 H3 JSON 暂无可执行样本(.15 无 ComfyUI),本套用
|
||||
自造 fixture + fake ComfyUI 验证解析/表单/回填/上传全链路(模拟链路)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.parser import parse_dynamic_inputs
|
||||
from calliope.comfyui.patcher import patch_workflow
|
||||
from calliope.comfyui.validate import validate_input_values
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import PNG_1PX, _real_run
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uploads_dir(client):
|
||||
d = settings.assets_dir / "uploads"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _load_05() -> dict:
|
||||
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _ref2va_workflow(*, images=1, videos=1, audios=1) -> dict:
|
||||
"""H3 Ref2VA 形状的自造 JSON:图/视频/音频各一组 Autogrow 连线。"""
|
||||
wf: dict = {
|
||||
"1": {
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"inputs": {"value": "提示词"},
|
||||
},
|
||||
"92": {
|
||||
"class_type": "SaveVideo",
|
||||
"inputs": {"video": ["131", 0]},
|
||||
},
|
||||
"131": {
|
||||
"class_type": "MiniMaxH3ReferenceToVideo",
|
||||
"inputs": {"prompt": ["1", 0]},
|
||||
},
|
||||
"901": {"class_type": "LoadImage", "inputs": {"image": "ref.png"}},
|
||||
"902": {"class_type": "LoadVideo", "inputs": {"file": "ref.mp4", "force_rate": 0}},
|
||||
"903": {"class_type": "LoadAudio", "inputs": {"audio": "ref.wav"}},
|
||||
}
|
||||
ins = wf["131"]["inputs"]
|
||||
for i in range(images):
|
||||
ins[f"ref_images.ref_image_{i}"] = ["901", 0]
|
||||
for i in range(videos):
|
||||
ins[f"ref_videos.ref_video_{i}"] = ["902", 0]
|
||||
for i in range(audios):
|
||||
ins[f"ref_audios.ref_audio_{i}"] = ["903", 0]
|
||||
return wf
|
||||
|
||||
|
||||
def _entry(schema: list[dict], key_suffix: str) -> dict | None:
|
||||
for e in schema:
|
||||
if e["nodeId"].endswith("@" + key_suffix):
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema: 入口只由实际连线决定
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ref2va_schema_exposes_three_media_groups():
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow())
|
||||
img = _entry(schema, "ref_images")
|
||||
vid = _entry(schema, "ref_videos")
|
||||
aud = _entry(schema, "ref_audios")
|
||||
assert img and img["kind"] == "image_list" and img["role"] == "reference_image"
|
||||
assert img["max"] == 9 and img["label"] == "参考图"
|
||||
assert vid and vid["kind"] == "video_list" and vid["role"] == "reference_video"
|
||||
assert vid["max"] == 3 and vid["label"] == "参考视频"
|
||||
assert aud and aud["kind"] == "audio_list" and aud["role"] == "reference_audio"
|
||||
assert aud["max"] == 3 and aud["label"] == "参考音频"
|
||||
# 官方限额提示按节点类名驱动(通用检测不携带 hint)
|
||||
assert img["media_hint"] == vid["media_hint"] == aud["media_hint"] == "minimax_h3_ref2va"
|
||||
# 被组吸收的 loader 不再单独出现
|
||||
assert not any(e["nodeId"] == "902" for e in schema)
|
||||
assert not any(e["nodeId"] == "903" for e in schema)
|
||||
|
||||
|
||||
def test_unwired_media_kinds_produce_no_entry():
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow(videos=0, audios=0))
|
||||
assert _entry(schema, "ref_images") is not None
|
||||
assert _entry(schema, "ref_videos") is None
|
||||
assert _entry(schema, "ref_audios") is None
|
||||
|
||||
|
||||
def test_generic_class_gets_group_without_hint():
|
||||
wf = _ref2va_workflow(videos=0, audios=0)
|
||||
wf["20"] = {"class_type": "GenericRefNode", "inputs": {}}
|
||||
wf["701"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "a.mp4"}}
|
||||
wf["702"] = {"class_type": "VHS_LoadVideo", "inputs": {"video": "b.mp4"}}
|
||||
wf["20"]["inputs"]["media.clip_0"] = ["701", 0]
|
||||
wf["20"]["inputs"]["media.clip_1"] = ["702", 0]
|
||||
schema = parse_dynamic_inputs(wf)
|
||||
g = _entry(schema, "media")
|
||||
assert g and g["kind"] == "video_list" and g["max"] == 9
|
||||
assert "media_hint" not in g
|
||||
|
||||
|
||||
def test_05_ref2va_fixture_stays_image_only():
|
||||
schema = parse_dynamic_inputs(_load_05())
|
||||
lists = [e for e in schema if e["kind"].endswith("_list")]
|
||||
assert len(lists) == 1
|
||||
assert lists[0]["kind"] == "image_list" and lists[0]["max"] == 9
|
||||
assert lists[0]["nodeId"] == "131@ref_images"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _media_file(path: str, data: bytes) -> str:
|
||||
p = Path(path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(data)
|
||||
return str(p)
|
||||
|
||||
|
||||
def test_validate_video_and_audio_lists(uploads_dir):
|
||||
schema = parse_dynamic_inputs(_ref2va_workflow())
|
||||
img_key = _entry(schema, "ref_images")["nodeId"]
|
||||
vid_key = _entry(schema, "ref_videos")["nodeId"]
|
||||
aud_key = _entry(schema, "ref_audios")["nodeId"]
|
||||
png = _media_file(str(uploads_dir / "ok.png"), PNG_1PX)
|
||||
mp4 = _media_file(str(uploads_dir / "ok.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "ok.mp3"), b"00")
|
||||
|
||||
clean, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == [] and clean[vid_key] == [mp4] and clean[aud_key] == [mp3]
|
||||
|
||||
# 错误格式(图片文件填视频槽 / 视频文件填音频槽)
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [png], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["参考视频 1:格式不支持,请使用 MP4 / WebM / MOV"]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: [mp4], aud_key: [mp4]}
|
||||
)
|
||||
assert errs == ["参考音频 1:格式不支持,请使用 MP3 / WAV / M4A"]
|
||||
|
||||
# 上限(H3 官方 3 段)与最少 1 段
|
||||
four = [_media_file(str(uploads_dir / f"v{i}.mp4"), b"00") for i in range(4)]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], vid_key: four, aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["参考视频最多 3 段(当前 4 段),请删除多余的视频"]
|
||||
_, errs = validate_input_values(
|
||||
schema, {"1": "p", img_key: [png], aud_key: [mp3]}
|
||||
)
|
||||
assert errs == ["需要至少 1 段参考视频"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patcher + full chain against fake ComfyUI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patcher_clones_and_backfills_each_media_kind(uploads_dir):
|
||||
wf = _ref2va_workflow()
|
||||
schema = parse_dynamic_inputs(wf)
|
||||
pngs = [_media_file(str(uploads_dir / "r1.png"), PNG_1PX),
|
||||
_media_file(str(uploads_dir / "r2.png"), PNG_1PX)]
|
||||
mp4 = _media_file(str(uploads_dir / "v1.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "a1.mp3"), b"00")
|
||||
patched = patch_workflow(
|
||||
wf,
|
||||
{
|
||||
_entry(schema, "ref_images")["nodeId"]: pngs,
|
||||
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
||||
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
||||
},
|
||||
)
|
||||
ins = patched["131"]["inputs"]
|
||||
image_links = [v for k, v in ins.items() if k.startswith("ref_images.")]
|
||||
video_links = [v for k, v in ins.items() if k.startswith("ref_videos.")]
|
||||
audio_links = [v for k, v in ins.items() if k.startswith("ref_audios.")]
|
||||
assert len(image_links) == 2 and len(video_links) == 1 and len(audio_links) == 1
|
||||
loaders = {nid: n for nid, n in patched.items()
|
||||
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")}
|
||||
imgs = [n for n in loaders.values() if n["class_type"] == "LoadImage"]
|
||||
vids = [n for n in loaders.values() if n["class_type"] == "LoadVideo"]
|
||||
auds = [n for n in loaders.values() if n["class_type"] == "LoadAudio"]
|
||||
assert len(imgs) == 2 and all(n["inputs"]["image"] in pngs for n in imgs)
|
||||
assert vids and vids[0]["inputs"]["file"] == mp4
|
||||
assert auds and auds[0]["inputs"]["audio"] == mp3
|
||||
# 克隆出的节点带中文槽位标题
|
||||
assert {n["_meta"]["title"] for n in imgs} == {"参考图 1", "参考图 2"}
|
||||
assert vids[0]["_meta"]["title"] == "参考视频 1"
|
||||
assert auds[0]["_meta"]["title"] == "参考音频 1"
|
||||
|
||||
|
||||
def test_full_chain_uploads_and_backfills_media_groups(client, fake_comfy, uploads_dir):
|
||||
"""提交 → 上传 → 回填:fake ComfyUI 收到的 prompt 里每类 loader 都
|
||||
指向已上传文件(模拟链路:fake 端点,非真机 H3)。"""
|
||||
wf = _ref2va_workflow()
|
||||
png = _media_file(str(uploads_dir / "ref.png"), PNG_1PX)
|
||||
mp4 = _media_file(str(uploads_dir / "ref.mp4"), b"00")
|
||||
mp3 = _media_file(str(uploads_dir / "ref.mp3"), b"00")
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": "媒体组链路", "workflow_json": wf, "usage": "ref2video"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
wf_id = r.json()["id"]
|
||||
schema = r.json()["input_schema"]
|
||||
|
||||
with _real_run(client, fake_comfy):
|
||||
fake_comfy.out_name = "media_out.mp4"
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test",
|
||||
json={
|
||||
"input_values": {
|
||||
_entry(schema, "ref_images")["nodeId"]: [png],
|
||||
_entry(schema, "ref_videos")["nodeId"]: [mp4],
|
||||
_entry(schema, "ref_audios")["nodeId"]: [mp3],
|
||||
}
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
job_id = r.json()["job"]["id"]
|
||||
|
||||
deadline = time.time() + 25
|
||||
job = {}
|
||||
while time.time() < deadline:
|
||||
job = client.get(f"/api/jobs/{job_id}").json()
|
||||
if job["status"] not in ("pending", "running"):
|
||||
break
|
||||
time.sleep(0.4)
|
||||
assert job["status"] == "done", job.get("error")
|
||||
|
||||
assert len(fake_comfy.uploads) >= 3 # 图、视频、音频各一次
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
loaders = [n for n in queued.values()
|
||||
if n.get("class_type") in ("LoadImage", "LoadVideo", "LoadAudio")]
|
||||
by_field = {
|
||||
(n["class_type"], k): v
|
||||
for n in loaders
|
||||
for k, v in n["inputs"].items()
|
||||
if isinstance(v, str) and "uploaded-input" in v
|
||||
}
|
||||
assert ("LoadImage", "image") in by_field
|
||||
assert ("LoadVideo", "file") in by_field
|
||||
assert ("LoadAudio", "audio") in by_field
|
||||
# 组连线数与提交数一致
|
||||
ins = queued["131"]["inputs"]
|
||||
assert len([k for k in ins if k.startswith("ref_images.")]) == 1
|
||||
assert len([k for k in ins if k.startswith("ref_videos.")]) == 1
|
||||
assert len([k for k in ins if k.startswith("ref_audios.")]) == 1
|
||||
@@ -50,6 +50,9 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
},
|
||||
)
|
||||
return
|
||||
if self.path == "/object_info":
|
||||
self._json(200, dict(getattr(self.server, "object_info", None) or {}))
|
||||
return
|
||||
if self.path.startswith("/history/"):
|
||||
pid = self.path[len("/history/") :]
|
||||
entry = self.server.history.get(pid)
|
||||
@@ -78,9 +81,13 @@ class _FakeComfyHandler(BaseHTTPRequestHandler):
|
||||
{"filename": self.server.out_name, "subfolder": "", "type": "output"}
|
||||
]
|
||||
}
|
||||
outputs = (
|
||||
{} if getattr(self.server, "drop_outputs", False) else
|
||||
{self._output_node(body): out_entry}
|
||||
)
|
||||
self.server.history[prompt_id] = {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": {self._output_node(body): out_entry},
|
||||
"outputs": outputs,
|
||||
"prompt": [],
|
||||
}
|
||||
self.server.prompts.append(json.loads(body.decode("utf-8") or "{}"))
|
||||
@@ -135,6 +142,8 @@ def fake_comfy():
|
||||
server.out_name = "out-1.png"
|
||||
server.outputs_key = "images"
|
||||
server.forced_output_node = None
|
||||
server.object_info = {}
|
||||
server.drop_outputs = False
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""L2 连接核查(HEL-643 批次 1):导入/复查时用目标 ComfyUI 的 /object_info
|
||||
预检节点与模型可用性。全部基于 fake ComfyUI 的脚本化 /object_info,
|
||||
不针对任何固定 JSON 或模型名写死判定。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from calliope.comfyui.client import clear_object_info_cache
|
||||
from calliope.comfyui.preflight import check_workflow_against
|
||||
from calliope.config import settings
|
||||
from tests.test_studio_generation import _url
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "comfy_native"
|
||||
|
||||
|
||||
def _load_05() -> dict:
|
||||
return json.loads((FIXTURES / "h3_ref2va_05_API.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def build_object_info(workflow: dict) -> dict:
|
||||
"""A catalogue where every widget value the JSON carries is legal.
|
||||
|
||||
String widgets become combos whose candidates include the workflow's own
|
||||
value (merged across nodes of the same class); numbers/booleans are typed
|
||||
inputs; wired inputs are link types — none of those can mismatch.
|
||||
"""
|
||||
info: dict = {}
|
||||
for node in workflow.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
ct = str(node.get("class_type") or "")
|
||||
if not ct:
|
||||
continue
|
||||
spec = info.setdefault(ct, {"input": {"required": {}}, "output": ["*"]})
|
||||
required = spec["input"]["required"]
|
||||
for name, val in (node.get("inputs") or {}).items():
|
||||
if isinstance(val, str):
|
||||
# Merge: two nodes of one class with different values → both legal.
|
||||
if name in required and isinstance(required[name][0], list):
|
||||
if val not in required[name][0]:
|
||||
required[name][0] = [*required[name][0], val]
|
||||
continue
|
||||
required[name] = [[val], {}]
|
||||
elif name in required:
|
||||
continue
|
||||
elif isinstance(val, list):
|
||||
required[name] = ["*", {}]
|
||||
elif isinstance(val, bool):
|
||||
required[name] = ["BOOLEAN", {}]
|
||||
elif isinstance(val, (int, float)):
|
||||
required[name] = ["NUMBER", {}]
|
||||
return info
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_cache():
|
||||
clear_object_info_cache()
|
||||
yield
|
||||
clear_object_info_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def endpoint_at(client, fake_comfy): # noqa: F811
|
||||
"""Register a connection pointing at the fake server; yields its id."""
|
||||
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
||||
prev_active = settings.comfyui_active_id
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "预检桩", "base_url": _url(fake_comfy), "auth_type": "none"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
endpoints = r.json()["endpoints"]
|
||||
ep = next(e for e in endpoints if e["base_url"] == _url(fake_comfy))
|
||||
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["base_url"] == _url(fake_comfy)]
|
||||
settings.comfyui_active_id = ep["id"]
|
||||
settings.apply_active_comfyui()
|
||||
try:
|
||||
yield ep["id"]
|
||||
finally:
|
||||
settings.comfyui_endpoints = prev_eps
|
||||
settings.comfyui_active_id = prev_active
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unit_missing_node_reported_with_node_context():
|
||||
wf = {"900": {"class_type": "H3SLAAttention", "inputs": {"enabled": True},
|
||||
"_meta": {"title": "H3 SLA Attention"}}}
|
||||
findings = check_workflow_against({"OtherNode": {}}, wf)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f["code"] == "missing_node" and f["level"] == "error"
|
||||
msg = f["message"]
|
||||
assert "H3SLAAttention" in msg and "900" in msg and "H3 SLA Attention" in msg
|
||||
assert "重新检查" in f["hint"]
|
||||
|
||||
|
||||
def test_unit_combo_value_not_in_list():
|
||||
wf = {"127": {"class_type": "UNETLoader",
|
||||
"inputs": {"unet_name": "missing_model.safetensors", "weight_dtype": "default"}}}
|
||||
info = {"UNETLoader": {"input": {"required": {
|
||||
"unet_name": [["installed_model.safetensors"], {}],
|
||||
"weight_dtype": [["default", "fp8"], {"upload": False}],
|
||||
}}, "output": ["MODEL"]}}
|
||||
findings = check_workflow_against(info, wf)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f["code"] == "value_not_in_list" and f["node_id"] == "127"
|
||||
assert "missing_model.safetensors" in f["message"] and "unet_name" in f["message"]
|
||||
assert "模型文件" in f["message"]
|
||||
assert "installed_model.safetensors" in f["hint"]
|
||||
|
||||
|
||||
def test_unit_upload_backed_and_wired_inputs_skipped():
|
||||
wf = {
|
||||
"1": {"class_type": "LoadImage", "inputs": {"image": "placeholder.png"}},
|
||||
"2": {"class_type": "LoadVideo", "inputs": {"file": "clip.mp4"}},
|
||||
"3": {"class_type": "LoadAudio", "inputs": {"audio": "voice.wav"}},
|
||||
"4": {"class_type": "KSampler", "inputs": {"seed": 7, "model": ["9", 0]}},
|
||||
}
|
||||
info = {
|
||||
"LoadImage": {"input": {"required": {"image": [["other.png"], {"image_upload": True}]}},
|
||||
"output": ["IMAGE"]},
|
||||
"LoadVideo": {"input": {"required": {"file": [[], {}]}}, "output": ["IMAGE"]},
|
||||
"LoadAudio": {"input": {"required": {"audio": [[], {}]}}, "output": ["AUDIO"]},
|
||||
"KSampler": {"input": {"required": {"seed": ["INT", {}], "model": ["MODEL", {}]}},
|
||||
"output": ["IMAGE"]},
|
||||
}
|
||||
assert check_workflow_against(info, wf) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analyze with a target connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_analyze_all_green_against_fake_object_info(client, endpoint_at, fake_comfy):
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
check = r.json()["connection_check"]
|
||||
assert check["reachable"] is True and check["ok"] is True
|
||||
assert check["findings"] == []
|
||||
|
||||
|
||||
def test_analyze_reports_missing_custom_node(client, endpoint_at, fake_comfy):
|
||||
info = build_object_info(_load_05())
|
||||
info.pop("H3SLAAttention")
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["ok"] is False
|
||||
missing = [f for f in check["findings"] if f["code"] == "missing_node"]
|
||||
assert missing and "H3SLAAttention" in missing[0]["message"]
|
||||
assert "900" in missing[0]["message"]
|
||||
|
||||
|
||||
def test_analyze_reports_missing_model_value(client, endpoint_at, fake_comfy):
|
||||
info = build_object_info(_load_05())
|
||||
# The server has a DIFFERENT unet installed than the one the JSON names.
|
||||
info["UNETLoader"]["input"]["required"]["unet_name"] = [["another_unet.safetensors"], {}]
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["ok"] is False
|
||||
bad = [f for f in check["findings"] if f["code"] == "value_not_in_list"]
|
||||
assert bad and bad[0]["node_id"] == "127"
|
||||
assert "minimax_h3_fused_refdelta" in bad[0]["message"]
|
||||
|
||||
|
||||
def test_unreachable_connection_degrades_to_unchecked(client):
|
||||
# A port that nothing listens on: the endpoint resolves, the server doesn't.
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
prev_eps = [dict(ep) for ep in (settings.comfyui_endpoints or [])]
|
||||
prev_active = settings.comfyui_active_id
|
||||
r = client.post(
|
||||
"/api/comfyui/endpoints",
|
||||
json={"name": "断连桩", "base_url": f"http://127.0.0.1:{port}", "auth_type": "none"},
|
||||
)
|
||||
endpoints = r.json()["endpoints"]
|
||||
ep = next(e for e in endpoints if e["base_url"] == f"http://127.0.0.1:{port}")
|
||||
settings.comfyui_endpoints = [dict(e) for e in endpoints if e["id"] == ep["id"]]
|
||||
settings.comfyui_active_id = ep["id"]
|
||||
settings.apply_active_comfyui()
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": ep["id"]},
|
||||
)
|
||||
check = r.json()["connection_check"]
|
||||
assert check["reachable"] is False and check["ok"] is False
|
||||
assert "无法核查" in check["message"]
|
||||
assert check["findings"] == []
|
||||
finally:
|
||||
settings.comfyui_endpoints = prev_eps
|
||||
settings.comfyui_active_id = prev_active
|
||||
settings.apply_active_comfyui()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Saved-workflow re-check + cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_workflow(client, workflow: dict, endpoint_id: str | None = None) -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={
|
||||
"name": "预检工作流",
|
||||
"workflow_json": workflow,
|
||||
"usage": "ref2video",
|
||||
**({"endpoint_id": endpoint_id} if endpoint_id else {}),
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_saved_workflow_recheck_and_refresh(client, endpoint_at, fake_comfy):
|
||||
wf_id = _create_workflow(client, _load_05(), endpoint_at)
|
||||
info = build_object_info(_load_05())
|
||||
info.pop("UNETLoader")
|
||||
fake_comfy.object_info = info
|
||||
r = client.post(f"/api/workflows/{wf_id}/check", json={})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is False and body["endpoint_id"] == endpoint_at
|
||||
assert any(f["code"] == "missing_node" for f in body["findings"])
|
||||
|
||||
# User installs the node package, comes back and re-checks with refresh.
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
r = client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
|
||||
body = r.json()
|
||||
assert body["ok"] is True and body["findings"] == []
|
||||
|
||||
|
||||
def test_object_info_cached_within_ttl(client, endpoint_at, fake_comfy):
|
||||
fake_comfy.object_info = build_object_info(_load_05())
|
||||
for _ in range(2):
|
||||
r = client.post(
|
||||
"/api/workflows/analyze",
|
||||
json={"workflow_json": _load_05(), "endpoint_id": endpoint_at},
|
||||
)
|
||||
assert r.json()["connection_check"]["ok"] is True
|
||||
hits = [p for p in fake_comfy.requests if p == "/object_info"]
|
||||
assert len(hits) == 1 # second call served from the 5-minute cache
|
||||
|
||||
wf_id = _create_workflow(client, _load_05())
|
||||
client.post(f"/api/workflows/{wf_id}/check", json={"refresh": True})
|
||||
hits = [p for p in fake_comfy.requests if p == "/object_info"]
|
||||
assert len(hits) == 2 # manual refresh bypasses the TTL
|
||||
@@ -0,0 +1,197 @@
|
||||
"""「测试工作流」(HEL-643 批次 2)端到端:真实队列 → fake ComfyUI → 产物隔离。
|
||||
|
||||
测试任务与普通任务区分(jobs.origin)、dry-run 拒绝、幂等复用、
|
||||
保留策略与级联清理,全部对着 fake ComfyUI 跑真实链路。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from calliope.config import settings
|
||||
from calliope.queue.workflow_test import workflow_test_dir
|
||||
from tests.test_studio_generation import (
|
||||
IMAGE_WORKFLOW,
|
||||
PNG_1PX,
|
||||
_real_run,
|
||||
)
|
||||
|
||||
|
||||
def _wait_terminal(client, job_id: int, timeout: float = 25.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
r = client.get(f"/api/jobs/{job_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
job = r.json()
|
||||
if job["status"] not in ("pending", "running"):
|
||||
return job
|
||||
time.sleep(0.4)
|
||||
raise AssertionError(f"test job {job_id} did not finish in {timeout}s")
|
||||
|
||||
|
||||
def _create_workflow(client, workflow: dict, name: str = "可测工作流") -> int:
|
||||
r = client.post(
|
||||
"/api/workflows",
|
||||
json={"name": name, "workflow_json": workflow, "usage": "text2image"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_passing_test_downloads_real_output_to_scratch_dir(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="通过用例")
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "测试提示词"}}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["reused"] is False
|
||||
job = _wait_terminal(client, body["job"]["id"])
|
||||
assert job["status"] == "done", job.get("error")
|
||||
assert job["origin"] == "workflow_test"
|
||||
assert job["payload"]["source"] == "workflow_test"
|
||||
assert job["phase"] == "finished"
|
||||
# 真实拿到可下载输出才算通过:产物落在独立 scratch 目录
|
||||
assert len(job["output_paths"]) >= 1
|
||||
out = Path(job["output_paths"][0])
|
||||
assert out.is_file() and out.read_bytes() == PNG_1PX
|
||||
assert workflow_test_dir(wf_id) in out.parents
|
||||
# 提交给 fake ComfyUI 的是补丁后的真实提示词
|
||||
queued = fake_comfy.prompts[-1]["prompt"]
|
||||
assert queued["1"]["inputs"]["text"] == "测试提示词"
|
||||
|
||||
|
||||
def test_tasks_list_shows_origin_but_playground_history_hides_it(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="可见性用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job_id = r.json()["job"]["id"]
|
||||
_wait_terminal(client, job_id)
|
||||
|
||||
listed = client.get("/api/jobs?limit=50").json()
|
||||
mine = [j for j in listed if j["id"] == job_id]
|
||||
assert mine and mine[0]["origin"] == "workflow_test"
|
||||
|
||||
pg = client.get("/api/playground/jobs").json()
|
||||
assert all(j["id"] != job_id for j in pg), "test jobs must stay out of studio history"
|
||||
|
||||
|
||||
def test_failed_prompt_reports_chinese_reason(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="排队被拒用例")
|
||||
fake_comfy.fail_prompt = True
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job = _wait_terminal(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "拒绝执行" in (job["error"] or "")
|
||||
|
||||
|
||||
def test_no_output_does_not_pass(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="无输出用例")
|
||||
fake_comfy.drop_outputs = True
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
job = _wait_terminal(client, r.json()["job"]["id"])
|
||||
assert job["status"] == "failed"
|
||||
assert "没有返回任何图片或视频" in (job["error"] or "")
|
||||
|
||||
|
||||
def test_dry_run_setting_refuses_real_test(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
settings.dry_run = True # conftest default; _real_run clears it — set again
|
||||
try:
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="dry 拒绝用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
assert r.status_code == 400
|
||||
assert "Dry-run" in r.json()["detail"]
|
||||
finally:
|
||||
settings.dry_run = False
|
||||
|
||||
|
||||
def test_repeat_click_reuses_inflight_job(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="幂等用例")
|
||||
assert client.post("/api/jobs/pause").status_code == 200
|
||||
try:
|
||||
r1 = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "一次就好"}}
|
||||
)
|
||||
assert r1.status_code == 200 and r1.json()["reused"] is False
|
||||
r2 = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "重复点击"}}
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["reused"] is True
|
||||
assert r2.json()["job"]["id"] == r1.json()["job"]["id"]
|
||||
finally:
|
||||
client.post("/api/jobs/resume")
|
||||
client.post(f"/api/jobs/{r1.json()['job']['id']}/cancel")
|
||||
_wait_terminal(client, r1.json()["job"]["id"])
|
||||
|
||||
|
||||
def test_retention_keeps_newest_records_and_prunes_artifacts(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="保留策略用例")
|
||||
for i in range(7):
|
||||
fake_comfy.out_name = f"t{i}.png"
|
||||
r = client.post(
|
||||
f"/api/workflows/{wf_id}/test",
|
||||
json={"input_values": {"1": f"第{i}次"}},
|
||||
)
|
||||
_wait_terminal(client, r.json()["job"]["id"])
|
||||
rows = client.get(f"/api/workflows/{wf_id}/tests").json()
|
||||
assert len(rows) <= 5
|
||||
kept_ids = {row["id"] for row in rows}
|
||||
# 被清掉的旧记录其产物文件也一并删除
|
||||
leftovers = [p for p in workflow_test_dir(wf_id).iterdir() if p.suffix == ".png"]
|
||||
assert all(p.stem.lstrip("t").isdigit() and int(p.stem[1:]) >= 2 for p in leftovers)
|
||||
assert len(kept_ids) == 5
|
||||
|
||||
|
||||
def test_clear_tests_and_workflow_delete_cascade(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_id = _create_workflow(client, IMAGE_WORKFLOW, name="清理用例")
|
||||
r = client.post(f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}})
|
||||
_wait_terminal(client, r.json()["job"]["id"])
|
||||
assert workflow_test_dir(wf_id).is_dir()
|
||||
|
||||
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
|
||||
assert r.status_code == 200 and r.json()["removed"] >= 1
|
||||
assert not workflow_test_dir(wf_id).exists()
|
||||
assert client.get(f"/api/workflows/{wf_id}/tests").json() == []
|
||||
|
||||
# 进行中的测试:先拒绝清除,取消后删除工作流级联清理
|
||||
assert client.post("/api/jobs/pause").status_code == 200
|
||||
try:
|
||||
job_id = client.post(
|
||||
f"/api/workflows/{wf_id}/test", json={"input_values": {"1": "p"}}
|
||||
).json()["job"]["id"]
|
||||
r = client.post(f"/api/workflows/{wf_id}/clear-tests")
|
||||
assert r.status_code == 409
|
||||
finally:
|
||||
client.post("/api/jobs/resume")
|
||||
client.post(f"/api/jobs/{job_id}/cancel")
|
||||
_wait_terminal(client, job_id)
|
||||
|
||||
# 工作流列表带出「上次测试」实时状态
|
||||
listed = client.get("/api/workflows").json()
|
||||
mine = [w for w in listed if w["id"] == wf_id][0]
|
||||
assert mine["last_test"]["id"] == job_id
|
||||
|
||||
assert client.delete(f"/api/workflows/{wf_id}").status_code == 200
|
||||
assert not workflow_test_dir(wf_id).exists()
|
||||
assert client.get(f"/api/workflows/{wf_id}/tests").status_code == 404
|
||||
|
||||
|
||||
def test_last_test_summary_live_on_list(client, fake_comfy):
|
||||
with _real_run(client, fake_comfy):
|
||||
wf_a = _create_workflow(client, IMAGE_WORKFLOW, name="A")
|
||||
wf_b = _create_workflow(client, IMAGE_WORKFLOW, name="B")
|
||||
r = client.post(f"/api/workflows/{wf_a}/test", json={"input_values": {"1": "p"}})
|
||||
done = _wait_terminal(client, r.json()["job"]["id"])
|
||||
listed = {w["id"]: w for w in client.get("/api/workflows").json()}
|
||||
assert listed[wf_a]["last_test"]["status"] == "done"
|
||||
assert listed[wf_a]["last_test"]["output_count"] == len(done["output_paths"])
|
||||
assert listed[wf_b]["last_test"] is None
|
||||
@@ -138,6 +138,8 @@ import type {
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
} from "./comfy/types";
|
||||
export type {
|
||||
Job,
|
||||
@@ -150,6 +152,8 @@ export type {
|
||||
ComfyDynamicInput,
|
||||
ComfyDynamicOutput,
|
||||
WorkflowOutputMapEntry,
|
||||
WorkflowConnectionCheck,
|
||||
WorkflowLastTest,
|
||||
};
|
||||
|
||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -458,13 +462,50 @@ export const comfy = {
|
||||
|
||||
export const workflows = {
|
||||
list: () => api<Workflow[]>("/api/workflows"),
|
||||
analyze: (workflow_json: Record<string, unknown>) =>
|
||||
analyze: (
|
||||
workflow_json: Record<string, unknown>,
|
||||
endpoint_id?: string | null,
|
||||
refresh = false,
|
||||
) =>
|
||||
api<WorkflowAnalyzeResult>("/api/workflows/analyze", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workflow_json }),
|
||||
body: JSON.stringify({
|
||||
workflow_json,
|
||||
...(endpoint_id ? { endpoint_id } : {}),
|
||||
...(refresh ? { refresh } : {}),
|
||||
}),
|
||||
}),
|
||||
reanalyze: (id: number) =>
|
||||
api<Workflow>(`/api/workflows/${id}/reanalyze`, { method: "POST" }),
|
||||
/** L2 re-check against a live ComfyUI (HEL-643): node/model availability. */
|
||||
check: (
|
||||
id: number,
|
||||
payload?: { endpoint_id?: string | null; refresh?: boolean },
|
||||
) =>
|
||||
api<WorkflowConnectionCheck>(`/api/workflows/${id}/check`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
}),
|
||||
/** Launch (or re-attach to) one REAL test run (HEL-643 L3). */
|
||||
test: (
|
||||
id: number,
|
||||
payload: {
|
||||
endpoint_id?: string | null;
|
||||
input_values?: Record<string, unknown>;
|
||||
},
|
||||
) =>
|
||||
api<{ ok: boolean; job: Job; reused: boolean }>(
|
||||
`/api/workflows/${id}/test`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
),
|
||||
tests: (id: number) => api<Job[]>(`/api/workflows/${id}/tests`),
|
||||
clearTests: (id: number) =>
|
||||
api<{ ok: boolean; removed: number }>(`/api/workflows/${id}/clear-tests`, {
|
||||
method: "POST",
|
||||
}),
|
||||
create: (payload: {
|
||||
name: string;
|
||||
workflow_json: Record<string, unknown>;
|
||||
|
||||
@@ -1,26 +1,72 @@
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
/** Studio image slots (ref-upload-spec §3): PNG / JPG / WebP, ≤ 20MB each. */
|
||||
export const IMAGE_SLOT_ACCEPT = '.png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp';
|
||||
export const IMAGE_SLOT_ACCEPT =
|
||||
".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp";
|
||||
export const IMAGE_SLOT_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
|
||||
const IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp"];
|
||||
|
||||
/** Reference video slots (HEL-643): MP4 / WebM / MOV, ≤ 500MB (upload cap). */
|
||||
export const VIDEO_SLOT_ACCEPT =
|
||||
"video/mp4,video/webm,video/quicktime,.mp4,.webm,.mov";
|
||||
export const VIDEO_SLOT_MAX_BYTES = 500 * 1024 * 1024;
|
||||
const VIDEO_EXTS = [".mp4", ".webm", ".mov"];
|
||||
|
||||
/** Reference audio slots (HEL-643): MP3 / WAV / M4A, ≤ 100MB. */
|
||||
export const AUDIO_SLOT_ACCEPT =
|
||||
"audio/mpeg,audio/wav,audio/mp4,.mp3,.wav,.m4a";
|
||||
export const AUDIO_SLOT_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const AUDIO_EXTS = [".mp3", ".wav", ".m4a"];
|
||||
|
||||
export type MediaSlotKind = "image" | "video" | "audio";
|
||||
|
||||
export function mediaSlotAccept(kind: MediaSlotKind): string {
|
||||
if (kind === "video") return VIDEO_SLOT_ACCEPT;
|
||||
if (kind === "audio") return AUDIO_SLOT_ACCEPT;
|
||||
return IMAGE_SLOT_ACCEPT;
|
||||
}
|
||||
|
||||
/** Why this file cannot fill a media slot of this kind (null = fine). */
|
||||
export function mediaFileProblem(
|
||||
kind: MediaSlotKind,
|
||||
file: { name: string; size: number },
|
||||
): string | null {
|
||||
const dot = file.name.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : "";
|
||||
const max =
|
||||
kind === "video"
|
||||
? VIDEO_SLOT_MAX_BYTES
|
||||
: kind === "audio"
|
||||
? AUDIO_SLOT_MAX_BYTES
|
||||
: IMAGE_SLOT_MAX_BYTES;
|
||||
if (kind === "video" && !VIDEO_EXTS.includes(ext))
|
||||
return t("studio.errVideoFormat", { name: file.name });
|
||||
if (kind === "audio" && !AUDIO_EXTS.includes(ext))
|
||||
return t("studio.errAudioFormat", { name: file.name });
|
||||
if (kind === "image" && !IMAGE_EXTS.includes(ext))
|
||||
return t("studio.errImageFormat", { name: file.name });
|
||||
if (file.size > max)
|
||||
return kind === "image"
|
||||
? t("studio.errImageSize", { name: file.name })
|
||||
: t("studio.errMediaSize", { name: file.name });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Why this file cannot fill an image slot (null = fine). */
|
||||
export function imageFileProblem(file: { name: string; size: number }): string | null {
|
||||
const dot = file.name.lastIndexOf('.');
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : '';
|
||||
if (!IMAGE_EXTS.includes(ext)) return t('studio.errImageFormat', { name: file.name });
|
||||
if (file.size > IMAGE_SLOT_MAX_BYTES) return t('studio.errImageSize', { name: file.name });
|
||||
return null;
|
||||
export function imageFileProblem(file: {
|
||||
name: string;
|
||||
size: number;
|
||||
}): string | null {
|
||||
return mediaFileProblem("image", file);
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
/** Display name of an uploaded path (strips the uuid8- prefix the upload endpoint writes). */
|
||||
export function uploadBaseName(p: string): string {
|
||||
const raw = p.split(/[/\\]/).pop() ?? p;
|
||||
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
|
||||
const raw = p.split(/[/\\]/).pop() ?? p;
|
||||
return /^[0-9a-f]{8}-/.test(raw) ? raw.slice(9) : raw;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ export type ComfyInputKind =
|
||||
| 'audio'
|
||||
| 'video'
|
||||
/** Ordered list of images for one Autogrow input (e.g. H3 ref_images, min…max). */
|
||||
| 'image_list';
|
||||
| 'image_list'
|
||||
/** Ordered lists of videos / audios for Autogrow inputs (HEL-643). */
|
||||
| 'video_list'
|
||||
| 'audio_list';
|
||||
export type ComfyOutputKind = 'image' | 'video' | 'other';
|
||||
|
||||
/** Structural finding on an imported workflow; `error` blocks generation. */
|
||||
@@ -38,6 +41,8 @@ export interface ComfyDynamicInput {
|
||||
options?: string[];
|
||||
/** On a ResolutionSelector aspect_ratio entry: its megapixels key and rounding multiple. */
|
||||
resolution?: { megapixels: string; multiple: number };
|
||||
/** Official usage-limits hint key for known media groups (HEL-643, e.g. 'minimax_h3_ref2va'). */
|
||||
media_hint?: string;
|
||||
}
|
||||
|
||||
export interface ComfyDynamicOutput {
|
||||
@@ -84,6 +89,40 @@ export const USAGE_BY_KIND: Record<'image' | 'video', WorkflowUsage[]> = {
|
||||
video: ['text2video', 'image2video', 'ref2video'],
|
||||
};
|
||||
|
||||
/** One L2 connection-check finding against a live ComfyUI (HEL-643). */
|
||||
export interface PreflightFinding {
|
||||
level: 'error' | 'warn' | 'info' | 'ok';
|
||||
code: string;
|
||||
node_id?: string;
|
||||
node_title?: string | null;
|
||||
message: string;
|
||||
hint?: string | null;
|
||||
}
|
||||
|
||||
/** Result of checking a workflow against a target connection's /object_info. */
|
||||
export interface WorkflowConnectionCheck {
|
||||
endpoint_id: string | null;
|
||||
endpoint_name: string | null;
|
||||
base_url?: string | null;
|
||||
/** False when any error-level finding exists (or the server was unreachable). */
|
||||
ok: boolean;
|
||||
/** False → 无法核查 (server down): honest, non-blocking. */
|
||||
reachable: boolean;
|
||||
findings: PreflightFinding[];
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
/** Slim live record of a workflow's latest test run (HEL-643). */
|
||||
export interface WorkflowLastTest {
|
||||
id: number;
|
||||
status: string;
|
||||
phase: string | null;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
output_count: number;
|
||||
}
|
||||
|
||||
/** Result of POST /api/workflows/analyze (import confirm step 1). */
|
||||
export interface WorkflowAnalyzeResult {
|
||||
inputs: ComfyDynamicInput[];
|
||||
@@ -101,6 +140,8 @@ export interface WorkflowAnalyzeResult {
|
||||
summary: string[];
|
||||
};
|
||||
usable_usage: WorkflowUsage[];
|
||||
/** Present when analyze was called with a target endpoint (HEL-643 L2). */
|
||||
connection_check?: WorkflowConnectionCheck;
|
||||
}
|
||||
|
||||
export interface WorkflowNode {
|
||||
@@ -130,6 +171,8 @@ export interface Workflow {
|
||||
output_map_source?: 'auto' | 'user' | null;
|
||||
/** True when the graph has unknown output nodes needing designation. */
|
||||
needs_mapping?: boolean;
|
||||
/** Latest test-run record, queried live (HEL-643); null = never tested. */
|
||||
last_test?: WorkflowLastTest | null;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
@@ -152,6 +195,8 @@ export interface Job {
|
||||
comfy_prompt_id?: string | null;
|
||||
/** connecting · comfy_queued · comfy_running · saving · finished · dry_run */
|
||||
phase?: string | null;
|
||||
/** 'user' (normal generation) or 'workflow_test' (HEL-643 test run). */
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface SceneVideoSettings {
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import {
|
||||
apiErrorMessage,
|
||||
assetUrl,
|
||||
playgroundApi,
|
||||
jobsApi,
|
||||
workflows,
|
||||
type ComfyEndpoint,
|
||||
type Job,
|
||||
type Workflow,
|
||||
} from '$lib/api';
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import { mediaFileProblem, type MediaSlotKind } from '$lib/comfy/imageFile';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 测试工作流 (HEL-643 L3): fill the workflow's REAL input slots, submit
|
||||
* one REAL run to the chosen ComfyUI through the ordinary job queue, and
|
||||
* call it a pass only when at least one downloadable output of the right
|
||||
* media kind came back. Test artifacts are isolated from projects.
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
workflow: Workflow | null;
|
||||
endpoints: ComfyEndpoint[];
|
||||
onchanged?: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), workflow, endpoints, onchanged }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
let endpointId = $state('');
|
||||
let values = $state<Record<string, string | number>>({});
|
||||
let listValues = $state<Record<string, string[]>>({});
|
||||
let sizes = $state<Record<string, number>>({});
|
||||
let uploadingName = $state<Record<string, string>>({});
|
||||
let rejects = $state<Record<string, { name: string; message: string }[]>>({});
|
||||
let submitError = $state('');
|
||||
let confirmOpen = $state(false);
|
||||
let submitting = $state(false);
|
||||
let testJobId = $state<number | null>(null);
|
||||
|
||||
const wf = $derived(workflow);
|
||||
const schema = $derived(wf?.input_schema ?? []);
|
||||
const listEntries = $derived(
|
||||
schema.filter((i) => i.kind === 'image_list' || i.kind === 'video_list' || i.kind === 'audio_list'),
|
||||
);
|
||||
const formEntries = $derived(
|
||||
schema.filter((i) => i.kind !== 'image_list' && i.kind !== 'video_list' && i.kind !== 'audio_list'),
|
||||
);
|
||||
const hasH3Hint = $derived(
|
||||
listEntries.some((e) => e.media_hint === 'minimax_h3_ref2va'),
|
||||
);
|
||||
const outputKind = $derived<'image' | 'video'>(wf?.kind === 'video' ? 'video' : 'image');
|
||||
|
||||
const testsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['workflow-tests', wf?.id ?? 0],
|
||||
queryFn: () => workflows.tests(wf!.id),
|
||||
enabled: !!wf && open,
|
||||
refetchInterval: 2000,
|
||||
})),
|
||||
);
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs'],
|
||||
queryFn: () => jobsApi.list(),
|
||||
enabled: !!wf && open,
|
||||
refetchInterval: 5000,
|
||||
})),
|
||||
);
|
||||
const queuedCount = $derived(
|
||||
($jobsQuery.data ?? []).filter((j) => j.status === 'pending' || j.status === 'running')
|
||||
.length,
|
||||
);
|
||||
|
||||
const history = $derived(($testsQuery.data ?? []) as Job[]);
|
||||
const activeJob = $derived.by(() => {
|
||||
if (testJobId != null) {
|
||||
const mine = history.find((j) => j.id === testJobId);
|
||||
if (mine && (mine.status === 'pending' || mine.status === 'running')) return mine;
|
||||
}
|
||||
return history.find((j) => j.status === 'pending' || j.status === 'running') ?? null;
|
||||
});
|
||||
const finishedJob = $derived.by(() => {
|
||||
if (testJobId != null) return history.find((j) => j.id === testJobId) ?? null;
|
||||
return null;
|
||||
});
|
||||
|
||||
const queueBusy = $derived(!!activeJob || submitting);
|
||||
|
||||
$effect(() => {
|
||||
if (!open || !wf) return;
|
||||
endpointId = wf.endpoint_id ?? '';
|
||||
values = Object.fromEntries(
|
||||
schema
|
||||
.filter((i) => i.defaultValue !== undefined && i.defaultValue !== null)
|
||||
.map((i) => [i.nodeId, i.defaultValue as string | number]),
|
||||
);
|
||||
listValues = {};
|
||||
sizes = {};
|
||||
rejects = {};
|
||||
uploadingName = {};
|
||||
submitError = '';
|
||||
testJobId = wf.last_test && (wf.last_test.status === 'pending' || wf.last_test.status === 'running')
|
||||
? wf.last_test.id
|
||||
: null;
|
||||
});
|
||||
|
||||
function kindOf(entry: { kind: string }): MediaSlotKind {
|
||||
if (entry.kind === 'video_list') return 'video';
|
||||
if (entry.kind === 'audio_list') return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function unitOf(kind: MediaSlotKind): string {
|
||||
return kind === 'image' ? '张' : '段';
|
||||
}
|
||||
|
||||
function setRejects(key: string, next: { name: string; message: string }[]) {
|
||||
rejects = { ...rejects, [key]: next };
|
||||
}
|
||||
|
||||
async function uploadOne(file: File): Promise<string | null> {
|
||||
const res = await playgroundApi.upload(file);
|
||||
sizes = { ...sizes, [res.path]: file.size };
|
||||
return res.path;
|
||||
}
|
||||
|
||||
async function mediaAdd(entry: (typeof listEntries)[number], files: File[]) {
|
||||
const kind = kindOf(entry);
|
||||
const key = entry.nodeId;
|
||||
const max = entry.max ?? 9;
|
||||
const current = listValues[key] ?? [];
|
||||
const ok: string[] = [];
|
||||
const bad: { name: string; message: string }[] = [];
|
||||
for (const f of files) {
|
||||
const problem = mediaFileProblem(kind, f);
|
||||
if (problem) bad.push({ name: f.name, message: problem });
|
||||
else ok.push(f.name);
|
||||
}
|
||||
if (current.length + ok.length > max) {
|
||||
toast.error(
|
||||
t('studio.errRefMax', { max, n: current.length, k: ok.length }),
|
||||
);
|
||||
if (bad.length) setRejects(key, [...(rejects[key] ?? []), ...bad]);
|
||||
return;
|
||||
}
|
||||
setRejects(key, [...(rejects[key] ?? []), ...bad]);
|
||||
for (const f of files) {
|
||||
if (!ok.includes(f.name)) continue;
|
||||
uploadingName = { ...uploadingName, [key]: f.name };
|
||||
try {
|
||||
const path = await uploadOne(f);
|
||||
if (path) listValues = { ...listValues, [key]: [...(listValues[key] ?? []), path] };
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
uploadingName = { ...uploadingName, [key]: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mediaReplace(entry: (typeof listEntries)[number], index: number, file: File) {
|
||||
const kind = kindOf(entry);
|
||||
const key = entry.nodeId;
|
||||
const problem = mediaFileProblem(kind, file);
|
||||
if (problem) {
|
||||
setRejects(key, [...(rejects[key] ?? []), { name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
uploadingName = { ...uploadingName, [key]: file.name };
|
||||
try {
|
||||
const path = await uploadOne(file);
|
||||
if (path) {
|
||||
const next = [...(listValues[key] ?? [])];
|
||||
next[index] = path;
|
||||
listValues = { ...listValues, [key]: next };
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
uploadingName = { ...uploadingName, [key]: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function mediaRemove(entry: (typeof listEntries)[number], index: number) {
|
||||
const key = entry.nodeId;
|
||||
listValues = { ...listValues, [key]: (listValues[key] ?? []).filter((_, i) => i !== index) };
|
||||
}
|
||||
|
||||
/** Mirrors the backend's validate_input_values for immediate feedback. */
|
||||
function validate(): string[] {
|
||||
const errs: string[] = [];
|
||||
for (const e of schema) {
|
||||
const label = e.label;
|
||||
if (e.kind === 'image_list' || e.kind === 'video_list' || e.kind === 'audio_list') {
|
||||
const n = (listValues[e.nodeId] ?? []).length;
|
||||
const unit = unitOf(kindOf(e));
|
||||
if (e.required && n < (e.min ?? 1)) errs.push(t('wft.errListMin', { n: e.min ?? 1, unit, label }));
|
||||
} else if (e.auto && e.required && e.role === 'prompt') {
|
||||
const v = values[e.nodeId];
|
||||
if (!(typeof v === 'string' && v.trim()) && !String(e.defaultValue ?? '').trim()) {
|
||||
errs.push(t('wft.errRequired', { label }));
|
||||
}
|
||||
} else if (e.required && (e.kind === 'image' || e.kind === 'video' || e.kind === 'audio') && e.role !== 'mask') {
|
||||
if (!values[e.nodeId]) errs.push(t('wft.errRequired', { label }));
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
async function submitTest() {
|
||||
if (!wf) return;
|
||||
const errs = validate();
|
||||
if (errs.length) {
|
||||
submitError = errs.join(';');
|
||||
return;
|
||||
}
|
||||
submitError = '';
|
||||
confirmOpen = true;
|
||||
}
|
||||
|
||||
async function reallyRunTest() {
|
||||
if (!wf) return;
|
||||
submitting = true;
|
||||
try {
|
||||
const inputValues: Record<string, unknown> = { ...values };
|
||||
for (const [k, v] of Object.entries(listValues)) {
|
||||
if (v.length) inputValues[k] = v;
|
||||
}
|
||||
const res = await workflows.test(wf.id, {
|
||||
...(endpointId ? { endpoint_id: endpointId } : {}),
|
||||
input_values: inputValues as Record<string, string | number | string[]>,
|
||||
});
|
||||
testJobId = res.job.id;
|
||||
if (res.reused) toast.info(t('wft.reusedToast'));
|
||||
void $testsQuery.refetch();
|
||||
} catch (err) {
|
||||
submitError = apiErrorMessage(err);
|
||||
} finally {
|
||||
submitting = false;
|
||||
confirmOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelTest() {
|
||||
if (!activeJob) return;
|
||||
try {
|
||||
await jobsApi.cancel(activeJob.id);
|
||||
void $testsQuery.refetch();
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function clearRecords() {
|
||||
if (!wf) return;
|
||||
try {
|
||||
await workflows.clearTests(wf.id);
|
||||
testJobId = null;
|
||||
await $testsQuery.refetch();
|
||||
onchanged?.();
|
||||
toast.success(t('wft.cleared'));
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function resultLine(job: Job): string {
|
||||
if (job.status === 'done' && (job.output_paths?.length ?? 0) > 0) {
|
||||
return t('wft.passedShort', { n: job.output_paths.length });
|
||||
}
|
||||
if (job.error === 'cancelled') return t('wft.cancelledShort');
|
||||
return job.error ? job.error.slice(0, 120) : t('wft.failedShort');
|
||||
}
|
||||
|
||||
function timeOf(at: string | null): string {
|
||||
if (!at) return '—';
|
||||
const ts = Date.parse(at.replace(' ', 'T') + 'Z');
|
||||
const d = new Date(Number.isFinite(ts) ? ts : at);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const endpointLabel = $derived(
|
||||
endpoints.find((e) => e.id === endpointId)?.name ?? t('conn.wfConnectionActive'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<Modal bind:open title={t('wft.title')} size="lg">
|
||||
{#if wf}
|
||||
<div class="wft">
|
||||
<p class="wft-sub">
|
||||
{wf.name}
|
||||
<span class="mono wft-id">#{wf.id}</span>
|
||||
</p>
|
||||
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('wft.connection')}</span>
|
||||
<select class="f-input" bind:value={endpointId} disabled={queueBusy}>
|
||||
<option value="">{t('conn.wfConnectionActive')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="f-hint">{t('wft.costNote')}</span>
|
||||
</label>
|
||||
|
||||
{#if hasH3Hint}
|
||||
<div class="wft-note" role="note">
|
||||
<Icon name="info" size={14} />
|
||||
<p>{t('wfMedia.h3Limits')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeJob}
|
||||
{@const info = jobStatusInfo(activeJob)}
|
||||
<div class="wft-active" class:ok={activeJob.status === 'done'} class:bad={activeJob.status === 'failed'}>
|
||||
<div class="wft-active-head">
|
||||
<span class="status-chip">{t('wft.running')}</span>
|
||||
<span class="wft-active-detail">{info.detail}</span>
|
||||
<Button variant="secondary" size="sm" onclick={cancelTest}>{t('wft.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if finishedJob && finishedJob.status === 'done' && (finishedJob.output_paths?.length ?? 0) > 0}
|
||||
<div class="wft-result ok">
|
||||
<p class="wft-verdict">{t('wft.passed', { n: finishedJob.output_paths.length })}</p>
|
||||
<div class="wft-outputs">
|
||||
{#each finishedJob.output_paths.slice(0, 4) as path (path)}
|
||||
<a
|
||||
class="wft-out"
|
||||
href={assetUrl(path)}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t('wft.download')}
|
||||
>
|
||||
<SafeMedia kind={outputKind} src={assetUrl(path)} controls={outputKind === 'video'} />
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if finishedJob && finishedJob.status !== 'pending' && finishedJob.status !== 'running'}
|
||||
<div class="wft-result bad">
|
||||
<p class="wft-verdict">{t('wft.failed')}</p>
|
||||
<p class="wft-error">{finishedJob.error === 'cancelled' ? t('wft.cancelledShort') : finishedJob.error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !queueBusy}
|
||||
<div class="wft-form">
|
||||
{#each listEntries as entry (entry.nodeId)}
|
||||
{@const kind = kindOf(entry)}
|
||||
<RefImageGrid
|
||||
label={entry.label}
|
||||
mediaKind={kind}
|
||||
paths={listValues[entry.nodeId] ?? []}
|
||||
max={entry.max ?? 9}
|
||||
sizes={sizes}
|
||||
uploadingName={uploadingName[entry.nodeId] || undefined}
|
||||
rejects={rejects[entry.nodeId] ?? []}
|
||||
onAdd={(files) => mediaAdd(entry, files)}
|
||||
onReplace={(i, file) => mediaReplace(entry, i, file)}
|
||||
onRemove={(i) => mediaRemove(entry, i)}
|
||||
onDismissReject={(i) =>
|
||||
setRejects(entry.nodeId, (rejects[entry.nodeId] ?? []).filter((_, j) => j !== i))}
|
||||
/>
|
||||
{/each}
|
||||
{#if formEntries.length}
|
||||
<ComfyDynamicForm inputs={formEntries} bind:values allowUpload showErrors={!!submitError} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if submitError}<span class="f-error" role="alert">{submitError}</span>{/if}
|
||||
<div class="wft-actions">
|
||||
<Button variant="primary" loading={submitting} onclick={submitTest}>
|
||||
{t('wft.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="wft-history">
|
||||
<div class="wft-history-head">
|
||||
<span class="f-label">{t('wft.history', { n: history.length })}</span>
|
||||
{#if history.length}
|
||||
<button type="button" class="link-danger" onclick={clearRecords}>
|
||||
{t('wft.clear')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if history.length === 0}
|
||||
<p class="wft-empty">{t('wft.never')}</p>
|
||||
{:else}
|
||||
<ul class="wft-rows">
|
||||
{#each history as job (job.id)}
|
||||
<li class:active={job.id === activeJob?.id}>
|
||||
<span class="mono wft-row-id">#{job.id}</span>
|
||||
<span class="wft-row-time">{timeOf(job.created_at)}</span>
|
||||
<span class="wft-row-result" class:ok={job.status === 'done' && (job.output_paths?.length ?? 0) > 0}>
|
||||
{resultLine(job)}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmOpen}
|
||||
title={t('wft.confirmTitle')}
|
||||
message={t('wft.confirmBody', { endpoint: endpointLabel, n: queuedCount })}
|
||||
confirmLabel={t('wft.submit')}
|
||||
onconfirm={reallyRunTest}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.wft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wft-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-id {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.wft-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--info, #3b82f6);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wft-note p {
|
||||
margin: 0;
|
||||
}
|
||||
.wft-active,
|
||||
.wft-result {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.wft-active-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wft-active-detail {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-result.ok {
|
||||
border-color: var(--success);
|
||||
}
|
||||
.wft-result.bad {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.wft-verdict {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.wft-result.ok .wft-verdict {
|
||||
color: var(--success);
|
||||
}
|
||||
.wft-error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
.wft-outputs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-out {
|
||||
display: block;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.wft-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.wft-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-history {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.wft-history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-empty {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wft-rows {
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wft-rows li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wft-rows li.active {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.wft-row-id {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.wft-row-time {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wft-row-result {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wft-row-result.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
</style>
|
||||
@@ -2,14 +2,21 @@
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
|
||||
import {
|
||||
IMAGE_SLOT_ACCEPT,
|
||||
formatBytes,
|
||||
mediaSlotAccept,
|
||||
uploadBaseName,
|
||||
type MediaSlotKind,
|
||||
} from '$lib/comfy/imageFile';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Reference-image grid (ref-upload-spec §2 `.refgrid`): numbered cells
|
||||
* 参考图 1…N in the order they reach the workflow, each with replace and
|
||||
* ✕; a dashed add cell while below `max`; rejected files show as error
|
||||
* cells that take no place in the list.
|
||||
* Reference-media grid (ref-upload-spec §2 `.refgrid`, generalized to
|
||||
* image/video/audio in HEL-643): numbered cells 参考图/参考视频/参考音频
|
||||
* 1…N in the order they reach the workflow, each with replace and ✕; a
|
||||
* dashed add cell while below `max`; rejected files show as error cells
|
||||
* that take no place in the list.
|
||||
*/
|
||||
interface Reject {
|
||||
name: string;
|
||||
@@ -19,6 +26,8 @@
|
||||
label: string;
|
||||
paths: string[];
|
||||
max: number;
|
||||
/** Media family of the slots — drives thumbnails, accept and copy. */
|
||||
mediaKind?: MediaSlotKind;
|
||||
/** Byte sizes of files uploaded in this session, by server path. */
|
||||
sizes?: Record<string, number>;
|
||||
/** Name of the file being uploaded (add or replace), if any. */
|
||||
@@ -39,6 +48,7 @@
|
||||
label,
|
||||
paths,
|
||||
max,
|
||||
mediaKind = 'image',
|
||||
sizes = {},
|
||||
uploadingName,
|
||||
uploadingIndex = -1,
|
||||
@@ -57,9 +67,28 @@
|
||||
let replaceIndex = $state(-1);
|
||||
let dragDepth = $state(0);
|
||||
let dims = $state<Record<string, string>>({});
|
||||
let durations = $state<Record<string, number>>({});
|
||||
|
||||
const uploading = $derived(!!uploadingName);
|
||||
const remaining = $derived(Math.max(0, max - paths.length));
|
||||
const accept = $derived(mediaKind === 'image' ? IMAGE_SLOT_ACCEPT : mediaSlotAccept(mediaKind));
|
||||
const isMedia = $derived(mediaKind !== 'image');
|
||||
const hintLabel = $derived(
|
||||
isMedia
|
||||
? t('studio.refHintMedia', { n: paths.length, max })
|
||||
: t('studio.refHint', { n: paths.length, max }),
|
||||
);
|
||||
function durationOf(path: string): string {
|
||||
const d = durations[path];
|
||||
if (d == null || !Number.isFinite(d)) return '';
|
||||
return `${d.toFixed(1)}s`;
|
||||
}
|
||||
function noteDuration(path: string, e: Event): void {
|
||||
const el = e.currentTarget as HTMLVideoElement | HTMLAudioElement | null;
|
||||
if (el && Number.isFinite(el.duration)) {
|
||||
durations = { ...durations, [path]: el.duration };
|
||||
}
|
||||
}
|
||||
|
||||
function pickAdd() {
|
||||
if (uploading || !addInput) return;
|
||||
@@ -88,14 +117,16 @@
|
||||
}
|
||||
|
||||
function metaOf(path: string): string {
|
||||
return [dims[path], sizes[path] != null ? formatBytes(sizes[path]) : null].filter(Boolean).join(' · ');
|
||||
return [dims[path], durationOf(path), sizes[path] != null ? formatBytes(sizes[path]) : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="refgrid-field">
|
||||
<div class="slot-label">
|
||||
<span class="lbl">{label}<em>*</em></span>
|
||||
<span class="slot-hint">{t('studio.refHint', { n: paths.length, max })}</span>
|
||||
<span class="slot-hint">{hintLabel}</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -104,7 +135,7 @@
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={IMAGE_SLOT_ACCEPT}
|
||||
accept={accept}
|
||||
multiple
|
||||
onchange={(e) => {
|
||||
const files = Array.from(e.currentTarget.files ?? []);
|
||||
@@ -117,14 +148,14 @@
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={IMAGE_SLOT_ACCEPT}
|
||||
accept={accept}
|
||||
onchange={(e) => {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
if (f && replaceIndex >= 0) onReplace(replaceIndex, f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="refgrid">
|
||||
<div class="refgrid" class:audio={mediaKind === 'audio'}>
|
||||
{#each paths as path, i (path + i)}
|
||||
<div class="ref-cell" class:busy={uploadingIndex === i}>
|
||||
<button
|
||||
@@ -133,7 +164,30 @@
|
||||
title={t('studio.refReplace')}
|
||||
onclick={() => pickReplace(i)}
|
||||
>
|
||||
<img src={assetUrl(path)} alt={t('studio.slotRefN', { n: i + 1 })} onload={(e) => noteDims(path, e)} />
|
||||
{#if mediaKind === 'image'}
|
||||
<img
|
||||
src={assetUrl(path)}
|
||||
alt={t('studio.slotRefN', { n: i + 1 })}
|
||||
onload={(e) => noteDims(path, e)}
|
||||
/>
|
||||
{:else if mediaKind === 'video'}
|
||||
<video
|
||||
src={assetUrl(path)}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
onloadedmetadata={(e) => noteDuration(path, e)}
|
||||
title={uploadBaseName(path)}
|
||||
></video>
|
||||
{:else}
|
||||
<span class="audio-glyph"><Icon name="music" size={18} /></span>
|
||||
<audio
|
||||
src={assetUrl(path)}
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => noteDuration(path, e)}
|
||||
title={uploadBaseName(path)}
|
||||
></audio>
|
||||
{/if}
|
||||
<span class="ref-no mono">{i + 1}</span>
|
||||
{#if uploadingIndex === i}
|
||||
<span class="ref-busy"><Spinner size="sm" /></span>
|
||||
@@ -142,7 +196,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="ref-x"
|
||||
aria-label={t('studio.refRemove', { n: i + 1 })}
|
||||
aria-label={isMedia ? t('studio.refRemoveMedia', { kind: label, n: i + 1 }) : t('studio.refRemove', { n: i + 1 })}
|
||||
onclick={() => onRemove(i)}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
@@ -195,8 +249,8 @@
|
||||
<span class="add-main">{uploadingName}</span>
|
||||
{:else}
|
||||
<Icon name="plus" size={16} />
|
||||
<span class="add-main">{t('studio.refAdd')}</span>
|
||||
<span class="add-sub mono">{t('studio.refAddSub', { n: remaining, max })}</span>
|
||||
<span class="add-main">{isMedia ? t('studio.refAddMedia', { kind: label }) : t('studio.refAdd')}</span>
|
||||
<span class="add-sub mono">{isMedia ? t('studio.refAddSubMedia', { n: remaining, max }) : t('studio.refAddSub', { n: remaining, max })}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -208,7 +262,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
{#if remaining === 0}
|
||||
<span class="full-note">{t('studio.refFull', { max })}</span>
|
||||
<span class="full-note">{isMedia ? t('studio.refFullMedia', { max }) : t('studio.refFull', { max })}</span>
|
||||
{/if}
|
||||
{#each rejects as rej, i (rej.name + i)}
|
||||
<span class="slot-error">{rej.message}</span>
|
||||
@@ -223,6 +277,30 @@
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.refgrid.audio {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.refgrid.audio .ref-thumb {
|
||||
aspect-ratio: auto;
|
||||
min-height: 64px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
.refgrid.audio audio {
|
||||
width: 90%;
|
||||
height: 28px;
|
||||
}
|
||||
.refgrid video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.audio-glyph {
|
||||
color: var(--text-3);
|
||||
display: inline-flex;
|
||||
}
|
||||
.slot-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
|
||||
import MediaSlot from '$lib/components/studio/MediaSlot.svelte';
|
||||
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
|
||||
import { imageFileProblem } from '$lib/comfy/imageFile';
|
||||
import { mediaFileProblem } from '$lib/comfy/imageFile';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { tick } from 'svelte';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
@@ -145,6 +145,7 @@
|
||||
let fileSizes = $state<Record<string, number>>({});
|
||||
/** Reference index being replaced while its upload runs. */
|
||||
let replacingIndex = $state(-1);
|
||||
let replacingKey = $state('');
|
||||
let showErrors = $state(false);
|
||||
let submitting = $state(false);
|
||||
let lightboxOpen = $state(false);
|
||||
@@ -212,7 +213,9 @@
|
||||
inp.kind === 'image_url' ||
|
||||
inp.kind === 'video' ||
|
||||
inp.kind === 'audio' ||
|
||||
inp.kind === 'image_list';
|
||||
inp.kind === 'image_list' ||
|
||||
inp.kind === 'video_list' ||
|
||||
inp.kind === 'audio_list';
|
||||
// Auto-detected slots carry their role from the wiring; tagged (Input:…)
|
||||
// workflows still name frames only in the title.
|
||||
const isFirstFrame = (inp: ComfyDynamicInput) =>
|
||||
@@ -220,14 +223,44 @@
|
||||
const isLastFrame = (inp: ComfyDynamicInput) =>
|
||||
role(inp) === 'last_frame' || (!inp.auto && kind === 'video' && /last|end|尾帧/.test(hay(inp)));
|
||||
|
||||
/** Autogrow media lists actually wired in this workflow (HEL-643: image /
|
||||
* video / audio). No wiring → no entry, whatever the model could do. */
|
||||
const listGroups = $derived(
|
||||
schema.filter(
|
||||
(i) => i.kind === 'image_list' || i.kind === 'video_list' || i.kind === 'audio_list',
|
||||
),
|
||||
);
|
||||
function listKind(entry: ComfyDynamicInput): 'image' | 'video' | 'audio' {
|
||||
if (entry.kind === 'video_list') return 'video';
|
||||
if (entry.kind === 'audio_list') return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
// ComfyUI Autogrow reference images (H3 ref_images): one ordered 1…max list.
|
||||
const refGroup = $derived(schema.find((i) => i.kind === 'image_list') ?? null);
|
||||
const refGroup = $derived(listGroups.find((i) => i.kind === 'image_list') ?? null);
|
||||
const refMax = $derived(refGroup?.max ?? 9);
|
||||
const refPaths = $derived.by(() => {
|
||||
const v = refGroup ? values[refGroup.nodeId] : undefined;
|
||||
if (Array.isArray(v)) return v;
|
||||
return typeof v === 'string' && v ? [v] : [];
|
||||
});
|
||||
function groupPaths(entry: ComfyDynamicInput): string[] {
|
||||
const v = values[entry.nodeId];
|
||||
if (Array.isArray(v)) return v;
|
||||
return typeof v === 'string' && v ? [v] : [];
|
||||
}
|
||||
// H3 Ref2VA official limits (soft hint, keyed off the node class only).
|
||||
const hasH3Hint = $derived(listGroups.some((e) => e.media_hint === 'minimax_h3_ref2va'));
|
||||
const h3SoftWarnings = $derived.by(() => {
|
||||
if (!hasH3Hint) return [];
|
||||
const warns: string[] = [];
|
||||
const counts = { image: 0, video: 0, audio: 0 };
|
||||
for (const g of listGroups) counts[listKind(g)] += groupPaths(g).length;
|
||||
const total = counts.image + counts.video + counts.audio;
|
||||
if (total > 12) warns.push(t('wfMedia.h3TotalOver', { n: total }));
|
||||
if (counts.audio > 0 && counts.image === 0 && counts.video === 0)
|
||||
warns.push(t('wfMedia.h3AudioAlone'));
|
||||
return warns;
|
||||
});
|
||||
const brokenReasons = $derived(
|
||||
(currentWorkflow?.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message),
|
||||
);
|
||||
@@ -271,7 +304,10 @@
|
||||
}
|
||||
|
||||
const mediaSlots = $derived.by(() => {
|
||||
const media = schema.filter((i) => isMedia(i) && i.kind !== 'image_list');
|
||||
const media = schema.filter(
|
||||
(i) =>
|
||||
isMedia(i) && i.kind !== 'image_list' && i.kind !== 'video_list' && i.kind !== 'audio_list',
|
||||
);
|
||||
const plainImages = media.filter(
|
||||
(i) => i.kind === 'image' && role(i) !== 'mask' && !/mask|遮罩/.test(hay(i)) && !isFirstFrame(i) && !isLastFrame(i),
|
||||
);
|
||||
@@ -424,10 +460,17 @@
|
||||
errs[s.inp.nodeId] = s.missing || t('studio.errUpload', { label: s.label });
|
||||
}
|
||||
}
|
||||
if (refGroup) {
|
||||
if (refPaths.length < (refGroup.min ?? 1)) errs[refGroup.nodeId] = t('studio.errNeedRef');
|
||||
else if (refPaths.length > refMax)
|
||||
errs[refGroup.nodeId] = t('studio.errRefMax', { max: refMax, n: refPaths.length, k: refPaths.length - refMax });
|
||||
for (const g of listGroups) {
|
||||
const n = groupPaths(g).length;
|
||||
const max = g.max ?? 9;
|
||||
if (n < (g.min ?? 1)) {
|
||||
errs[g.nodeId] =
|
||||
g.kind === 'image_list'
|
||||
? t('studio.errNeedRef')
|
||||
: t('studio.errNeedRefMedia', { n: g.min ?? 1, label: g.label });
|
||||
} else if (n > max) {
|
||||
errs[g.nodeId] = t('studio.errRefMax', { max, n, k: n - max });
|
||||
}
|
||||
}
|
||||
for (const inp of restInputs) {
|
||||
if (inp.required && !String(values[inp.nodeId] ?? '').trim()) {
|
||||
@@ -486,12 +529,10 @@
|
||||
}
|
||||
|
||||
async function slotFile(nodeId: string, file: File, mediaKind: 'image' | 'video' | 'audio' = 'image') {
|
||||
if (mediaKind === 'image') {
|
||||
const problem = imageFileProblem(file);
|
||||
if (problem) {
|
||||
setRejects(nodeId, [{ name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
const problem = mediaFileProblem(mediaKind, file);
|
||||
if (problem) {
|
||||
setRejects(nodeId, [{ name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
setRejects(nodeId, []);
|
||||
const path = await uploadImage(nodeId, file);
|
||||
@@ -499,46 +540,48 @@
|
||||
}
|
||||
|
||||
/** Add picked/dropped files in order; a batch that would pass `max` is refused whole. */
|
||||
async function refAdd(files: File[]) {
|
||||
if (!refGroup) return;
|
||||
const key = refGroup.nodeId;
|
||||
const ok = files.filter((f) => !imageFileProblem(f));
|
||||
async function groupAdd(entry: ComfyDynamicInput, files: File[]) {
|
||||
const key = entry.nodeId;
|
||||
const mk = listKind(entry);
|
||||
const max = entry.max ?? 9;
|
||||
const paths = groupPaths(entry);
|
||||
const ok = files.filter((f) => !mediaFileProblem(mk, f));
|
||||
const rejected = files
|
||||
.filter((f) => imageFileProblem(f))
|
||||
.map((f) => ({ name: f.name, message: imageFileProblem(f)! }));
|
||||
.filter((f) => mediaFileProblem(mk, f))
|
||||
.map((f) => ({ name: f.name, message: mediaFileProblem(mk, f)! }));
|
||||
setRejects(key, rejected);
|
||||
if (refPaths.length + ok.length > refMax) {
|
||||
toast.error(t('studio.errRefMax', { max: refMax, n: refPaths.length, k: ok.length }));
|
||||
if (paths.length + ok.length > max) {
|
||||
toast.error(t('studio.errRefMax', { max, n: paths.length, k: ok.length }));
|
||||
return;
|
||||
}
|
||||
for (const f of ok) {
|
||||
const path = await uploadImage(key, f);
|
||||
if (!path) break;
|
||||
setVal(key, [...refPaths, path]);
|
||||
setVal(key, [...paths, path]);
|
||||
}
|
||||
}
|
||||
|
||||
async function refReplace(index: number, file: File) {
|
||||
if (!refGroup) return;
|
||||
const key = refGroup.nodeId;
|
||||
const problem = imageFileProblem(file);
|
||||
async function groupReplace(entry: ComfyDynamicInput, index: number, file: File) {
|
||||
const key = entry.nodeId;
|
||||
const problem = mediaFileProblem(listKind(entry), file);
|
||||
if (problem) {
|
||||
setRejects(key, [{ name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
setRejects(key, []);
|
||||
replacingKey = key;
|
||||
replacingIndex = index;
|
||||
try {
|
||||
const path = await uploadImage(key, file);
|
||||
if (path) setVal(key, refPaths.map((p, i) => (i === index ? path : p)));
|
||||
if (path) setVal(key, groupPaths(entry).map((p, i) => (i === index ? path : p)));
|
||||
} finally {
|
||||
replacingKey = '';
|
||||
replacingIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function refRemove(index: number) {
|
||||
if (!refGroup) return;
|
||||
setVal(refGroup.nodeId, refPaths.filter((_, i) => i !== index));
|
||||
function groupRemove(entry: ComfyDynamicInput, index: number) {
|
||||
setVal(entry.nodeId, groupPaths(entry).filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
const imageResults = $derived(
|
||||
@@ -706,6 +749,28 @@
|
||||
const selectedEntry = $derived(
|
||||
doneEntries.find((e) => e.key === selectedKey) ?? doneEntries[0] ?? null,
|
||||
);
|
||||
const selectedJobId = $derived(selectedEntry?.job.id ?? null);
|
||||
|
||||
/**
|
||||
* Clicking a history row: a finished job loads into the preview
|
||||
* (player / image), anything else just reports its live status —
|
||||
* never a silent jump to the newest finished clip.
|
||||
*/
|
||||
function selectFromHistory(job: Job) {
|
||||
const st = jobStatusInfo(job);
|
||||
if (st.tone === 'ok' && job.output_paths.length) {
|
||||
selectedKey = `${job.id}:${job.output_paths[0]}`;
|
||||
return;
|
||||
}
|
||||
toast.info(st.detail && st.detail !== st.label ? `${st.label} · ${st.detail}` : st.label);
|
||||
}
|
||||
|
||||
function histRowKey(e: KeyboardEvent, job: Job) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectFromHistory(job);
|
||||
}
|
||||
}
|
||||
|
||||
function summaryOf(job: Job): string {
|
||||
const parts: string[] = [];
|
||||
@@ -758,6 +823,26 @@
|
||||
let curSec = $state(0);
|
||||
let durSec = $state(0);
|
||||
|
||||
// Switching the selected video must stop the old clip and rewind it,
|
||||
// never keep playing the previous audio/progress into the new one.
|
||||
// Runs BEFORE the DOM swaps the src so the reset cannot clobber the
|
||||
// duration/metadata events of the clip being loaded in.
|
||||
$effect.pre(() => {
|
||||
void selectedEntry?.key;
|
||||
const v = videoEl;
|
||||
if (v) {
|
||||
try {
|
||||
v.pause();
|
||||
v.currentTime = 0;
|
||||
} catch {
|
||||
/* no loaded metadata yet — src change already reset it */
|
||||
}
|
||||
}
|
||||
playing = false;
|
||||
curSec = 0;
|
||||
durSec = 0;
|
||||
});
|
||||
|
||||
function fmtClock(s: number): string {
|
||||
if (!Number.isFinite(s)) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
@@ -785,13 +870,16 @@
|
||||
else void playerBox?.requestFullscreen();
|
||||
}
|
||||
|
||||
// First/last frame strip for the selected video job
|
||||
// First/last frame + reference strip for the selected video job.
|
||||
// Ref workflows come in two shapes: an autogrow image_list, or several
|
||||
// plain image inputs — both are shown as reference images.
|
||||
const selectedFrames = $derived.by(() => {
|
||||
if (kind !== 'video' || !selectedEntry) return [];
|
||||
const wf = ($workflowsQuery.data ?? []).find((w) => w.id === selectedEntry.job.workflow_id);
|
||||
if (!wf) return [];
|
||||
const vals = (selectedEntry.job.payload?.input_values ?? {}) as Record<string, unknown>;
|
||||
const out: Array<{ label: string; path: string; ref?: boolean }> = [];
|
||||
let refIdx = 0;
|
||||
for (const inp of wf.input_schema ?? []) {
|
||||
const h = `${inp.role ?? ''} ${inp.label}`.toLowerCase();
|
||||
const v = vals[inp.nodeId];
|
||||
@@ -805,6 +893,10 @@
|
||||
if (typeof v !== 'string' || !v) continue;
|
||||
if (/first|start|首帧/.test(h)) out.push({ label: t('studio.slotFirst'), path: v });
|
||||
else if (/last|end|尾帧/.test(h)) out.push({ label: t('studio.slotLast'), path: v });
|
||||
else if (isMedia(inp) && inp.kind === 'image' && !/mask|遮罩/.test(h)) {
|
||||
refIdx += 1;
|
||||
out.push({ label: t('studio.slotRefN', { n: refIdx }), path: v, ref: true });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
@@ -852,12 +944,31 @@
|
||||
}
|
||||
|
||||
const deleteTarget = $derived(kindJobs.find((j) => j.id === deleteJobId) ?? null);
|
||||
// The dialog names the exact job and — when it produced several
|
||||
// outputs — says plainly that ALL of them go away with it (the
|
||||
// backend only deletes whole jobs, never a single output file).
|
||||
const deleteTitle = $derived(
|
||||
deleteTarget ? t('studio.delTitleJob', { name: wfName(deleteTarget) }) : t('studio.delTitle'),
|
||||
);
|
||||
const deleteMessage = $derived.by(() => {
|
||||
const n = deleteTarget?.output_paths?.length ?? 0;
|
||||
return n > 1 ? t('studio.delConfirmMulti', { n }) : t('studio.delConfirm');
|
||||
});
|
||||
|
||||
function askDeleteJob(jobId: number) {
|
||||
deleteJobId = jobId;
|
||||
deleteOpen = true;
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteJobId) return;
|
||||
try {
|
||||
await playgroundApi.deleteJob(deleteJobId);
|
||||
submittedIds.delete(deleteJobId);
|
||||
if (selectedEntry?.job.id === deleteJobId) selectedKey = null;
|
||||
toast.success(t('studio.deletedToast'));
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -1066,25 +1177,36 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if refGroup}
|
||||
<div class="f-field">
|
||||
<RefImageGrid
|
||||
label={refGroup.label}
|
||||
paths={refPaths}
|
||||
max={refMax}
|
||||
sizes={fileSizes}
|
||||
uploadingName={uploads.uploading[refGroup.nodeId]}
|
||||
uploadingIndex={replacingIndex}
|
||||
rejects={slotRejects[refGroup.nodeId] ?? []}
|
||||
error={showErrors ? errors[refGroup.nodeId] : undefined}
|
||||
allowFromResults
|
||||
onAdd={refAdd}
|
||||
onReplace={refReplace}
|
||||
onRemove={refRemove}
|
||||
onDismissReject={(i) =>
|
||||
setRejects(refGroup.nodeId, (slotRejects[refGroup.nodeId] ?? []).filter((_, j) => j !== i))}
|
||||
onFromResults={() => openPicker(refGroup.nodeId)}
|
||||
/>
|
||||
{#if listGroups.length}
|
||||
<div class="f-field ref-groups">
|
||||
{#each listGroups as entry (entry.nodeId)}
|
||||
<RefImageGrid
|
||||
label={entry.label}
|
||||
mediaKind={listKind(entry)}
|
||||
paths={groupPaths(entry)}
|
||||
max={entry.max ?? 9}
|
||||
sizes={fileSizes}
|
||||
uploadingName={uploads.uploading[entry.nodeId]}
|
||||
uploadingIndex={replacingKey === entry.nodeId ? replacingIndex : -1}
|
||||
rejects={slotRejects[entry.nodeId] ?? []}
|
||||
error={showErrors ? errors[entry.nodeId] : undefined}
|
||||
allowFromResults={entry.kind === 'image_list'}
|
||||
onAdd={(files) => groupAdd(entry, files)}
|
||||
onReplace={(i, file) => groupReplace(entry, i, file)}
|
||||
onRemove={(i) => groupRemove(entry, i)}
|
||||
onDismissReject={(i) =>
|
||||
setRejects(entry.nodeId, (slotRejects[entry.nodeId] ?? []).filter((_, j) => j !== i))}
|
||||
onFromResults={entry.kind === 'image_list' ? () => openPicker(entry.nodeId) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
{#if hasH3Hint}
|
||||
<div class="h3-hint" role="note">
|
||||
<p>{t('wfMedia.h3Limits')}</p>
|
||||
{#each h3SoftWarnings as w (w)}
|
||||
<p class="warn">{w}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1502,11 +1624,20 @@
|
||||
<Icon name="upload" size={13} />
|
||||
{t('studio.sendToImg2Img')}
|
||||
</Button>
|
||||
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
|
||||
<Icon name="download" size={13} />
|
||||
{t('studio.download')}
|
||||
</a>
|
||||
</span>
|
||||
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
|
||||
<Icon name="download" size={13} />
|
||||
{t('studio.download')}
|
||||
</a>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
title={t('studio.deleteBtn')}
|
||||
onclick={() => askDeleteJob(selectedEntry!.job.id)}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
{t('studio.deleteBtn')}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
@@ -1521,7 +1652,8 @@
|
||||
<h2>{t('studio.video.resultsTitle')}</h2>
|
||||
{#if selectedEntry}
|
||||
<span class="panel-hint">
|
||||
{t('studio.lastDone', {
|
||||
{wfName(selectedEntry.job)} ·
|
||||
{t('studio.doneAt', {
|
||||
time: fmtTime(parseDbTs(selectedEntry.job.completed_at ?? selectedEntry.job.created_at)),
|
||||
})}
|
||||
</span>
|
||||
@@ -1594,11 +1726,20 @@
|
||||
<Icon name="retry" size={13} />
|
||||
{t('studio.retryLink')}
|
||||
</Button>
|
||||
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
|
||||
<Icon name="download" size={13} />
|
||||
{t('studio.downloadMp4')}
|
||||
</a>
|
||||
</span>
|
||||
<a class="a-btn secondary sm" href={assetUrl(selectedEntry.path)} download={downloadName(selectedEntry.path)}>
|
||||
<Icon name="download" size={13} />
|
||||
{t('studio.downloadMp4')}
|
||||
</a>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
title={t('studio.deleteBtn')}
|
||||
onclick={() => askDeleteJob(selectedEntry!.job.id)}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
{t('studio.deleteBtn')}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="empty-line">{t('studio.historyEmptyVideo')}</p>
|
||||
@@ -1619,59 +1760,73 @@
|
||||
{kind === 'image' ? t('studio.historyEmptyImage') : t('studio.historyEmptyVideo')}
|
||||
</p>
|
||||
{: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]}
|
||||
{#if kind === 'image'}
|
||||
<img src={assetUrl(job.output_paths[0])} alt={wfName(job)} loading="lazy" />
|
||||
{:else}
|
||||
<video
|
||||
src={assetUrl(job.output_paths[0])}
|
||||
preload="metadata"
|
||||
muted
|
||||
onloadedmetadata={(e) => noteDuration(job.output_paths![0], e)}
|
||||
></video>
|
||||
{#if durations[job.output_paths[0]]}
|
||||
<span class="dur mono">{fmtClock(durations[job.output_paths[0]])}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if job.status === 'failed'}
|
||||
<span class="thumb-fallback failed"><Icon name="alert" size={14} /></span>
|
||||
<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'}
|
||||
class:selected={selectedJobId === job.id}
|
||||
role="button"
|
||||
tabindex={0}
|
||||
aria-label={`${wfName(job)} · ${st.label}`}
|
||||
aria-current={selectedJobId === job.id ? 'true' : undefined}
|
||||
onclick={() => selectFromHistory(job)}
|
||||
onkeydown={(e) => histRowKey(e, job)}
|
||||
>
|
||||
<div class="hist-thumb" class:video={kind === 'video'}>
|
||||
{#if job.output_paths?.[0]}
|
||||
{#if kind === 'image'}
|
||||
<img src={assetUrl(job.output_paths[0])} alt={wfName(job)} loading="lazy" />
|
||||
{:else}
|
||||
<span class="thumb-fallback"><Icon name={kind === 'image' ? 'image' : 'film'} size={14} /></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="hist-body">
|
||||
<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 !== 'failed'}
|
||||
<span class="st {st.tone}" title={st.detail}>{st.label}</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="st fail-link"
|
||||
disabled={retryingId === job.id}
|
||||
onclick={() => retryJob(job)}
|
||||
>
|
||||
{t('studio.retryLink')}
|
||||
</button>
|
||||
<video
|
||||
src={assetUrl(job.output_paths[0])}
|
||||
preload="metadata"
|
||||
muted
|
||||
onloadedmetadata={(e) => noteDuration(job.output_paths![0], e)}
|
||||
></video>
|
||||
{#if durations[job.output_paths[0]]}
|
||||
<span class="dur mono">{fmtClock(durations[job.output_paths[0]])}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if job.status === 'failed'}
|
||||
<span class="thumb-fallback failed"><Icon name="alert" size={14} /></span>
|
||||
{:else}
|
||||
<span class="thumb-fallback"><Icon name={kind === 'image' ? 'image' : 'film'} size={14} /></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="hist-body">
|
||||
<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 !== 'failed'}
|
||||
<span class="st {st.tone}" title={st.detail}>{st.label}</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="hist-del"
|
||||
title={t('studio.deleteBtn')}
|
||||
onclick={() => {
|
||||
deleteJobId = job.id;
|
||||
deleteOpen = true;
|
||||
class="st fail-link"
|
||||
disabled={retryingId === job.id}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
retryJob(job);
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
{t('studio.retryLink')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="hist-del"
|
||||
title={t('studio.deleteBtn')}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
askDeleteJob(job.id);
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</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')}
|
||||
@@ -1711,9 +1866,10 @@
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('studio.delTitle')}
|
||||
message={t('studio.delConfirm')}
|
||||
title={deleteTitle}
|
||||
message={deleteMessage}
|
||||
confirmLabel={t('studio.deleteBtn')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => {
|
||||
@@ -1723,6 +1879,26 @@
|
||||
/>
|
||||
|
||||
<style>
|
||||
.ref-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.h3-hint {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.h3-hint p {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.h3-hint p.warn {
|
||||
color: var(--accent);
|
||||
}
|
||||
.studio-page {
|
||||
display: grid;
|
||||
grid-template-columns: 352px minmax(0, 1fr) 296px;
|
||||
@@ -2295,10 +2471,23 @@
|
||||
border-radius: 8px;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hist-row:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.hist-row:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
/* The row loaded into the preview stays marked, and its delete stays visible. */
|
||||
.hist-row.selected {
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 2px 0 0 var(--accent);
|
||||
}
|
||||
.hist-row.selected .hist-del {
|
||||
opacity: 1;
|
||||
}
|
||||
.hist-row.failed {
|
||||
background: var(--error-dim);
|
||||
}
|
||||
@@ -2424,6 +2613,15 @@
|
||||
.hist-row:hover .hist-del {
|
||||
opacity: 1;
|
||||
}
|
||||
.hist-del:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Touch devices have no hover to reveal the delete affordance. */
|
||||
@media (hover: none) {
|
||||
.hist-del {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.hist-del:hover {
|
||||
color: var(--error);
|
||||
background: var(--error-dim);
|
||||
|
||||
@@ -1462,7 +1462,7 @@ export const de: Dict = {
|
||||
'studio.image.historyTitle': 'Bildverlauf',
|
||||
'studio.video.historyTitle': 'Videoverlauf',
|
||||
'studio.historyCount': '{n} gesamt',
|
||||
'studio.lastDone': 'Zuletzt fertig · {time}',
|
||||
'studio.doneAt': 'Fertiggestellt um {time}',
|
||||
'studio.kindImageText': 'Bild',
|
||||
'studio.kindVideoText': 'Video',
|
||||
'studio.loading': 'Lädt…',
|
||||
@@ -1578,7 +1578,10 @@ export const de: Dict = {
|
||||
'studio.cancelToast': 'Abbruch angefordert',
|
||||
'studio.deleteBtn': 'Löschen',
|
||||
'studio.delTitle': 'Aufgabeneintrag löschen',
|
||||
'studio.delConfirm': 'Die Ergebnisdateien werden mitgelöscht. Löschen?',
|
||||
'studio.delConfirm': 'Beim Löschen werden auch die Ergebnisdateien dieses Auftrags entfernt; der Verlauf ist nicht wiederherstellbar. Löschen?',
|
||||
'studio.delTitleJob': 'Auftrag „{name}“ löschen',
|
||||
'studio.delConfirmMulti': 'Dieser Auftrag hat {n} Ergebnisse. Beim Löschen werden der gesamte Auftragsdatensatz und alle {n} Ergebnisdateien entfernt; dies ist unwiderruflich. Löschen?',
|
||||
'studio.deletedToast': 'Auftrag und zugehörige Ergebnisdateien gelöscht',
|
||||
'studio.wfEmptyTitle': 'Noch keine {kind}-Workflows',
|
||||
'studio.wfEmptyBody': 'Entwicklermodus in ComfyUI aktivieren, Workflow als API-JSON exportieren und auf der Seite ComfyUI-Verbindungen als {kind} importieren.',
|
||||
'studio.wfEmptyGo': 'In ComfyUI-Verbindungen importieren',
|
||||
@@ -1872,4 +1875,53 @@ export const de: Dict = {
|
||||
'studio.doneToast': 'Fertig: {n} Datei(en) (ComfyUI-Prompt-ID {id})',
|
||||
'studio.dryRunToast': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
|
||||
'studio.dryRunNote': 'Dry-run ist aktiv: Generieren schreibt nur Platzhalter und ruft ComfyUI nie auf. In den Einstellungen abschalten.',
|
||||
// --- HEL-643: Import-Prüfung / Echtlauf-Test / Mediengruppen ---
|
||||
'studio.errVideoFormat': '„{name}“ ist kein unterstütztes Videoformat — MP4 / WebM / MOV verwenden',
|
||||
'studio.errAudioFormat': '„{name}“ ist kein unterstütztes Audioformat — MP3 / WAV / M4A verwenden',
|
||||
'studio.errMediaSize': '„{name}“ überschreitet die Größenbeschränkung — bitte vorher komprimieren',
|
||||
'studio.errNeedRefMedia': '„{label}“ benötigt mindestens {n} Clips',
|
||||
'studio.refHintMedia': 'Pflicht · 1–{max} Clips · {n} ausgewählt',
|
||||
'studio.refAddMedia': '{kind} hinzufügen',
|
||||
'studio.refAddSubMedia': 'Noch {n} · max {max}',
|
||||
'studio.refRemoveMedia': '{kind} {n} entfernen',
|
||||
'studio.refFullMedia': 'Maximum von {max} Clips erreicht — erst eines entfernen',
|
||||
'wfMedia.h3Limits': 'Offizielle Limits des MiniMax H3 Ref2VA: bis zu 9 Referenzbilder, 3 Referenzvideos und 3 Referenzaudios; je Clip 2–15 s, Summen je Typ ≤15 s; Audio allein ist keine gültige Referenz; insgesamt höchstens 12 Referenzdateien.',
|
||||
'wfMedia.h3TotalOver': '{n} Referenzdateien — über dem offiziellen Limit von 12; die Generierung kann fehlschlagen',
|
||||
'wfMedia.h3AudioAlone': 'Audio allein kann nicht die einzige Referenz sein — mindestens ein Bild oder Video ergänzen',
|
||||
'conn.wfCheckTitle': 'Kompatibilitätsprüfung der Verbindung',
|
||||
'conn.wfCheckEndpoint': 'Ziel: {name}',
|
||||
'conn.wfCheckBtn': 'Erneut prüfen',
|
||||
'conn.wfChecking': 'Prüfe…',
|
||||
'conn.wfCheckOk': 'Diese Verbindung besitzt die Knoten und Modelle, die dieser Workflow braucht',
|
||||
'conn.wfCheckProblems': '{n} fehlende Knoten/Modelle gefunden',
|
||||
'conn.wfCheckUnreachable': 'Keine Prüfung möglich (ComfyUI unerreichbar) — Speichern bleibt möglich; nach Reparatur erneut prüfen',
|
||||
'conn.wfCheckSaveHint': 'Der Entwurf kann trotzdem gespeichert werden (wird nicht als generierbar markiert); Fehlendes installieren und erneut prüfen.',
|
||||
'conn.wfTestBtn': 'Testen',
|
||||
'conn.wfLastTest': 'Letzter Test',
|
||||
'conn.wfTestPassed': '{time} bestanden ({n} Dateien)',
|
||||
'conn.wfTestRunning': '{time} läuft',
|
||||
'conn.wfTestFailed': '{time} nicht bestanden',
|
||||
'wft.title': 'Workflow testen',
|
||||
'wft.connection': 'Zielverbindung',
|
||||
'wft.costNote': 'Ein Test schickt einen echten Auftrag an den gewählten ComfyUI und belegt VRAM und Warteschlange.',
|
||||
'wft.submit': 'Test starten',
|
||||
'wft.confirmTitle': 'Echten Lauf bestätigen',
|
||||
'wft.confirmBody': 'An „{endpoint}“ wird ein echter Auftrag gesendet ({n} Aufträge in der Warteschlange). Der Test gilt nur als bestanden, wenn mindestens eine herunterladbare Ausgabedatei des richtigen Typs zurückkommt.',
|
||||
'wft.running': 'Test läuft…',
|
||||
'wft.cancel': 'Test abbrechen',
|
||||
'wft.passed': 'Test bestanden: {n} Datei(en) erzeugt',
|
||||
'wft.passedShort': 'Bestanden ({n} Dateien)',
|
||||
'wft.failed': 'Test nicht bestanden',
|
||||
'wft.failedShort': 'Nicht bestanden',
|
||||
'wft.cancelledShort': 'Abgebrochen',
|
||||
'wft.reusedToast': 'Für diesen Workflow läuft bereits ein Test — angebunden',
|
||||
'wft.history': 'Testverlauf ({n})',
|
||||
'wft.clear': 'Testverlauf löschen',
|
||||
'wft.cleared': 'Testverlauf gelöscht',
|
||||
'wft.never': 'Nie getestet',
|
||||
'wft.download': 'Herunterladen',
|
||||
'wft.errRequired': 'Bitte „{label}“ ausfüllen',
|
||||
'wft.errListMin': '„{label}“ benötigt mindestens {n} {unit}',
|
||||
'tasks.testBadge': 'Test',
|
||||
'tasks.sourceWorkflowTest': 'Workflow-Test',
|
||||
};
|
||||
|
||||
@@ -1461,7 +1461,7 @@ export const en = {
|
||||
'studio.image.historyTitle': 'Image history',
|
||||
'studio.video.historyTitle': 'Video history',
|
||||
'studio.historyCount': '{n} total',
|
||||
'studio.lastDone': 'Last done · {time}',
|
||||
'studio.doneAt': 'Done at {time}',
|
||||
'studio.kindImageText': 'image',
|
||||
'studio.kindVideoText': 'video',
|
||||
'studio.loading': 'Loading…',
|
||||
@@ -1577,7 +1577,10 @@ export const en = {
|
||||
'studio.cancelToast': 'Cancel requested',
|
||||
'studio.deleteBtn': 'Delete',
|
||||
'studio.delTitle': 'Delete task record',
|
||||
'studio.delConfirm': 'The result files will be removed together with the record. Delete it?',
|
||||
'studio.delConfirm': 'Deleting also removes this job’s result files; the history cannot be restored. Delete?',
|
||||
'studio.delTitleJob': 'Delete job “{name}”',
|
||||
'studio.delConfirmMulti': 'This job produced {n} results. Deleting removes the whole job record and all {n} result files; this cannot be undone. Delete?',
|
||||
'studio.deletedToast': 'Job deleted together with its result files',
|
||||
'studio.wfEmptyTitle': 'No {kind} workflows yet',
|
||||
'studio.wfEmptyBody': "Enable ComfyUI's developer mode, export the workflow as API JSON, then import it on the ComfyUI Connections page as {kind}.",
|
||||
'studio.wfEmptyGo': 'Import in ComfyUI Connections',
|
||||
@@ -1871,6 +1874,55 @@ export const en = {
|
||||
'studio.doneToast': 'Done: {n} file(s) (ComfyUI prompt ID {id})',
|
||||
'studio.dryRunToast': 'Placeholder only: Dry-run is on, ComfyUI was not called',
|
||||
'studio.dryRunNote': 'Dry-run is on: Generate only writes placeholder files and never calls ComfyUI. Turn it off in Settings.',
|
||||
// --- HEL-643: workflow import check / real test / media groups ---
|
||||
'studio.errVideoFormat': '"{name}" is not a supported video format — use MP4 / WebM / MOV',
|
||||
'studio.errAudioFormat': '"{name}" is not a supported audio format — use MP3 / WAV / M4A',
|
||||
'studio.errMediaSize': '"{name}" exceeds the size limit — compress it first',
|
||||
'studio.errNeedRefMedia': '"{label}" needs at least {n} clips',
|
||||
'studio.refHintMedia': 'Required · 1–{max} clips · {n} selected',
|
||||
'studio.refAddMedia': 'Add {kind}',
|
||||
'studio.refAddSubMedia': '{n} more · max {max}',
|
||||
'studio.refRemoveMedia': 'Remove {kind} {n}',
|
||||
'studio.refFullMedia': 'Limit of {max} clips reached — remove one to add another',
|
||||
'wfMedia.h3Limits': 'MiniMax H3 Ref2VA official limits: up to 9 reference images, 3 reference videos and 3 reference audios; each clip 2–15s with video/audio totals ≤15s each; audio cannot be the only reference; at most 12 reference files in total.',
|
||||
'wfMedia.h3TotalOver': '{n} reference files selected — above the official cap of 12; generation may fail',
|
||||
'wfMedia.h3AudioAlone': 'Audio cannot be the only reference — add at least one reference image or video',
|
||||
'conn.wfCheckTitle': 'Connection compatibility check',
|
||||
'conn.wfCheckEndpoint': 'Target: {name}',
|
||||
'conn.wfCheckBtn': 'Re-check',
|
||||
'conn.wfChecking': 'Checking…',
|
||||
'conn.wfCheckOk': 'This connection has the nodes and models this workflow needs',
|
||||
'conn.wfCheckProblems': '{n} missing node(s)/model(s) found',
|
||||
'conn.wfCheckUnreachable': 'Cannot verify (ComfyUI unreachable) — saving stays possible; re-check after fixing the connection',
|
||||
'conn.wfCheckSaveHint': 'You can still save the draft (it is not marked generatable); install the missing pieces and re-check.',
|
||||
'conn.wfTestBtn': 'Test',
|
||||
'conn.wfLastTest': 'Last test',
|
||||
'conn.wfTestPassed': '{time} passed ({n} files)',
|
||||
'conn.wfTestRunning': '{time} running',
|
||||
'conn.wfTestFailed': '{time} failed',
|
||||
'wft.title': 'Test workflow',
|
||||
'wft.connection': 'Target connection',
|
||||
'wft.costNote': 'A test submits one real job to the selected ComfyUI and uses its VRAM and queue time.',
|
||||
'wft.submit': 'Start test',
|
||||
'wft.confirmTitle': 'Confirm a real run',
|
||||
'wft.confirmBody': 'One real job will be submitted to "{endpoint}" ({n} jobs in queue). The test only passes when at least one downloadable output of the right media type comes back.',
|
||||
'wft.running': 'Testing…',
|
||||
'wft.cancel': 'Cancel test',
|
||||
'wft.passed': 'Test passed: {n} file(s) produced',
|
||||
'wft.passedShort': 'Passed ({n} files)',
|
||||
'wft.failed': 'Test failed',
|
||||
'wft.failedShort': 'Failed',
|
||||
'wft.cancelledShort': 'Cancelled',
|
||||
'wft.reusedToast': 'A test is already running for this workflow — attached to it',
|
||||
'wft.history': 'Test records ({n})',
|
||||
'wft.clear': 'Clear test records',
|
||||
'wft.cleared': 'Test records cleared',
|
||||
'wft.never': 'Never tested',
|
||||
'wft.download': 'Download',
|
||||
'wft.errRequired': 'Please fill in "{label}"',
|
||||
'wft.errListMin': '"{label}" needs at least {n} {unit}',
|
||||
'tasks.testBadge': 'Test',
|
||||
'tasks.sourceWorkflowTest': 'Workflow test',
|
||||
};
|
||||
|
||||
export type Dict = typeof en;
|
||||
|
||||
@@ -1462,7 +1462,7 @@ export const es: Dict = {
|
||||
'studio.image.historyTitle': 'Historial de imágenes',
|
||||
'studio.video.historyTitle': 'Historial de vídeos',
|
||||
'studio.historyCount': '{n} en total',
|
||||
'studio.lastDone': 'Último · {time}',
|
||||
'studio.doneAt': 'Completado a las {time}',
|
||||
'studio.kindImageText': 'imagen',
|
||||
'studio.kindVideoText': 'vídeo',
|
||||
'studio.loading': 'Cargando…',
|
||||
@@ -1578,7 +1578,10 @@ export const es: Dict = {
|
||||
'studio.cancelToast': 'Cancelación solicitada',
|
||||
'studio.deleteBtn': 'Eliminar',
|
||||
'studio.delTitle': 'Eliminar registro de tarea',
|
||||
'studio.delConfirm': 'Los archivos de resultados se eliminarán junto con el registro. ¿Eliminar?',
|
||||
'studio.delConfirm': 'Al eliminar también se quitan los archivos de resultados de esta tarea; el historial no se puede restaurar. ¿Eliminar?',
|
||||
'studio.delTitleJob': 'Eliminar tarea «{name}»',
|
||||
'studio.delConfirmMulti': 'Esta tarea produjo {n} resultados. Al eliminarla se quita el registro completo y los {n} archivos de resultados; no se puede deshacer. ¿Eliminar?',
|
||||
'studio.deletedToast': 'Tarea eliminada junto con sus archivos de resultados',
|
||||
'studio.wfEmptyTitle': 'Aún no hay flujos de {kind}',
|
||||
'studio.wfEmptyBody': 'Activa el modo desarrollador de ComfyUI, exporta el flujo como JSON API e impórtalo en Conexiones ComfyUI como {kind}.',
|
||||
'studio.wfEmptyGo': 'Importar en Conexiones ComfyUI',
|
||||
@@ -1872,4 +1875,53 @@ export const es: Dict = {
|
||||
'studio.doneToast': 'Listo: {n} archivo(s) (ID de prompt de ComfyUI {id})',
|
||||
'studio.dryRunToast': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
|
||||
'studio.dryRunNote': 'Dry-run activo: Generar solo escribe marcadores y nunca llama a ComfyUI. Desactívalo en Ajustes.',
|
||||
// --- HEL-643: verificación de importación / prueba real / grupos de medios ---
|
||||
'studio.errVideoFormat': '«{name}» no es un formato de vídeo admitido: usa MP4 / WebM / MOV',
|
||||
'studio.errAudioFormat': '«{name}» no es un formato de audio admitido: usa MP3 / WAV / M4A',
|
||||
'studio.errMediaSize': '«{name}» supera el límite de tamaño, comprímelo primero',
|
||||
'studio.errNeedRefMedia': '«{label}» necesita al menos {n} clips',
|
||||
'studio.refHintMedia': 'Obligatorio · 1–{max} clips · {n} seleccionados',
|
||||
'studio.refAddMedia': 'Añadir {kind}',
|
||||
'studio.refAddSubMedia': 'Quedan {n} · máx. {max}',
|
||||
'studio.refRemoveMedia': 'Quitar {kind} {n}',
|
||||
'studio.refFullMedia': 'Límite de {max} clips alcanzado: quita uno para añadir otro',
|
||||
'wfMedia.h3Limits': 'Límites oficiales de MiniMax H3 Ref2VA: hasta 9 imágenes, 3 vídeos y 3 audios de referencia; cada clip de 2–15 s con totales ≤15 s por tipo; el audio no puede ser la única referencia; 12 archivos de referencia como máximo.',
|
||||
'wfMedia.h3TotalOver': '{n} archivos de referencia: por encima del límite oficial de 12; la generación puede fallar',
|
||||
'wfMedia.h3AudioAlone': 'El audio no puede ser la única referencia: añade al menos una imagen o un vídeo',
|
||||
'conn.wfCheckTitle': 'Comprobación de compatibilidad de la conexión',
|
||||
'conn.wfCheckEndpoint': 'Destino: {name}',
|
||||
'conn.wfCheckBtn': 'Volver a comprobar',
|
||||
'conn.wfChecking': 'Comprobando…',
|
||||
'conn.wfCheckOk': 'Esta conexión tiene los nodos y modelos que necesita este flujo',
|
||||
'conn.wfCheckProblems': 'Se encontraron {n} nodos/modelos ausentes',
|
||||
'conn.wfCheckUnreachable': 'No se puede verificar (ComfyUI inaccesible): puedes guardar igualmente; vuelve a comprobar tras arreglar la conexión',
|
||||
'conn.wfCheckSaveHint': 'Puedes guardar el borrador (no se marcará como generable); instala lo que falta y vuelve a comprobar.',
|
||||
'conn.wfTestBtn': 'Probar',
|
||||
'conn.wfLastTest': 'Última prueba',
|
||||
'conn.wfTestPassed': '{time} superada ({n} archivos)',
|
||||
'conn.wfTestRunning': '{time} en curso',
|
||||
'conn.wfTestFailed': '{time} no superada',
|
||||
'wft.title': 'Probar flujo',
|
||||
'wft.connection': 'Conexión de destino',
|
||||
'wft.costNote': 'La prueba envía un trabajo real al ComfyUI elegido y usa su VRAM y su cola.',
|
||||
'wft.submit': 'Iniciar prueba',
|
||||
'wft.confirmTitle': 'Confirmar ejecución real',
|
||||
'wft.confirmBody': 'Se enviará un trabajo real a «{endpoint}» ({n} trabajos en cola). La prueba solo se aprueba si vuelve al menos un archivo descargable del tipo correcto.',
|
||||
'wft.running': 'Probando…',
|
||||
'wft.cancel': 'Cancelar prueba',
|
||||
'wft.passed': 'Prueba superada: {n} archivo(s) producidos',
|
||||
'wft.passedShort': 'Superada ({n} archivos)',
|
||||
'wft.failed': 'Prueba no superada',
|
||||
'wft.failedShort': 'No superada',
|
||||
'wft.cancelledShort': 'Cancelada',
|
||||
'wft.reusedToast': 'Ya hay una prueba en curso para este flujo: se ha retomado',
|
||||
'wft.history': 'Registros de prueba ({n})',
|
||||
'wft.clear': 'Borrar registros de prueba',
|
||||
'wft.cleared': 'Registros de prueba borrados',
|
||||
'wft.never': 'Nunca probado',
|
||||
'wft.download': 'Descargar',
|
||||
'wft.errRequired': 'Rellena «{label}»',
|
||||
'wft.errListMin': '«{label}» necesita al menos {n} {unit}',
|
||||
'tasks.testBadge': 'Prueba',
|
||||
'tasks.sourceWorkflowTest': 'Prueba de flujo',
|
||||
};
|
||||
|
||||
@@ -1462,7 +1462,7 @@ export const fr: Dict = {
|
||||
'studio.image.historyTitle': 'Historique images',
|
||||
'studio.video.historyTitle': 'Historique vidéos',
|
||||
'studio.historyCount': '{n} au total',
|
||||
'studio.lastDone': 'Dernier · {time}',
|
||||
'studio.doneAt': 'Terminé à {time}',
|
||||
'studio.kindImageText': 'image',
|
||||
'studio.kindVideoText': 'vidéo',
|
||||
'studio.loading': 'Chargement…',
|
||||
@@ -1578,7 +1578,10 @@ export const fr: Dict = {
|
||||
'studio.cancelToast': 'Annulation demandée',
|
||||
'studio.deleteBtn': 'Supprimer',
|
||||
'studio.delTitle': 'Supprimer la tâche',
|
||||
'studio.delConfirm': 'Les fichiers de résultats seront supprimés avec la tâche. Supprimer ?',
|
||||
'studio.delConfirm': 'La suppression retire aussi les fichiers de résultats de cette tâche ; l’historique est irrécupérable. Supprimer ?',
|
||||
'studio.delTitleJob': 'Supprimer la tâche « {name} »',
|
||||
'studio.delConfirmMulti': 'Cette tâche a produit {n} résultats. La suppression retire la tâche entière et ses {n} fichiers de résultats ; action irréversible. Supprimer ?',
|
||||
'studio.deletedToast': 'Tâche supprimée avec ses fichiers de résultats',
|
||||
'studio.wfEmptyTitle': 'Pas encore de workflows {kind}',
|
||||
'studio.wfEmptyBody': 'Activez le mode développeur de ComfyUI, exportez le workflow en JSON API, puis importez-le dans Connexions ComfyUI comme {kind}.',
|
||||
'studio.wfEmptyGo': 'Importer dans Connexions ComfyUI',
|
||||
@@ -1872,4 +1875,53 @@ export const fr: Dict = {
|
||||
'studio.doneToast': 'Terminé : {n} fichier(s) (ID de prompt ComfyUI {id})',
|
||||
'studio.dryRunToast': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
|
||||
'studio.dryRunNote': 'Dry-run actif : Générer n\'écrit que des substituts et n\'appelle jamais ComfyUI. Désactivez-le dans Réglages.',
|
||||
// --- HEL-643 : vérification d'import / test réel / groupes de médias ---
|
||||
'studio.errVideoFormat': "« {name} » n'est pas un format vidéo pris en charge — utilisez MP4 / WebM / MOV",
|
||||
'studio.errAudioFormat': "« {name} » n'est pas un format audio pris en charge — utilisez MP3 / WAV / M4A",
|
||||
'studio.errMediaSize': "« {name} » dépasse la limite de taille — compressez-le d'abord",
|
||||
'studio.errNeedRefMedia': '« {label} » exige au moins {n} clips',
|
||||
'studio.refHintMedia': 'Requis · 1–{max} clips · {n} sélectionnés',
|
||||
'studio.refAddMedia': 'Ajouter {kind}',
|
||||
'studio.refAddSubMedia': '{n} de plus · max {max}',
|
||||
'studio.refRemoveMedia': 'Retirer {kind} {n}',
|
||||
'studio.refFullMedia': 'Limite de {max} clips atteinte — retirez-en un pour en ajouter un autre',
|
||||
'wfMedia.h3Limits': "Limites officielles MiniMax H3 Ref2VA : jusqu'à 9 images, 3 vidéos et 3 audios de référence ; chaque clip de 2–15 s, totaux ≤15 s par type ; l'audio ne peut être la seule référence ; 12 fichiers de référence au maximum.",
|
||||
'wfMedia.h3TotalOver': '{n} fichiers de référence — au-delà du plafond officiel de 12 ; la génération peut échouer',
|
||||
'wfMedia.h3AudioAlone': "L'audio ne peut pas être la seule référence — ajoutez au moins une image ou une vidéo",
|
||||
'conn.wfCheckTitle': 'Vérification de compatibilité de la connexion',
|
||||
'conn.wfCheckEndpoint': 'Cible : {name}',
|
||||
'conn.wfCheckBtn': 'Revérifier',
|
||||
'conn.wfChecking': 'Vérification…',
|
||||
'conn.wfCheckOk': 'Cette connexion possède les nœuds et modèles requis par ce flux',
|
||||
'conn.wfCheckProblems': '{n} nœud(s)/modèle(s) manquant(s) détecté(s)',
|
||||
'conn.wfCheckUnreachable': "Vérification impossible (ComfyUI injoignable) — l'enregistrement reste possible ; revérifiez après avoir réparé la connexion",
|
||||
'conn.wfCheckSaveHint': 'Vous pouvez enregistrer le brouillon (il ne sera pas marqué générable) ; installez le manquant puis revérifiez.',
|
||||
'conn.wfTestBtn': 'Tester',
|
||||
'conn.wfLastTest': 'Dernier test',
|
||||
'conn.wfTestPassed': '{time} réussi ({n} fichiers)',
|
||||
'conn.wfTestRunning': '{time} en cours',
|
||||
'conn.wfTestFailed': '{time} échoué',
|
||||
'wft.title': 'Tester le flux',
|
||||
'wft.connection': 'Connexion cible',
|
||||
'wft.costNote': 'Un test envoie une tâche réelle au ComfyUI choisi et mobilise son VRAM et sa file.',
|
||||
'wft.submit': 'Lancer le test',
|
||||
'wft.confirmTitle': 'Confirmer une exécution réelle',
|
||||
'wft.confirmBody': 'Une tâche réelle sera envoyée à « {endpoint} » ({n} tâches en file). Le test ne réussit que si au moins un fichier téléchargeable du bon type revient.',
|
||||
'wft.running': 'Test en cours…',
|
||||
'wft.cancel': 'Annuler le test',
|
||||
'wft.passed': 'Test réussi : {n} fichier(s) produit(s)',
|
||||
'wft.passedShort': 'Réussi ({n} fichiers)',
|
||||
'wft.failed': 'Test échoué',
|
||||
'wft.failedShort': 'Échoué',
|
||||
'wft.cancelledShort': 'Annulé',
|
||||
'wft.reusedToast': 'Un test est déjà en cours pour ce flux — rattaché à celui-ci',
|
||||
'wft.history': 'Historique des tests ({n})',
|
||||
'wft.clear': "Effacer l'historique des tests",
|
||||
'wft.cleared': 'Historique des tests effacé',
|
||||
'wft.never': 'Jamais testé',
|
||||
'wft.download': 'Télécharger',
|
||||
'wft.errRequired': 'Veuillez renseigner « {label} »',
|
||||
'wft.errListMin': '« {label} » exige au moins {n} {unit}',
|
||||
'tasks.testBadge': 'Test',
|
||||
'tasks.sourceWorkflowTest': 'Test de flux',
|
||||
};
|
||||
|
||||
@@ -1462,7 +1462,7 @@ export const ja: Dict = {
|
||||
'studio.image.historyTitle': '画像生成履歴',
|
||||
'studio.video.historyTitle': '動画生成履歴',
|
||||
'studio.historyCount': '計 {n} 回',
|
||||
'studio.lastDone': '最終完了 · {time}',
|
||||
'studio.doneAt': '完了 {time}',
|
||||
'studio.kindImageText': '画像',
|
||||
'studio.kindVideoText': '動画',
|
||||
'studio.loading': '読み込み中…',
|
||||
@@ -1578,7 +1578,10 @@ export const ja: Dict = {
|
||||
'studio.cancelToast': 'キャンセルを要求しました',
|
||||
'studio.deleteBtn': '削除',
|
||||
'studio.delTitle': 'タスク記録を削除',
|
||||
'studio.delConfirm': '結果ファイルも一緒に削除され、履歴は復元できません。削除しますか?',
|
||||
'studio.delConfirm': '削除するとこのタスクの結果ファイルも削除され、履歴は元に戻せません。削除しますか?',
|
||||
'studio.delTitleJob': 'タスク「{name}」を削除',
|
||||
'studio.delConfirmMulti': 'このタスクには結果が {n} 件あります。削除するとタスク記録と {n} 件すべての結果ファイルが失われ、元に戻せません。削除しますか?',
|
||||
'studio.deletedToast': 'タスクと結果ファイルを削除しました',
|
||||
'studio.wfEmptyTitle': '{kind}ワークフローがまだありません',
|
||||
'studio.wfEmptyBody': 'ComfyUI の開発者モードで API 形式 JSON を書き出し、「ComfyUI 接続」ページで{kind}として取り込んでください。',
|
||||
'studio.wfEmptyGo': '「ComfyUI 接続」で取り込む',
|
||||
@@ -1872,4 +1875,53 @@ export const ja: Dict = {
|
||||
'studio.doneToast': '完了:{n} ファイル(ComfyUI プロンプト ID {id})',
|
||||
'studio.dryRunToast': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
|
||||
'studio.dryRunNote': 'Dry-run が有効です:生成はプレースホルダーを書き出すだけで ComfyUI を呼び出しません。設定でオフにできます。',
|
||||
// --- HEL-643: インポート検査 / 実テスト / メディアグループ ---
|
||||
'studio.errVideoFormat': '「{name}」は未対応の動画形式です。MP4 / WebM / MOV を使ってください',
|
||||
'studio.errAudioFormat': '「{name}」は未対応の音声形式です。MP3 / WAV / M4A を使ってください',
|
||||
'studio.errMediaSize': '「{name}」はサイズ上限を超えています。先に圧縮してください',
|
||||
'studio.errNeedRefMedia': '「{label}」は最低 {n} 本必要です',
|
||||
'studio.refHintMedia': '必須 · 1–{max} 本 · 選択 {n}',
|
||||
'studio.refAddMedia': '{kind}を追加',
|
||||
'studio.refAddSubMedia': 'あと {n} 本 · 上限 {max}',
|
||||
'studio.refRemoveMedia': '{kind} {n} を削除',
|
||||
'studio.refFullMedia': '上限 {max} 本に達しました。削除してから追加してください',
|
||||
'wfMedia.h3Limits': 'MiniMax H3 Ref2VA の公式上限:参照画像は最大 9 枠、参照動画は 3 本、参照音声は 3 本。各クリップ 2–15 秒で種類ごとの合計 15 秒以下。音声のみの参照は不可。参照ファイルは合計 12 まで。',
|
||||
'wfMedia.h3TotalOver': '参照ファイルが {n} 個あり、公式上限 12 を超えています。生成に失敗する可能性があります',
|
||||
'wfMedia.h3AudioAlone': '音声だけで参照にすることはできません。参照画像か参照動画を少なくとも 1 つ追加してください',
|
||||
'conn.wfCheckTitle': '接続互換性チェック',
|
||||
'conn.wfCheckEndpoint': '対象:{name}',
|
||||
'conn.wfCheckBtn': '再チェック',
|
||||
'conn.wfChecking': 'チェック中…',
|
||||
'conn.wfCheckOk': 'この接続にはこのワークフローに必要なノードとモデルがあります',
|
||||
'conn.wfCheckProblems': '不足しているノード/モデルが {n} 件見つかりました',
|
||||
'conn.wfCheckUnreachable': '確認できません(ComfyUI に接続不可)。保存は可能です。接続を直してから再チェックしてください',
|
||||
'conn.wfCheckSaveHint': 'ドラフトは保存できます(生成可能とは表示されません)。不足項目を入れて再チェックしてください。',
|
||||
'conn.wfTestBtn': 'テスト',
|
||||
'conn.wfLastTest': '前回のテスト',
|
||||
'conn.wfTestPassed': '{time} 合格({n} ファイル)',
|
||||
'conn.wfTestRunning': '{time} 実行中',
|
||||
'conn.wfTestFailed': '{time} 不合格',
|
||||
'wft.title': 'ワークフローをテスト',
|
||||
'wft.connection': '対象接続',
|
||||
'wft.costNote': 'テストは選択した ComfyUI に実際のジョブを投入し、VRAM と待ち時間を消費します。',
|
||||
'wft.submit': 'テスト開始',
|
||||
'wft.confirmTitle': '実行の確認',
|
||||
'wft.confirmBody': '「{endpoint}」へ実際のジョブを 1 件投入します(現在のキュー {n} 件)。正しい種類のダウンロード可能な出力が 1 つ以上戻った場合のみ合格です。',
|
||||
'wft.running': 'テスト中…',
|
||||
'wft.cancel': 'テストを中止',
|
||||
'wft.passed': 'テスト合格:{n} ファイル生成',
|
||||
'wft.passedShort': '合格({n} ファイル)',
|
||||
'wft.failed': 'テスト不合格',
|
||||
'wft.failedShort': '不合格',
|
||||
'wft.cancelledShort': '中止しました',
|
||||
'wft.reusedToast': 'このワークフローはテスト実行中のため、そのジョブに接続しました',
|
||||
'wft.history': 'テスト記録({n} 件)',
|
||||
'wft.clear': 'テスト記録を消去',
|
||||
'wft.cleared': 'テスト記録を消去しました',
|
||||
'wft.never': '未テスト',
|
||||
'wft.download': 'ダウンロード',
|
||||
'wft.errRequired': '「{label}」を入力してください',
|
||||
'wft.errListMin': '「{label}」は最低 {n} {unit} 必要です',
|
||||
'tasks.testBadge': 'テスト',
|
||||
'tasks.sourceWorkflowTest': 'ワークフローテスト',
|
||||
};
|
||||
|
||||
@@ -1462,7 +1462,7 @@ export const ko: Dict = {
|
||||
'studio.image.historyTitle': '이미지 생성 기록',
|
||||
'studio.video.historyTitle': '영상 생성 기록',
|
||||
'studio.historyCount': '총 {n}회',
|
||||
'studio.lastDone': '최근 완료 · {time}',
|
||||
'studio.doneAt': '완료 {time}',
|
||||
'studio.kindImageText': '이미지',
|
||||
'studio.kindVideoText': '비디오',
|
||||
'studio.loading': '불러오는 중…',
|
||||
@@ -1578,7 +1578,10 @@ export const ko: Dict = {
|
||||
'studio.cancelToast': '취소를 요청했습니다',
|
||||
'studio.deleteBtn': '삭제',
|
||||
'studio.delTitle': '작업 기록 삭제',
|
||||
'studio.delConfirm': '결과 파일도 함께 삭제되며 기록은 복구할 수 없습니다. 삭제할까요?',
|
||||
'studio.delConfirm': '삭제 시 이 작업의 결과 파일도 함께 제거되며 기록은 복구할 수 없습니다. 삭제할까요?',
|
||||
'studio.delTitleJob': '작업 «{name}» 삭제',
|
||||
'studio.delConfirmMulti': '이 작업에는 결과가 {n}개 있습니다. 삭제하면 작업 기록과 결과 파일 {n}개 모두 제거되며 복구할 수 없습니다. 삭제할까요?',
|
||||
'studio.deletedToast': '작업과 결과 파일을 삭제했습니다',
|
||||
'studio.wfEmptyTitle': '{kind} 워크플로가 아직 없습니다',
|
||||
'studio.wfEmptyBody': 'ComfyUI 개발자 모드를 켜고 API 형식 JSON으로 내보낸 뒤, ComfyUI 연결 페이지에서 {kind}(으)로 가져오세요.',
|
||||
'studio.wfEmptyGo': 'ComfyUI 연결에서 가져오기',
|
||||
@@ -1872,4 +1875,53 @@ export const ko: Dict = {
|
||||
'studio.doneToast': '완료: 파일 {n}개 (ComfyUI 프롬프트 ID {id})',
|
||||
'studio.dryRunToast': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
|
||||
'studio.dryRunNote': 'Dry-run이 켜져 있습니다: 생성은 자리표시자만 쓰고 ComfyUI를 호출하지 않습니다. 설정에서 끌 수 있습니다.',
|
||||
// --- HEL-643: 가져오기 검사 / 실제 테스트 / 미디어 그룹 ---
|
||||
'studio.errVideoFormat': '«{name}»은(는) 지원하지 않는 동영상 형식입니다. MP4 / WebM / MOV를 사용하세요',
|
||||
'studio.errAudioFormat': '«{name}»은(는) 지원하지 않는 오디오 형식입니다. MP3 / WAV / M4A를 사용하세요',
|
||||
'studio.errMediaSize': '«{name}»이(가) 크기 제한을 초과합니다. 먼저 압축하세요',
|
||||
'studio.errNeedRefMedia': '«{label}»에 최소 {n}개가 필요합니다',
|
||||
'studio.refHintMedia': '필수 · 1–{max}개 · 선택 {n}',
|
||||
'studio.refAddMedia': '{kind} 추가',
|
||||
'studio.refAddSubMedia': '{n}개 더 · 최대 {max}',
|
||||
'studio.refRemoveMedia': '{kind} {n} 제거',
|
||||
'studio.refFullMedia': '최대 {max}개에 도달했습니다. 제거 후 추가하세요',
|
||||
'wfMedia.h3Limits': 'MiniMax H3 Ref2VA 공식 한도: 참조 이미지 최대 9장, 참조 영상 3개, 참조 오디오 3개. 클립당 2–15초이며 유형별 총합 15초 이하. 오디오만 단독 참조는 불가. 참조 파일은 총 12개까지.',
|
||||
'wfMedia.h3TotalOver': '참조 파일이 {n}개로 공식 한도 12를 초과했습니다. 생성이 실패할 수 있습니다',
|
||||
'wfMedia.h3AudioAlone': '오디오만 단독 참조가 될 수 없습니다. 참조 이미지 또는 영상을 1개 이상 추가하세요',
|
||||
'conn.wfCheckTitle': '연결 호환성 검사',
|
||||
'conn.wfCheckEndpoint': '대상: {name}',
|
||||
'conn.wfCheckBtn': '다시 검사',
|
||||
'conn.wfChecking': '검사 중…',
|
||||
'conn.wfCheckOk': '이 연결은 이 워크플로에 필요한 노드와 모델을 갖추고 있습니다',
|
||||
'conn.wfCheckProblems': '누락된 노드/모델 {n}개를 발견했습니다',
|
||||
'conn.wfCheckUnreachable': '확인할 수 없습니다(ComfyUI 연결 불가). 저장은 가능하며 연결을 고친 뒤 다시 검사하세요',
|
||||
'conn.wfCheckSaveHint': '초안은 저장할 수 있습니다(생성 가능으로 표시되지 않음). 누락 항목을 설치한 뒤 다시 검사하세요.',
|
||||
'conn.wfTestBtn': '테스트',
|
||||
'conn.wfLastTest': '마지막 테스트',
|
||||
'conn.wfTestPassed': '{time} 통과({n}개 파일)',
|
||||
'conn.wfTestRunning': '{time} 진행 중',
|
||||
'conn.wfTestFailed': '{time} 실패',
|
||||
'wft.title': '워크플로 테스트',
|
||||
'wft.connection': '대상 연결',
|
||||
'wft.costNote': '테스트는 선택한 ComfyUI에 실제 작업을 1건 보내 VRAM과 대기열을 사용합니다.',
|
||||
'wft.submit': '테스트 시작',
|
||||
'wft.confirmTitle': '실제 실행 확인',
|
||||
'wft.confirmBody': '«{endpoint}»에 실제 작업 1건을 제출합니다(현재 대기열 {n}건). 올바른 유형의 다운로드 가능한 출력이 1개 이상 돌아와야 통과입니다.',
|
||||
'wft.running': '테스트 중…',
|
||||
'wft.cancel': '테스트 취소',
|
||||
'wft.passed': '테스트 통과: 파일 {n}개 생성',
|
||||
'wft.passedShort': '통과({n}개 파일)',
|
||||
'wft.failed': '테스트 실패',
|
||||
'wft.failedShort': '실패',
|
||||
'wft.cancelledShort': '취소됨',
|
||||
'wft.reusedToast': '이 워크플로는 이미 테스트 진행 중입니다. 해당 작업에 연결했습니다',
|
||||
'wft.history': '테스트 기록({n}건)',
|
||||
'wft.clear': '테스트 기록 삭제',
|
||||
'wft.cleared': '테스트 기록을 삭제했습니다',
|
||||
'wft.never': '테스트 이력 없음',
|
||||
'wft.download': '다운로드',
|
||||
'wft.errRequired': '«{label}»을(를) 입력하세요',
|
||||
'wft.errListMin': '«{label}»에 최소 {n}{unit}이(가) 필요합니다',
|
||||
'tasks.testBadge': '테스트',
|
||||
'tasks.sourceWorkflowTest': '워크플로 테스트',
|
||||
};
|
||||
|
||||
@@ -1461,7 +1461,7 @@ export const zh: Dict = {
|
||||
'studio.image.historyTitle': '生图历史',
|
||||
'studio.video.historyTitle': '生视频历史',
|
||||
'studio.historyCount': '共 {n} 次',
|
||||
'studio.lastDone': '最近完成 · {time}',
|
||||
'studio.doneAt': '完成于 {time}',
|
||||
'studio.kindImageText': '图片',
|
||||
'studio.kindVideoText': '视频',
|
||||
'studio.loading': '正在加载…',
|
||||
@@ -1579,6 +1579,9 @@ export const zh: Dict = {
|
||||
'studio.deleteBtn': '删除',
|
||||
'studio.delTitle': '删除任务记录',
|
||||
'studio.delConfirm': '删除后该任务的结果文件会一并移除,历史不可恢复。确定删除?',
|
||||
'studio.delTitleJob': '删除任务「{name}」',
|
||||
'studio.delConfirmMulti': '该任务共生成 {n} 个结果,删除将移除整条任务记录及其全部 {n} 个结果文件,历史不可恢复。确定删除?',
|
||||
'studio.deletedToast': '已删除该任务及其结果文件',
|
||||
'studio.wfEmptyTitle': '还没有{kind}工作流',
|
||||
'studio.wfEmptyBody': '在 ComfyUI 里启用开发者模式,用「导出(API 格式)」得到 JSON,再到「ComfyUI 连接」页导入并分类为{kind}。',
|
||||
'studio.wfEmptyGo': '去「ComfyUI 连接」导入',
|
||||
@@ -1872,4 +1875,53 @@ export const zh: Dict = {
|
||||
'studio.doneToast': '生成完成:{n} 个文件(ComfyUI 任务 ID {id})',
|
||||
'studio.dryRunToast': '占位完成:当前开启了 Dry-run,没有调用 ComfyUI,结果不是真实生成',
|
||||
'studio.dryRunNote': '已开启 Dry-run:点生成只会写占位文件,不会调用 ComfyUI。可在「设置」中关闭。',
|
||||
// --- HEL-643: workflow import check / real test / media groups ---
|
||||
'studio.errVideoFormat': '「{name}」不是支持的视频格式,请使用 MP4 / WebM / MOV',
|
||||
'studio.errAudioFormat': '「{name}」不是支持的音频格式,请使用 MP3 / WAV / M4A',
|
||||
'studio.errMediaSize': '「{name}」超过大小限制,请压缩后再传',
|
||||
'studio.errNeedRefMedia': '「{label}」需要至少 {n} 段',
|
||||
'studio.refHintMedia': '必填 · 1–{max} 段 · 已选 {n}',
|
||||
'studio.refAddMedia': '添加{kind}',
|
||||
'studio.refAddSubMedia': '还可加 {n} 段 · 上限 {max}',
|
||||
'studio.refRemoveMedia': '移除{kind} {n}',
|
||||
'studio.refFullMedia': '已达上限 {max} 段,移除后才能再添加',
|
||||
'wfMedia.h3Limits': 'MiniMax H3 Ref2VA 官方限额:参考图最多 9 张、参考视频 3 段、参考音频 3 段;单段音视频 2–15 秒,视频与音频各自总时长不超过 15 秒;音频不能单独作为唯一参考;参考文件总数最多 12。',
|
||||
'wfMedia.h3TotalOver': '参考文件总数已达 {n} 个,超过官方上限 12,生成可能失败',
|
||||
'wfMedia.h3AudioAlone': '音频不能单独作为唯一参考,请至少搭配一张参考图或一段参考视频',
|
||||
'conn.wfCheckTitle': '连接兼容性检查',
|
||||
'conn.wfCheckEndpoint': '目标:{name}',
|
||||
'conn.wfCheckBtn': '重新检查',
|
||||
'conn.wfChecking': '检查中…',
|
||||
'conn.wfCheckOk': '该连接具备运行此工作流的节点与模型',
|
||||
'conn.wfCheckProblems': '发现 {n} 个缺失的节点/模型',
|
||||
'conn.wfCheckUnreachable': '无法核查(连不上该 ComfyUI),不阻断保存;修好连接后可重新检查',
|
||||
'conn.wfCheckSaveHint': '仍可保存草稿(不会误称可生成);装好缺失项后回来重新检查即可。',
|
||||
'conn.wfTestBtn': '测试',
|
||||
'conn.wfLastTest': '上次测试',
|
||||
'conn.wfTestPassed': '{time} 通过({n} 个文件)',
|
||||
'conn.wfTestRunning': '{time} 进行中',
|
||||
'conn.wfTestFailed': '{time} 未通过',
|
||||
'wft.title': '测试工作流',
|
||||
'wft.connection': '目标连接',
|
||||
'wft.costNote': '测试会把一次真实任务加入所选 ComfyUI 的队列,占用其显存与排队时间。',
|
||||
'wft.submit': '开始测试',
|
||||
'wft.confirmTitle': '确认真实运行',
|
||||
'wft.confirmBody': '将向「{endpoint}」提交一次真实任务并占用其资源(当前队列 {n} 个任务)。只有拿到可下载且类型正确的输出文件才算测试通过。',
|
||||
'wft.running': '测试中…',
|
||||
'wft.cancel': '取消测试',
|
||||
'wft.passed': '测试通过:产出 {n} 个文件',
|
||||
'wft.passedShort': '通过({n} 个文件)',
|
||||
'wft.failed': '测试未通过',
|
||||
'wft.failedShort': '未通过',
|
||||
'wft.cancelledShort': '已取消',
|
||||
'wft.reusedToast': '该工作流已有进行中的测试,已连接到该任务',
|
||||
'wft.history': '测试记录({n} 条)',
|
||||
'wft.clear': '清除测试记录',
|
||||
'wft.cleared': '已清除测试记录',
|
||||
'wft.never': '尚未测试过',
|
||||
'wft.download': '下载',
|
||||
'wft.errRequired': '请填写「{label}」',
|
||||
'wft.errListMin': '「{label}」需要至少 {n} {unit}',
|
||||
'tasks.testBadge': '测试',
|
||||
'tasks.sourceWorkflowTest': '工作流测试',
|
||||
};
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import type { Job } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import type { Job } from "$lib/api";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
/** Where a generation job came from: 独立生图 / 独立生视频 / 创作流程 / Playground. */
|
||||
/** Where a generation job came from: 独立生图 / 独立生视频 / 创作流程 / Playground / 测试. */
|
||||
export function jobSourceLabel(job: Job): string {
|
||||
const src = job.payload?.source;
|
||||
if (src === 'studio-image') return t('tasks.sourceStudioImage');
|
||||
if (src === 'studio-video') return t('tasks.sourceStudioVideo');
|
||||
if (src === 'workbench') return t('tasks.sourceWorkbench');
|
||||
if (src === 'playground') return t('tasks.sourcePlayground');
|
||||
if (job.scene_id || job.clip_id) return t('tasks.sourceFlow');
|
||||
if (!src) return t('tasks.sourceFlow');
|
||||
return t('tasks.sourceOther');
|
||||
const src = job.payload?.source;
|
||||
if (src === "studio-image") return t("tasks.sourceStudioImage");
|
||||
if (src === "studio-video") return t("tasks.sourceStudioVideo");
|
||||
if (src === "workbench") return t("tasks.sourceWorkbench");
|
||||
if (src === "playground") return t("tasks.sourcePlayground");
|
||||
if (src === "workflow_test" || job.origin === "workflow_test")
|
||||
return t("tasks.sourceWorkflowTest");
|
||||
if (job.scene_id || job.clip_id) return t("tasks.sourceFlow");
|
||||
if (!src) return t("tasks.sourceFlow");
|
||||
return t("tasks.sourceOther");
|
||||
}
|
||||
|
||||
/** Jobs started outside any project (standalone windows / Playground). */
|
||||
/** A real run launched from the workflow library's 测试 button (HEL-643). */
|
||||
export function isWorkflowTestJob(job: Job): boolean {
|
||||
return (
|
||||
job.origin === "workflow_test" || job.payload?.source === "workflow_test"
|
||||
);
|
||||
}
|
||||
|
||||
/** Jobs started outside any project (standalone windows / Playground / tests). */
|
||||
export function isStandaloneJob(job: Job): boolean {
|
||||
const src = job.payload?.source;
|
||||
return src === 'studio-image' || src === 'studio-video' || src === 'playground';
|
||||
const src = job.payload?.source;
|
||||
return (
|
||||
src === "studio-image" ||
|
||||
src === "studio-video" ||
|
||||
src === "playground" ||
|
||||
isWorkflowTestJob(job)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowOutputMapEntry, type WorkflowUsage, type WorkflowAnalyzeResult } from '$lib/api';
|
||||
import WorkflowTestDialog from '$lib/components/WorkflowTestDialog.svelte';
|
||||
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowOutputMapEntry, type WorkflowUsage, type WorkflowAnalyzeResult, type WorkflowConnectionCheck } from '$lib/api';
|
||||
import { WORKFLOW_USAGES } from '$lib/comfy/types';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
@@ -386,12 +387,16 @@
|
||||
return (wf.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message);
|
||||
}
|
||||
|
||||
async function runWfAnalyze() {
|
||||
async function runWfAnalyze(refresh = false) {
|
||||
if (!wfParsed.valid || !wfParsed.json) return;
|
||||
wfAnalyzing = true;
|
||||
wfAnalyzeError = '';
|
||||
try {
|
||||
const res = await workflows.analyze(wfParsed.json);
|
||||
const res = await workflows.analyze(
|
||||
wfParsed.json,
|
||||
wfImportEndpoint || undefined,
|
||||
refresh,
|
||||
);
|
||||
wfAnalysis = res;
|
||||
wfUsage = res.usage ?? null;
|
||||
wfRoleOverrides = {};
|
||||
@@ -523,6 +528,66 @@
|
||||
$wfImportMutation.mutate();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// HEL-643 L2: connection-compatibility check on the import confirm
|
||||
// step, and per-row 重新检查 / 测试 in the workflow library.
|
||||
// ------------------------------------------------------------------
|
||||
const wfConnCheck = $derived<WorkflowConnectionCheck | null>(
|
||||
wfAnalysis?.connection_check ?? null,
|
||||
);
|
||||
let wfRecheckId = $state<number | null>(null);
|
||||
const wfRecheckResult = $state<Record<number, WorkflowConnectionCheck>>({});
|
||||
|
||||
async function recheckRow(wf: Workflow) {
|
||||
wfRecheckId = wf.id;
|
||||
try {
|
||||
const res = await workflows.check(wf.id, { refresh: true });
|
||||
wfRecheckResult[wf.id] = res;
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
wfRecheckId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function recheckLine(wf: Workflow): { tone: 'ok' | 'bad' | 'warn'; text: string } | null {
|
||||
const check = wfRecheckResult[wf.id];
|
||||
if (!check) return null;
|
||||
if (!check.reachable) return { tone: 'warn', text: t('conn.wfCheckUnreachable') };
|
||||
if (check.ok) return { tone: 'ok', text: t('conn.wfCheckOk') };
|
||||
const n = check.findings.length;
|
||||
return { tone: 'bad', text: t('conn.wfCheckProblems', { n }) };
|
||||
}
|
||||
|
||||
let testWf = $state<Workflow | null>(null);
|
||||
let testOpen = $state(false);
|
||||
|
||||
function openTest(wf: Workflow) {
|
||||
testWf = wf;
|
||||
testOpen = true;
|
||||
}
|
||||
|
||||
function lastTestLine(wf: Workflow): string | null {
|
||||
const lt = wf.last_test;
|
||||
if (!lt) return null;
|
||||
if (lt.status === 'done' && lt.output_count > 0) {
|
||||
return t('conn.wfTestPassed', { n: lt.output_count, time: formatDbTime(lt.completed_at ?? lt.created_at) });
|
||||
}
|
||||
if (lt.status === 'pending' || lt.status === 'running') {
|
||||
return t('conn.wfTestRunning', { time: formatDbTime(lt.created_at) });
|
||||
}
|
||||
return t('conn.wfTestFailed', { time: formatDbTime(lt.completed_at ?? lt.created_at) });
|
||||
}
|
||||
|
||||
function formatDbTime(at: string | null): string {
|
||||
if (!at) return '—';
|
||||
const ts = Date.parse(at.replace(' ', 'T') + 'Z');
|
||||
const d = new Date(Number.isFinite(ts) ? ts : at);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const wfConfirmMutation = createMutation({
|
||||
mutationFn: ({ id, usage }: { id: number; usage: WorkflowUsage }) =>
|
||||
workflows.update(id, { usage, usage_source: 'user' }),
|
||||
@@ -934,7 +999,7 @@
|
||||
size="sm"
|
||||
disabled={!wfParsed.valid || !wfImportName.trim()}
|
||||
loading={wfAnalyzing}
|
||||
onclick={runWfAnalyze}
|
||||
onclick={() => runWfAnalyze()}
|
||||
>
|
||||
{wfAnalyzing ? t('conn.wfAnalyzing') : t('conn.wfAnalyze')}
|
||||
</Button>
|
||||
@@ -942,18 +1007,53 @@
|
||||
{:else if wfAnalysis}
|
||||
<p class="wf-import-hint">{t('conn.wfConfirmHint')}</p>
|
||||
|
||||
{#if wfAnalysis.usage_problem}
|
||||
<div class="wf-note" class:fatal={wfFatal} role={wfFatal ? 'alert' : 'note'}>
|
||||
<Icon name={wfFatal ? 'alert' : 'info'} size={14} />
|
||||
<p>{wfAnalysis.usage_problem}</p>
|
||||
{#if wfAnalysis.usage_problem}
|
||||
<div class="wf-note" class:fatal={wfFatal} role={wfFatal ? 'alert' : 'note'}>
|
||||
<Icon name={wfFatal ? 'alert' : 'info'} size={14} />
|
||||
<p>{wfAnalysis.usage_problem}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if wfAnalyzeError}
|
||||
<div class="wf-note fatal" role="alert">
|
||||
<Icon name="alert" size={14} />
|
||||
<p>{wfAnalyzeError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if wfConnCheck}
|
||||
<div class="wf-conn-check" class:ok={wfConnCheck.ok} class:bad={wfConnCheck.ok === false && wfConnCheck.reachable} class:warn={!wfConnCheck.reachable}>
|
||||
<div class="wf-conn-head">
|
||||
<Icon name={wfConnCheck.ok ? 'check' : wfConnCheck.reachable ? 'alert' : 'info'} size={14} />
|
||||
<span class="wf-conn-title">{t('conn.wfCheckTitle')}</span>
|
||||
{#if wfConnCheck.endpoint_name}
|
||||
<span class="wf-conn-ep">{t('conn.wfCheckEndpoint', { name: wfConnCheck.endpoint_name })}</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="mini-link"
|
||||
disabled={wfAnalyzing}
|
||||
onclick={() => runWfAnalyze(true)}
|
||||
>
|
||||
{wfAnalyzing ? t('conn.wfChecking') : t('conn.wfCheckBtn')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if wfAnalyzeError}
|
||||
<div class="wf-note fatal" role="alert">
|
||||
<Icon name="alert" size={14} />
|
||||
<p>{wfAnalyzeError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !wfConnCheck.reachable}
|
||||
<p class="wf-conn-line">{wfConnCheck.message ?? t('conn.wfCheckUnreachable')}</p>
|
||||
{:else if wfConnCheck.ok}
|
||||
<p class="wf-conn-line">{t('conn.wfCheckOk')}</p>
|
||||
{:else}
|
||||
<ul class="wf-conn-findings">
|
||||
{#each wfConnCheck.findings as f (f.node_id + f.code)}
|
||||
<li>
|
||||
<span class="wf-conn-msg">{f.message}</span>
|
||||
{#if f.hint}<span class="wf-conn-hint">{f.hint}</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="wf-conn-line">{t('conn.wfCheckSaveHint')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="wf-evidence">
|
||||
{#each wfAnalysis.usage_evidence.summary as line (line)}
|
||||
@@ -1178,30 +1278,68 @@
|
||||
{t('conn.wfBrokenBadge')}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="wf-params">{t('conn.wfParams', { n: wf.input_schema?.length ?? 0 })}</span>
|
||||
<select
|
||||
class="a-select"
|
||||
value={wf.endpoint_id ?? ''}
|
||||
onchange={(e) =>
|
||||
$wfBindMutation.mutate({
|
||||
id: wf.id,
|
||||
endpointId: e.currentTarget.value || null,
|
||||
})}
|
||||
>
|
||||
<option value="">{t('conn.wfConnectionActive')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="link-danger"
|
||||
title={t('conn.delete')}
|
||||
onclick={() => $wfDeleteMutation.mutate(wf.id)}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
</button>
|
||||
<span class="wf-params">{t('conn.wfParams', { n: wf.input_schema?.length ?? 0 })}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link-accent wf-test-btn"
|
||||
title={t('wft.title')}
|
||||
onclick={() => openTest(wf)}
|
||||
>
|
||||
<Icon name="play" size={13} />
|
||||
{t('conn.wfTestBtn')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="link-accent wf-test-btn"
|
||||
disabled={wfRecheckId === wf.id}
|
||||
title={t('conn.wfCheckBtn')}
|
||||
onclick={() => recheckRow(wf)}
|
||||
>
|
||||
<Icon name="retry" size={13} />
|
||||
{wfRecheckId === wf.id ? t('conn.wfChecking') : t('conn.wfCheckBtn')}
|
||||
</button>
|
||||
<select
|
||||
class="a-select"
|
||||
value={wf.endpoint_id ?? ''}
|
||||
onchange={(e) =>
|
||||
$wfBindMutation.mutate({
|
||||
id: wf.id,
|
||||
endpointId: e.currentTarget.value || null,
|
||||
})}
|
||||
>
|
||||
<option value="">{t('conn.wfConnectionActive')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="link-danger"
|
||||
title={t('conn.delete')}
|
||||
onclick={() => $wfDeleteMutation.mutate(wf.id)}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{@const lastLine = lastTestLine(wf)}
|
||||
{@const recheck = recheckLine(wf)}
|
||||
{#if lastLine || recheck}
|
||||
<div class="wf-meta-row">
|
||||
{#if lastLine}<span class="wf-meta-item">{t('conn.wfLastTest')}:{lastLine}</span>{/if}
|
||||
{#if recheck}
|
||||
<span class="wf-meta-item recheck" class:ok={recheck.tone === 'ok'} class:bad={recheck.tone === 'bad'} class:warn={recheck.tone === 'warn'}>
|
||||
{#if recheck.tone === 'bad'}
|
||||
{@const check = wfRecheckResult[wf.id]}
|
||||
<span class="wf-recheck-text" title={(check?.findings ?? []).map((f) => f.message).join('\n')}>
|
||||
{recheck.text}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="wf-recheck-text">{recheck.text}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if wfMapEditId === wf.id}
|
||||
<div class="wf-map-editor">
|
||||
<p class="wf-map-hint">{t('conn.wfOutputHint')}</p>
|
||||
@@ -1454,6 +1592,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<WorkflowTestDialog
|
||||
bind:open={testOpen}
|
||||
workflow={testWf}
|
||||
{endpoints}
|
||||
onchanged={() => client.invalidateQueries({ queryKey: ['workflows'] })}
|
||||
/>
|
||||
</AppShell>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -1798,6 +1942,133 @@
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wf-conn-check {
|
||||
margin: 0 0 12px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 7px;
|
||||
background: var(--surface-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.wf-conn-check.ok {
|
||||
border-color: var(--success);
|
||||
}
|
||||
.wf-conn-check.bad {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.wf-conn-check.warn {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.wf-conn-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wf-conn-head :global(.icon) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-conn-check.ok .wf-conn-head :global(.icon) {
|
||||
color: var(--success);
|
||||
}
|
||||
.wf-conn-check.bad .wf-conn-head :global(.icon) {
|
||||
color: var(--error);
|
||||
}
|
||||
.wf-conn-check.warn .wf-conn-head :global(.icon) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.wf-conn-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.wf-conn-ep {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.mini-link {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-link:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.wf-conn-line {
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wf-conn-findings {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.wf-conn-findings li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.wf-conn-msg {
|
||||
font-size: 11.5px;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wf-conn-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wf-test-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wf-test-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.wf-meta-row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 10px 8px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.wf-meta-item {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-meta-item.recheck .wf-recheck-text {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-meta-item.recheck.ok .wf-recheck-text {
|
||||
color: var(--success);
|
||||
}
|
||||
.wf-meta-item.recheck.bad .wf-recheck-text {
|
||||
color: var(--error);
|
||||
cursor: help;
|
||||
}
|
||||
.wf-meta-item.recheck.warn .wf-recheck-text {
|
||||
color: var(--accent);
|
||||
}
|
||||
.wf-evidence {
|
||||
margin: 0 0 12px;
|
||||
padding: 0 0 0 4px;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { isStandaloneJob, jobSourceLabel } from '$lib/jobLabels';
|
||||
import { isStandaloneJob, isWorkflowTestJob, jobSourceLabel } from '$lib/jobLabels';
|
||||
|
||||
/**
|
||||
* 生成任务页 (spec-v2 §3 nav item, HEL-612): one table summing both
|
||||
@@ -167,8 +167,11 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tmain">
|
||||
<div class="tname" title={wfName(job)}>{wfName(job)}</div>
|
||||
<div class="tmain">
|
||||
<div class="tname" title={wfName(job)}>
|
||||
{#if isWorkflowTestJob(job)}<span class="tag tag-test">{t('tasks.testBadge')}</span>{/if}
|
||||
{wfName(job)}
|
||||
</div>
|
||||
<div class="tmeta mono">
|
||||
{#if projectOf(job)}{projectOf(job)} · {/if}{jobSourceLabel(job)} · {timeOf(job)}
|
||||
{#if (job.output_paths?.length ?? 0) > 0}
|
||||
@@ -352,6 +355,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
.tname {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
@@ -458,6 +465,16 @@
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.tag-test {
|
||||
flex-shrink: 0;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.tasks-page {
|
||||
padding: 14px 16px 32px;
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
/* HEL-646 流水台原型 · 设计 Token 统一在 :root,组件只引用变量 */
|
||||
:root {
|
||||
--bg: #121211;
|
||||
--bg-2: #191917;
|
||||
--bg-3: #22221f;
|
||||
--bg-4: #2b2b27;
|
||||
--line: #2c2c28;
|
||||
--line-2: #3b3b35;
|
||||
--text: #f2f1ec;
|
||||
--text-2: #b6b4ab;
|
||||
--text-3: #7f7d75;
|
||||
--accent: #c9f36b;
|
||||
--accent-ink: #182200;
|
||||
--accent-soft: rgba(201, 243, 107, 0.12);
|
||||
--run: #86adff;
|
||||
--run-soft: rgba(134, 173, 255, 0.14);
|
||||
--err: #ff8070;
|
||||
--err-soft: rgba(255, 128, 112, 0.12);
|
||||
--warn: #f3c55a;
|
||||
--warn-soft: rgba(243, 197, 90, 0.12);
|
||||
|
||||
--r-sm: 6px;
|
||||
--r-md: 10px;
|
||||
--r-lg: 14px;
|
||||
--shadow-float: 0 12px 40px rgba(0, 0, 0, 0.45), 0 0 0 1px var(--line-2);
|
||||
|
||||
--font: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--mono: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
|
||||
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--d-press: 120ms;
|
||||
--d-state: 150ms;
|
||||
--d-pop: 180ms;
|
||||
--d-enter: 220ms;
|
||||
--d-drawer: 280ms;
|
||||
}
|
||||
|
||||
:root[data-reduce="1"] {
|
||||
--d-press: 0ms;
|
||||
--d-pop: 120ms;
|
||||
--d-enter: 120ms;
|
||||
--d-drawer: 120ms;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 13px/1.55 var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
button, input, textarea, select { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; background: none; border: 0; padding: 0; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--r-sm); }
|
||||
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.muted { color: var(--text-3); }
|
||||
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
|
||||
/* ---------- 壳层 ---------- */
|
||||
.app { display: grid; grid-template-columns: 60px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); height: 100vh; height: 100dvh; }
|
||||
.rail {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
padding: 12px 0; border-right: 1px solid var(--line); background: var(--bg);
|
||||
}
|
||||
.rail .logo {
|
||||
width: 34px; height: 34px; border-radius: 9px; margin-bottom: 14px;
|
||||
display: grid; place-items: center; font-weight: 700; font-size: 16px;
|
||||
background: var(--accent); color: var(--accent-ink);
|
||||
}
|
||||
.rail .spacer { flex: 1; }
|
||||
.rail-btn {
|
||||
position: relative; width: 44px; height: 44px; border-radius: var(--r-md);
|
||||
display: grid; place-items: center; color: var(--text-3);
|
||||
transition: color var(--d-state) ease, background-color var(--d-state) ease;
|
||||
}
|
||||
.rail-btn svg { width: 20px; height: 20px; }
|
||||
.rail-btn[aria-current="page"] { color: var(--text); background: var(--bg-3); }
|
||||
.rail-btn[aria-current="page"]::before {
|
||||
content: ""; position: absolute; left: -8px; top: 12px; bottom: 12px; width: 3px;
|
||||
border-radius: 0 3px 3px 0; background: var(--accent);
|
||||
}
|
||||
.rail-btn .tip {
|
||||
position: absolute; left: 52px; white-space: nowrap; pointer-events: none;
|
||||
background: var(--bg-4); color: var(--text); padding: 4px 8px; border-radius: var(--r-sm);
|
||||
font-size: 12px; opacity: 0; transform: translateX(-4px);
|
||||
transition: opacity var(--d-pop) var(--ease-out), transform var(--d-pop) var(--ease-out);
|
||||
z-index: 30;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.rail-btn:hover { color: var(--text); background: var(--bg-2); }
|
||||
.rail-btn:hover .tip { opacity: 1; transform: none; transition-delay: 350ms; }
|
||||
}
|
||||
|
||||
.main { display: grid; grid-template-rows: 52px minmax(0, 1fr); min-width: 0; }
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 10px; padding: 0 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.crumb { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.crumb h1 { font-size: 15px; font-weight: 600; margin: 0; white-space: nowrap; }
|
||||
.capsule {
|
||||
display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 10px;
|
||||
border: 1px solid var(--line-2); border-radius: 999px; color: var(--text-2); white-space: nowrap;
|
||||
transition: border-color var(--d-state) ease, color var(--d-state) ease;
|
||||
}
|
||||
.capsule .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
||||
.capsule .dot.run { background: var(--run); }
|
||||
.capsule .dot.err { background: var(--err); }
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.capsule:hover { border-color: var(--text-3); color: var(--text); }
|
||||
}
|
||||
.topbar .grow { flex: 1; }
|
||||
.kbd {
|
||||
font: 11px var(--mono); color: var(--text-3); border: 1px solid var(--line-2);
|
||||
border-bottom-width: 2px; border-radius: 4px; padding: 0 5px; line-height: 16px;
|
||||
}
|
||||
.search-btn { min-width: 240px; justify-content: space-between; }
|
||||
|
||||
/* 通用按钮 */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
height: 32px; padding: 0 12px; border-radius: var(--r-md);
|
||||
border: 1px solid var(--line-2); color: var(--text); background: var(--bg-2);
|
||||
transition: transform var(--d-press) var(--ease-out), background-color var(--d-state) ease, border-color var(--d-state) ease, opacity var(--d-state) ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
.btn:active:not(:disabled) { transform: scale(0.97); }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; font-weight: 600; }
|
||||
.btn.ghost { background: transparent; border-color: transparent; color: var(--text-2); }
|
||||
.btn.danger { color: var(--err); }
|
||||
.btn.sm { height: 26px; padding: 0 8px; font-size: 12px; border-radius: var(--r-sm); }
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.btn:hover:not(:disabled) { border-color: var(--text-3); }
|
||||
.btn.primary:hover:not(:disabled) { background: #d6f88a; }
|
||||
.btn.ghost:hover:not(:disabled) { background: var(--bg-3); color: var(--text); }
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 7px;
|
||||
border-radius: 999px; font-size: 11px; color: var(--text-2); background: var(--bg-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag.accent { color: var(--accent); background: var(--accent-soft); }
|
||||
.tag.run { color: var(--run); background: var(--run-soft); }
|
||||
.tag.err { color: var(--err); background: var(--err-soft); }
|
||||
.tag.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
|
||||
/* ---------- 视图切换 ---------- */
|
||||
.view { display: none; min-height: 0; }
|
||||
.view.on { display: grid; }
|
||||
|
||||
/* ---------- 生成台 ---------- */
|
||||
.desk { grid-template-columns: minmax(0, 1fr) 420px; grid-template-rows: minmax(0, 1fr); min-height: 0; }
|
||||
.desk-center { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
||||
.context-bar {
|
||||
display: none; align-items: center; gap: 10px; margin: 10px 20px 0; padding: 8px 12px;
|
||||
border-radius: var(--r-md); background: var(--run-soft); color: var(--text); font-size: 12px;
|
||||
}
|
||||
.context-bar.on { display: flex; }
|
||||
.context-bar b { color: var(--run); font-weight: 600; }
|
||||
.filters { display: flex; align-items: center; gap: 6px; padding: 10px 20px; border-bottom: 1px solid var(--line); }
|
||||
.seg { display: inline-flex; padding: 2px; border-radius: var(--r-md); background: var(--bg-2); border: 1px solid var(--line); }
|
||||
.seg button {
|
||||
height: 26px; padding: 0 10px; border-radius: 8px; color: var(--text-3);
|
||||
transition: background-color var(--d-state) ease, color var(--d-state) ease;
|
||||
}
|
||||
.seg button[aria-pressed="true"] { background: var(--bg-4); color: var(--text); }
|
||||
.filters .count { margin-left: auto; color: var(--text-3); font-size: 12px; }
|
||||
|
||||
.feed { flex: 1; overflow-y: auto; padding: 16px 20px 8px; scroll-behavior: smooth; }
|
||||
:root[data-reduce="1"] .feed { scroll-behavior: auto; }
|
||||
.feed-day { color: var(--text-3); font-size: 12px; margin: 10px 0 8px; display: flex; align-items: center; gap: 8px; }
|
||||
.feed-day::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||||
|
||||
.take {
|
||||
position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px 14px;
|
||||
padding: 12px 14px; margin-bottom: 8px; border-radius: var(--r-lg);
|
||||
border: 1px solid transparent; background: var(--bg-2); cursor: pointer;
|
||||
transition: border-color var(--d-state) ease, background-color var(--d-state) ease, opacity var(--d-enter) var(--ease-out), transform var(--d-enter) var(--ease-out);
|
||||
}
|
||||
@starting-style { .take.new { opacity: 0; transform: translateY(8px); } }
|
||||
:root[data-reduce="1"] .take.new { transform: none; }
|
||||
.take.selected { border-color: var(--accent); background: var(--bg-3); }
|
||||
.take.leaving { opacity: 0; transform: translateX(-12px); pointer-events: none; }
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.take:hover:not(.selected) { border-color: var(--line-2); }
|
||||
}
|
||||
.take-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.take-head .name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.take-head .meta { color: var(--text-3); font-size: 12px; white-space: nowrap; }
|
||||
.take-prompt {
|
||||
grid-column: 1; color: var(--text-2); font-size: 12.5px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.take-inputs { display: flex; gap: 4px; align-items: center; }
|
||||
.take-inputs .mini { width: 26px; height: 26px; border-radius: 5px; background-size: cover; background-position: center; border: 1px solid var(--line-2); }
|
||||
.take-inputs .more { font-size: 11px; color: var(--text-3); padding-left: 2px; }
|
||||
.take-out { grid-column: 2; grid-row: 1 / span 3; align-self: start; }
|
||||
.thumb {
|
||||
position: relative; width: 150px; aspect-ratio: 16 / 9; border-radius: var(--r-md); overflow: hidden;
|
||||
background: var(--bg-4) center / cover no-repeat;
|
||||
}
|
||||
.thumb.portrait { width: 96px; aspect-ratio: 3 / 4; }
|
||||
.thumb.fresh { transition: opacity var(--d-state) ease; }
|
||||
@starting-style { .thumb.fresh { opacity: 0; } }
|
||||
.thumb .badge {
|
||||
position: absolute; right: 6px; bottom: 6px; font: 11px var(--mono); padding: 1px 5px; border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.6); color: #fff;
|
||||
}
|
||||
.thumb .play {
|
||||
position: absolute; left: 6px; bottom: 6px; width: 22px; height: 22px; border-radius: 50%;
|
||||
display: grid; place-items: center; background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.thumb .play svg { width: 10px; height: 10px; fill: #fff; }
|
||||
.thumb.missing, .thumb.pending {
|
||||
display: grid; place-items: center; text-align: center; font-size: 11px; color: var(--text-3);
|
||||
border: 1px dashed var(--line-2); background: var(--bg-2); padding: 6px;
|
||||
}
|
||||
.thumb.pending { border-style: solid; overflow: hidden; }
|
||||
.thumb.pending::after {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: linear-gradient(100deg, transparent 30%, rgba(255, 255, 255, 0.05) 50%, transparent 70%);
|
||||
animation: sheen 1.4s linear infinite;
|
||||
}
|
||||
@keyframes sheen { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
|
||||
:root[data-reduce="1"] .thumb.pending::after { animation: none; }
|
||||
|
||||
.take-state { grid-column: 1; display: flex; align-items: center; gap: 8px; font-size: 12px; min-height: 22px; }
|
||||
.take-state .txt { color: var(--text-2); }
|
||||
.take-state .err-txt { color: var(--err); }
|
||||
.bar { position: relative; flex: 0 0 140px; height: 4px; border-radius: 4px; background: var(--bg-4); overflow: hidden; }
|
||||
.bar i {
|
||||
position: absolute; inset: 0; background: var(--run); transform-origin: left;
|
||||
transform: scaleX(var(--p, 0)); transition: transform 400ms linear;
|
||||
}
|
||||
.take-actions { display: flex; gap: 4px; margin-left: auto; opacity: 0; transition: opacity var(--d-state) ease; }
|
||||
.take:focus-within .take-actions, .take.selected .take-actions { opacity: 1; }
|
||||
@media (hover: hover) and (pointer: fine) { .take:hover .take-actions { opacity: 1; } }
|
||||
@media (hover: none) { .take-actions { opacity: 1; } }
|
||||
.err-detail { grid-column: 1 / -1; display: none; margin: 0; padding: 8px 10px 8px 26px; border-radius: var(--r-md); background: var(--err-soft); color: var(--text-2); font-size: 12px; }
|
||||
.take.open .err-detail { display: block; }
|
||||
.err-detail li { margin: 2px 0; }
|
||||
|
||||
/* ---------- 输入台 ---------- */
|
||||
.composer-wrap { padding: 8px 20px 16px; }
|
||||
.composer {
|
||||
border: 1px solid var(--line-2); border-radius: 16px; background: var(--bg-2);
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.composer:focus-within { border-color: var(--text-3); }
|
||||
.slots { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 12px 0; }
|
||||
.slots:empty { display: none; }
|
||||
.slot-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.slot-label { font-size: 11.5px; color: var(--text-3); display: flex; gap: 6px; align-items: center; }
|
||||
.slot-label .req { color: var(--warn); }
|
||||
.slot-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tile {
|
||||
position: relative; width: 58px; height: 58px; border-radius: var(--r-md);
|
||||
background: var(--bg-3) center / cover no-repeat; border: 1px solid var(--line-2);
|
||||
display: grid; place-items: center; color: var(--text-3);
|
||||
transition: border-color var(--d-state) ease, transform var(--d-press) var(--ease-out), opacity var(--d-enter) var(--ease-out);
|
||||
}
|
||||
@starting-style { .tile.filled { opacity: 0; transform: scale(0.94); } }
|
||||
.tile.add { border-style: dashed; }
|
||||
.tile.add svg { width: 18px; height: 18px; }
|
||||
.tile.add:active { transform: scale(0.97); }
|
||||
.tile.drag { border-color: var(--accent); background-color: var(--accent-soft); color: var(--accent); }
|
||||
.tile.bad { border-color: var(--err); }
|
||||
.tile.need { border-color: var(--warn); }
|
||||
.tile .x {
|
||||
position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%;
|
||||
background: var(--bg-4); border: 1px solid var(--line-2); display: grid; place-items: center; opacity: 0;
|
||||
transition: opacity var(--d-state) ease;
|
||||
}
|
||||
.tile .x svg { width: 9px; height: 9px; }
|
||||
.tile:focus-within .x { opacity: 1; }
|
||||
@media (hover: hover) and (pointer: fine) { .tile:hover .x { opacity: 1; } .tile.add:hover { border-color: var(--text-3); color: var(--text); } }
|
||||
@media (hover: none) { .tile .x { opacity: 1; } }
|
||||
.tile .ring { position: absolute; inset: 0; border-radius: inherit; background: rgba(0, 0, 0, 0.55); display: grid; place-items: center; font-size: 11px; color: #fff; }
|
||||
.tile .idx { position: absolute; left: 3px; top: 2px; font: 10px var(--mono); color: #fff; text-shadow: 0 1px 2px #000; }
|
||||
.slot-hint { font-size: 11px; color: var(--text-3); }
|
||||
.slot-hint.err { color: var(--err); }
|
||||
|
||||
.prompt {
|
||||
display: block; width: 100%; resize: none; border: 0; outline: 0; background: transparent;
|
||||
padding: 12px 14px 6px; min-height: 52px; max-height: 180px; line-height: 1.6;
|
||||
}
|
||||
.prompt::placeholder { color: var(--text-3); }
|
||||
.composer-foot { display: flex; align-items: center; gap: 6px; padding: 6px 8px 8px 8px; flex-wrap: wrap; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; border-radius: 999px;
|
||||
background: var(--bg-3); color: var(--text-2); font-size: 12px; white-space: nowrap; max-width: 280px;
|
||||
transition: background-color var(--d-state) ease, color var(--d-state) ease, transform var(--d-press) var(--ease-out);
|
||||
}
|
||||
.chip:active { transform: scale(0.97); }
|
||||
.chip .lbl { overflow: hidden; text-overflow: ellipsis; }
|
||||
.chip svg { width: 14px; height: 14px; flex: none; }
|
||||
.chip.wf { color: var(--text); background: var(--bg-4); font-weight: 500; }
|
||||
.chip.warn { color: var(--warn); }
|
||||
.chip.err { color: var(--err); }
|
||||
.chip.changed { color: var(--accent); }
|
||||
@media (hover: hover) and (pointer: fine) { .chip:hover { background: var(--bg-4); color: var(--text); } }
|
||||
.submit-group { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
||||
.why { font-size: 12px; color: var(--warn); white-space: nowrap; text-decoration: underline dotted; text-underline-offset: 3px; }
|
||||
.why.err { color: var(--err); }
|
||||
.gen-btn { height: 34px; padding: 0 16px; border-radius: 999px; }
|
||||
|
||||
/* ---------- 右侧检视区 ---------- */
|
||||
.inspector { border-left: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; background: var(--bg); }
|
||||
.insp-tabs { display: flex; gap: 4px; padding: 10px 12px; border-bottom: 1px solid var(--line); align-items: center; }
|
||||
.insp-body { flex: 1; overflow-y: auto; padding: 14px; }
|
||||
.stage { position: relative; border-radius: var(--r-lg); overflow: hidden; background: #000; aspect-ratio: 16 / 9; }
|
||||
.stage.portrait { aspect-ratio: 3 / 4; max-height: 480px; margin: 0 auto; }
|
||||
.stage > * { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; transition: opacity var(--d-state) ease; }
|
||||
.stage .fade-out { opacity: 0; }
|
||||
.stage-empty { display: grid; place-items: center; color: var(--text-3); font-size: 12px; background: var(--bg-2); }
|
||||
.stage-meta { display: flex; flex-wrap: wrap; gap: 6px 12px; margin: 12px 0; font-size: 12px; color: var(--text-3); }
|
||||
.stage-meta b { color: var(--text-2); font-weight: 500; }
|
||||
.stage-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.section-title { font-size: 12px; color: var(--text-3); margin: 18px 0 8px; display: flex; align-items: center; gap: 8px; }
|
||||
.kv { display: grid; grid-template-columns: 92px 1fr; gap: 6px 10px; font-size: 12px; }
|
||||
.kv dt { color: var(--text-3); }
|
||||
.kv dd { margin: 0; color: var(--text-2); word-break: break-all; }
|
||||
|
||||
/* 参数表单(按工作流 JSON 生成) */
|
||||
.field { display: grid; gap: 6px; margin-bottom: 12px; }
|
||||
.field label { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-2); }
|
||||
.field label .orig { color: var(--text-3); font-family: var(--mono); font-size: 11px; }
|
||||
.input {
|
||||
height: 32px; border-radius: var(--r-sm); border: 1px solid var(--line-2); background: var(--bg-2);
|
||||
padding: 0 10px; outline: 0; width: 100%;
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.input:focus { border-color: var(--text-3); }
|
||||
.input.changed { border-color: rgba(201, 243, 107, 0.5); }
|
||||
textarea.input { height: auto; min-height: 64px; padding: 8px 10px; resize: vertical; }
|
||||
.fold { border-top: 1px solid var(--line); margin-top: 6px; }
|
||||
.fold > summary {
|
||||
list-style: none; cursor: pointer; padding: 10px 0; display: flex; align-items: center; gap: 6px; color: var(--text-2); font-size: 12px;
|
||||
}
|
||||
.fold > summary::-webkit-details-marker { display: none; }
|
||||
.fold > summary svg { width: 12px; height: 12px; transition: transform var(--d-pop) var(--ease-out); }
|
||||
.fold[open] > summary svg { transform: rotate(90deg); }
|
||||
.note { font-size: 12px; color: var(--text-3); padding: 8px 10px; border-radius: var(--r-md); background: var(--bg-2); margin-bottom: 12px; }
|
||||
.note.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
.note.err { color: var(--err); background: var(--err-soft); }
|
||||
.note ul { margin: 6px 0 0; padding-left: 18px; }
|
||||
|
||||
/* ---------- 浮层:工作流面板 ---------- */
|
||||
.pop {
|
||||
position: fixed; z-index: 50; width: 520px; max-height: min(560px, 70vh); display: flex; flex-direction: column;
|
||||
border-radius: var(--r-lg); background: var(--bg-2); box-shadow: var(--shadow-float);
|
||||
opacity: 0; transform: scale(0.96); pointer-events: none; transform-origin: var(--origin, bottom left);
|
||||
transition: opacity var(--d-pop) var(--ease-out), transform var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.pop.on { opacity: 1; transform: none; pointer-events: auto; }
|
||||
.pop.instant { transition: none; }
|
||||
:root[data-reduce="1"] .pop { transform: none; }
|
||||
.pop-search { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
|
||||
.pop-search input { flex: 1; background: transparent; border: 0; outline: 0; font-size: 14px; }
|
||||
.pop-list { overflow-y: auto; padding: 6px; }
|
||||
.pop-group { font-size: 11px; color: var(--text-3); padding: 10px 10px 4px; display: flex; gap: 6px; }
|
||||
.wf-row {
|
||||
display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; width: 100%; text-align: left;
|
||||
padding: 8px 10px; border-radius: var(--r-md);
|
||||
}
|
||||
.wf-row[aria-selected="true"] { background: var(--bg-4); }
|
||||
.wf-row .n { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.wf-row .s { grid-column: 1; display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.wf-row .r { grid-column: 2; grid-row: 1 / span 2; align-self: center; font-size: 11px; text-align: right; }
|
||||
.wf-row.off .n { color: var(--text-3); }
|
||||
.pop-foot { border-top: 1px solid var(--line); padding: 8px; display: flex; gap: 6px; align-items: center; }
|
||||
.pop-foot .hint { margin-left: auto; font-size: 11px; color: var(--text-3); display: flex; gap: 6px; align-items: center; }
|
||||
|
||||
.menu { width: 260px; }
|
||||
.menu .pop-list button { display: flex; width: 100%; padding: 8px 10px; border-radius: var(--r-sm); gap: 8px; align-items: center; }
|
||||
.menu .pop-list button:hover, .menu .pop-list button[aria-selected="true"] { background: var(--bg-4); }
|
||||
|
||||
/* ---------- 抽屉:导入工作流 ---------- */
|
||||
.scrim { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); opacity: 0; pointer-events: none; transition: opacity var(--d-drawer) ease; z-index: 60; }
|
||||
.scrim.on { opacity: 1; pointer-events: auto; }
|
||||
.drawer {
|
||||
position: fixed; top: 0; right: 0; bottom: 0; width: 520px; max-width: 100vw; z-index: 61;
|
||||
background: var(--bg-2); border-left: 1px solid var(--line-2); display: flex; flex-direction: column;
|
||||
transform: translateX(100%); transition: transform var(--d-drawer) var(--ease-drawer);
|
||||
}
|
||||
.drawer.on { transform: none; }
|
||||
:root[data-reduce="1"] .drawer { transform: none; opacity: 0; pointer-events: none; transition: opacity var(--d-drawer) ease; }
|
||||
:root[data-reduce="1"] .drawer.on { opacity: 1; pointer-events: auto; }
|
||||
.drawer-head { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--line); }
|
||||
.drawer-head h2 { font-size: 15px; margin: 0; flex: 1; }
|
||||
.drawer-body { flex: 1; overflow-y: auto; padding: 16px; }
|
||||
.drawer-foot { display: flex; gap: 8px; justify-content: flex-end; padding: 12px 16px; border-top: 1px solid var(--line); }
|
||||
.drop {
|
||||
border: 1px dashed var(--line-2); border-radius: var(--r-lg); padding: 14px; display: grid; gap: 10px;
|
||||
transition: border-color var(--d-state) ease, background-color var(--d-state) ease;
|
||||
}
|
||||
.drop.drag { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.drop textarea { width: 100%; min-height: 110px; background: var(--bg); border: 1px solid var(--line); border-radius: var(--r-md); padding: 10px; font: 12px/1.5 var(--mono); color: var(--text-2); resize: vertical; outline: 0; }
|
||||
.checks { display: grid; gap: 8px; margin-top: 14px; }
|
||||
.check { display: grid; grid-template-columns: 22px 1fr; gap: 2px 10px; padding: 10px 12px; border-radius: var(--r-md); background: var(--bg); border: 1px solid var(--line); }
|
||||
.check .ic { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-4); color: var(--text-3); font-size: 11px; transition: background-color var(--d-state) ease, color var(--d-state) ease; }
|
||||
.check.ok .ic { background: var(--accent-soft); color: var(--accent); }
|
||||
.check.bad .ic { background: var(--err-soft); color: var(--err); }
|
||||
.check.run .ic { background: var(--run-soft); color: var(--run); }
|
||||
.check .t { font-weight: 500; }
|
||||
.check .d { grid-column: 2; color: var(--text-3); font-size: 12px; }
|
||||
.check .d ul { margin: 4px 0 0; padding-left: 16px; }
|
||||
.slot-map { display: grid; gap: 6px; margin-top: 8px; }
|
||||
.slot-map div { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 6px 8px; border-radius: var(--r-sm); background: var(--bg-3); }
|
||||
.usage-pick { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.usage-pick button { height: 28px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--line-2); color: var(--text-2); font-size: 12px; }
|
||||
.usage-pick button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
/* ---------- Toast ---------- */
|
||||
.toasts { position: fixed; right: 24px; bottom: 24px; z-index: 80; display: grid; gap: 8px; pointer-events: none; max-width: 400px; }
|
||||
.toast {
|
||||
display: flex; align-items: center; gap: 12px; padding: 10px 12px 10px 14px; border-radius: var(--r-md);
|
||||
background: var(--bg-4); box-shadow: var(--shadow-float); pointer-events: auto; min-width: 280px;
|
||||
transition: opacity var(--d-enter) var(--ease-out), transform var(--d-enter) var(--ease-out);
|
||||
}
|
||||
@starting-style { .toast { opacity: 0; transform: translateY(100%); } }
|
||||
.toast.out { opacity: 0; transform: translateY(100%); }
|
||||
:root[data-reduce="1"] .toast, :root[data-reduce="1"] .toast.out { transform: none; }
|
||||
.toast .grow { flex: 1; }
|
||||
.toast button { color: var(--accent); font-weight: 600; }
|
||||
|
||||
/* ---------- 任务托盘 ---------- */
|
||||
.tray { width: 340px; }
|
||||
.tray-row { display: grid; grid-template-columns: 36px 1fr auto; gap: 10px; align-items: center; padding: 8px 10px; border-radius: var(--r-md); width: 100%; text-align: left; }
|
||||
.tray-row:hover { background: var(--bg-3); }
|
||||
.tray-row .t { width: 36px; height: 36px; border-radius: 6px; background: var(--bg-4) center / cover; }
|
||||
.tray-row .n { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tray-row .m { font-size: 11px; color: var(--text-3); }
|
||||
|
||||
/* ---------- 项目 / 创作流程 ---------- */
|
||||
.page { overflow-y: auto; padding: 24px 28px 40px; align-content: start; gap: 18px; }
|
||||
.hero {
|
||||
display: grid; grid-template-columns: 300px 1fr; gap: 20px; padding: 16px; border-radius: var(--r-lg); background: var(--bg-2); border: 1px solid var(--line);
|
||||
}
|
||||
.hero .cover { aspect-ratio: 16 / 9; border-radius: var(--r-md); background: var(--bg-4) center / cover; }
|
||||
.hero h2 { margin: 2px 0 6px; font-size: 20px; }
|
||||
.hero p { margin: 0 0 12px; color: var(--text-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.track { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; margin: 10px 0 14px; }
|
||||
.track button { text-align: left; padding: 8px 10px; border-radius: var(--r-md); background: var(--bg-3); font-size: 12px; color: var(--text-2); transition: background-color var(--d-state) ease; }
|
||||
.track button .k { display: block; font-size: 11px; color: var(--text-3); }
|
||||
.track button.done .k { color: var(--accent); }
|
||||
.track button.cur { background: var(--bg-4); color: var(--text); box-shadow: inset 0 -2px 0 var(--warn); }
|
||||
.track button.cur .k { color: var(--warn); }
|
||||
.track button[aria-current="step"] { outline: 1px solid var(--text-3); }
|
||||
.proj-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
|
||||
.proj { display: grid; gap: 6px; padding: 12px; border-radius: var(--r-lg); background: var(--bg-2); border: 1px solid var(--line); text-align: left; transition: border-color var(--d-state) ease; }
|
||||
@media (hover: hover) and (pointer: fine) { .proj:hover { border-color: var(--line-2); } }
|
||||
.proj .pips { display: flex; gap: 3px; }
|
||||
.proj .pips i { flex: 1; height: 3px; border-radius: 3px; background: var(--bg-4); }
|
||||
.proj .pips i.d { background: var(--accent); }
|
||||
.proj .pips i.c { background: var(--warn); }
|
||||
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.shot { display: grid; grid-template-columns: 44px 1fr auto; gap: 12px; padding: 12px 14px; border-radius: var(--r-lg); background: var(--bg-2); border: 1px solid var(--line); align-items: start; }
|
||||
.shot .no { font: 600 13px var(--mono); color: var(--warn); }
|
||||
.shot .p { color: var(--text-2); font-size: 12.5px; }
|
||||
.shot .en { margin-top: 6px; font: 11.5px/1.5 var(--mono); color: var(--text-3); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.placeholder { place-items: center; text-align: center; color: var(--text-3); padding: 40px; }
|
||||
.placeholder h2 { color: var(--text); font-size: 16px; }
|
||||
|
||||
/* 原型标记 */
|
||||
.proto-flag { font-size: 11px; color: var(--text-3); border: 1px dashed var(--line-2); padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
|
||||
.switch { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-2); cursor: pointer; white-space: nowrap; }
|
||||
.switch input { accent-color: var(--accent); }
|
||||
|
||||
/* 底部标签栏(窄屏) */
|
||||
.tabbar { display: none; }
|
||||
.mobile-only { display: none; }
|
||||
|
||||
/* ---------- 中屏:检视区改为右侧浮层 ---------- */
|
||||
@media (max-width: 1180px) {
|
||||
.desk { grid-template-columns: minmax(0, 1fr); }
|
||||
.inspector {
|
||||
position: fixed; top: 52px; right: 0; bottom: 0; width: 400px; z-index: 40; box-shadow: var(--shadow-float);
|
||||
transform: translateX(100%); transition: transform var(--d-drawer) var(--ease-drawer);
|
||||
}
|
||||
.inspector.on { transform: none; }
|
||||
:root[data-reduce="1"] .inspector { transform: none; opacity: 0; pointer-events: none; transition: opacity var(--d-drawer) ease; }
|
||||
:root[data-reduce="1"] .inspector.on { opacity: 1; pointer-events: auto; }
|
||||
.insp-close { display: inline-flex !important; }
|
||||
.search-btn { min-width: 0; }
|
||||
.search-btn .lbl { display: none; }
|
||||
}
|
||||
|
||||
/* ---------- 窄屏:底部标签栏 + 输入台底部抽屉 ---------- */
|
||||
@media (max-width: 760px) {
|
||||
.app { grid-template-columns: 1fr; }
|
||||
.rail { display: none; }
|
||||
.main { grid-template-rows: 48px minmax(0, 1fr) 56px; }
|
||||
.topbar { padding: 0 12px; gap: 8px; }
|
||||
.topbar .hide-sm { display: none; }
|
||||
.mobile-only { display: inline-flex; }
|
||||
.tabbar {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); background: var(--bg);
|
||||
}
|
||||
.tabbar button { display: grid; place-items: center; gap: 2px; font-size: 11px; color: var(--text-3); padding-top: 6px; }
|
||||
.tabbar button svg { width: 20px; height: 20px; }
|
||||
.tabbar button[aria-current="page"] { color: var(--accent); }
|
||||
.filters { padding: 8px 12px; overflow-x: auto; }
|
||||
.filters .count { display: none; }
|
||||
.feed { padding: 12px 12px 4px; }
|
||||
.take { grid-template-columns: minmax(0, 1fr) auto; padding: 10px; }
|
||||
.take-head { flex-wrap: wrap; row-gap: 2px; }
|
||||
.take-out { grid-row: 1 / span 2; }
|
||||
.take-state { grid-column: 1 / -1; flex-wrap: wrap; }
|
||||
.take-state .txt, .take-state .err-txt { white-space: nowrap; }
|
||||
.take-actions { margin-left: auto; }
|
||||
.toasts { left: 12px; right: 12px; bottom: 124px; max-width: none; }
|
||||
.thumb { width: 108px; }
|
||||
.thumb.portrait { width: 72px; }
|
||||
.bar { flex-basis: 80px; }
|
||||
.composer-wrap { padding: 6px 10px 10px; }
|
||||
.composer:not(.expanded) .slots, .composer:not(.expanded) .opt-chip { display: none; }
|
||||
.composer:not(.expanded) .prompt { min-height: 40px; max-height: 40px; padding: 10px 12px 2px; white-space: nowrap; overflow: hidden; }
|
||||
.composer.expanded { position: fixed; left: 0; right: 0; bottom: 56px; z-index: 45; border-radius: 16px 16px 0 0; border-bottom: 0; box-shadow: var(--shadow-float); }
|
||||
.chip { max-width: 170px; }
|
||||
.inspector { top: 0; width: 100vw; bottom: 0; z-index: 70; }
|
||||
.pop { left: 0 !important; right: 0; top: auto !important; bottom: 0 !important; width: 100vw; border-radius: 16px 16px 0 0; max-height: 78vh; --origin: bottom center; }
|
||||
.drawer { width: 100vw; }
|
||||
.page { padding: 16px 12px 24px; }
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.track { grid-template-columns: repeat(5, minmax(64px, 1fr)); overflow-x: auto; }
|
||||
.shot { grid-template-columns: 30px 1fr; }
|
||||
.shot .btn { grid-column: 2; justify-self: start; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root { --d-press: 0ms; --d-pop: 120ms; --d-enter: 120ms; --d-drawer: 120ms; }
|
||||
.take.new, .tile.filled { transform: none !important; }
|
||||
.thumb.pending::after { animation: none; }
|
||||
}
|
||||
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,156 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>影工坊 · 流水台交互原型(HEL-646,隔离原型)</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
<svg width="0" height="0" style="position:absolute">
|
||||
<defs>
|
||||
<symbol id="i-home" viewBox="0 0 24 24"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-flow" viewBox="0 0 24 24"><path d="M4 6h5v5H4zM15 13h5v5h-5zM9 8.5h3.5a2 2 0 0 1 2 2V13" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-spark" viewBox="0 0 24 24"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8zM18.5 16l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-list" viewBox="0 0 24 24"><path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-agent" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M4 20c1.5-4 4.5-6 8-6s6.5 2 8 6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M12 2.5v3M12 18.5v3M21.5 12h-3M5.5 12h-3M18.7 5.3l-2.1 2.1M7.4 16.6l-2.1 2.1M18.7 18.7l-2.1-2.1M7.4 7.4L5.3 5.3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24"><path d="M7 4l13 8-13 8z"/></symbol>
|
||||
<symbol id="i-down" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-right" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-sliders" viewBox="0 0 24 24"><path d="M4 7h10M18 7h2M4 17h4M12 17h8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="16" cy="7" r="2" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="10" cy="17" r="2" fill="none" stroke="currentColor" stroke-width="1.8"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 16l4 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 15V4M7 9l5-5 5 5M5 19h14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-dice" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="9" cy="9" r="1.2"/><circle cx="15" cy="15" r="1.2"/><circle cx="15" cy="9" r="1.2"/><circle cx="9" cy="15" r="1.2"/></symbol>
|
||||
<symbol id="i-back" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div class="app">
|
||||
<nav class="rail" aria-label="主导航">
|
||||
<div class="logo" aria-hidden="true">影</div>
|
||||
<button class="rail-btn" data-nav="projects"><svg><use href="#i-home"/></svg><span class="tip">项目</span><span class="sr">项目</span></button>
|
||||
<button class="rail-btn" data-nav="flow"><svg><use href="#i-flow"/></svg><span class="tip">创作流程</span><span class="sr">创作流程</span></button>
|
||||
<button class="rail-btn" data-nav="desk" aria-current="page"><svg><use href="#i-spark"/></svg><span class="tip">生成台(生图 + 生视频)</span><span class="sr">生成台</span></button>
|
||||
<button class="rail-btn" data-nav="tasks"><svg><use href="#i-list"/></svg><span class="tip">全部任务</span><span class="sr">全部任务</span></button>
|
||||
<button class="rail-btn" data-nav="agents"><svg><use href="#i-agent"/></svg><span class="tip">智能体</span><span class="sr">智能体</span></button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rail-btn" data-nav="connections"><svg><use href="#i-link"/></svg><span class="tip">ComfyUI 连接与工作流库</span><span class="sr">连接</span></button>
|
||||
<button class="rail-btn" data-nav="settings"><svg><use href="#i-gear"/></svg><span class="tip">模型与设置</span><span class="sr">设置</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="crumb">
|
||||
<h1 id="page-title">生成台</h1>
|
||||
<button class="capsule" id="scope-btn" aria-haspopup="menu"><span id="scope-label">全部来源</span><svg width="12" height="12"><use href="#i-down"/></svg></button>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<span class="proto-flag hide-sm">隔离原型 · 不连后端</span>
|
||||
<label class="switch hide-sm"><input type="checkbox" id="reduce-toggle">减少动态</label>
|
||||
<button class="capsule search-btn" id="cmd-btn" title="Ctrl/⌘ + K"><svg width="14" height="14"><use href="#i-search"/></svg><span class="lbl">搜索工作流、项目、任务</span><span class="kbd">Ctrl K</span></button>
|
||||
<button class="capsule" id="tray-btn" aria-haspopup="dialog"><span class="dot" id="tray-dot"></span><span id="tray-label">队列空闲</span></button>
|
||||
<button class="capsule hide-sm" id="conn-btn" title="生成请求由工作台服务器发往该 ComfyUI"><span class="dot"></span>本机 ComfyUI-4080 · 22 ms</button>
|
||||
</header>
|
||||
|
||||
<!-- 生成台 -->
|
||||
<section class="view desk on" id="view-desk" aria-label="生成台">
|
||||
<div class="desk-center">
|
||||
<div class="context-bar" id="context-bar">
|
||||
<button class="btn sm ghost" id="ctx-back"><svg><use href="#i-back"/></svg>返回镜头脚本</button>
|
||||
<span>来自 <b>《雨夜便利店》· 镜头 S1</b>,生成结果会回填到该镜头</span>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<div class="seg" role="group" aria-label="按类型筛选">
|
||||
<button data-filter="all" aria-pressed="true">全部</button>
|
||||
<button data-filter="image" aria-pressed="false">图片</button>
|
||||
<button data-filter="video" aria-pressed="false">视频</button>
|
||||
<button data-filter="failed" aria-pressed="false">失败</button>
|
||||
</div>
|
||||
<span class="count" id="feed-count"></span>
|
||||
</div>
|
||||
<div class="feed" id="feed" role="listbox" aria-label="生成记录(↑↓ 切换)" tabindex="0"></div>
|
||||
<div class="composer-wrap">
|
||||
<div class="composer" id="composer">
|
||||
<div class="slots" id="slots"></div>
|
||||
<textarea class="prompt" id="prompt" rows="2" placeholder="描述想要的画面…(Ctrl/⌘ + Enter 生成,/ 快速聚焦)"></textarea>
|
||||
<div class="composer-foot">
|
||||
<button class="chip wf" id="wf-chip" aria-haspopup="listbox"><svg><use href="#i-flow"/></svg><span class="lbl" id="wf-chip-label"></span><svg><use href="#i-down"/></svg></button>
|
||||
<span id="common-chips" style="display:contents"></span>
|
||||
<button class="chip opt-chip" id="params-chip"><svg><use href="#i-sliders"/></svg><span id="params-chip-label">全部参数</span></button>
|
||||
<div class="submit-group">
|
||||
<button class="why" id="why" hidden></button>
|
||||
<button class="btn primary gen-btn" id="gen-btn">生成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="inspector" id="inspector" aria-label="检视区">
|
||||
<div class="insp-tabs">
|
||||
<div class="seg" role="tablist">
|
||||
<button data-tab="preview" aria-pressed="true">结果</button>
|
||||
<button data-tab="params" aria-pressed="false">参数</button>
|
||||
</div>
|
||||
<div class="grow" style="flex:1"></div>
|
||||
<span class="muted" style="font-size:11px" id="insp-hint">↑ ↓ 切换 · 空格播放</span>
|
||||
<button class="btn sm ghost insp-close" id="insp-close" style="display:none" aria-label="关闭"><svg><use href="#i-x"/></svg></button>
|
||||
</div>
|
||||
<div class="insp-body" id="insp-preview"></div>
|
||||
<div class="insp-body" id="insp-params" hidden></div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<!-- 项目 -->
|
||||
<section class="view page" id="view-projects" aria-label="项目"></section>
|
||||
<!-- 创作流程 -->
|
||||
<section class="view page" id="view-flow" aria-label="创作流程"></section>
|
||||
<!-- 其他页占位 -->
|
||||
<section class="view page placeholder" id="view-other"></section>
|
||||
|
||||
<nav class="tabbar" aria-label="主导航(窄屏)">
|
||||
<button data-nav="projects"><svg><use href="#i-home"/></svg>项目</button>
|
||||
<button data-nav="flow"><svg><use href="#i-flow"/></svg>创作</button>
|
||||
<button data-nav="desk" aria-current="page"><svg><use href="#i-spark"/></svg>生成</button>
|
||||
<button data-nav="more"><svg><use href="#i-list"/></svg>更多</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工作流面板 -->
|
||||
<div class="pop" id="wf-pop" role="dialog" aria-label="选择工作流">
|
||||
<div class="pop-search"><svg width="16" height="16"><use href="#i-search"/></svg><input id="wf-search" placeholder="搜索工作流,或输入用途:图生视频、参考图…" autocomplete="off"></div>
|
||||
<div class="pop-list" id="wf-list" role="listbox"></div>
|
||||
<div class="pop-foot">
|
||||
<button class="btn sm" id="import-open"><svg><use href="#i-upload"/></svg>导入 ComfyUI 工作流 JSON…</button>
|
||||
<span class="hint"><span class="kbd">↑↓</span>选择 <span class="kbd">Enter</span>使用 <span class="kbd">Esc</span>关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通用小菜单 -->
|
||||
<div class="pop menu" id="menu-pop" role="menu"><div class="pop-list" id="menu-list"></div></div>
|
||||
<!-- 任务托盘 -->
|
||||
<div class="pop tray" id="tray-pop" role="dialog" aria-label="任务托盘"><div class="pop-list" id="tray-list"></div></div>
|
||||
|
||||
<!-- 导入抽屉 -->
|
||||
<div class="scrim" id="scrim"></div>
|
||||
<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title">
|
||||
<div class="drawer-head">
|
||||
<h2 id="drawer-title">导入 ComfyUI 工作流</h2>
|
||||
<button class="btn sm ghost" id="drawer-close" aria-label="关闭"><svg><use href="#i-x"/></svg></button>
|
||||
</div>
|
||||
<div class="drawer-body" id="drawer-body"></div>
|
||||
<div class="drawer-foot" id="drawer-foot"></div>
|
||||
</aside>
|
||||
|
||||
<input type="file" id="file-input" accept="image/png,image/jpeg,image/webp" multiple hidden>
|
||||
<input type="file" id="json-input" accept=".json,application/json" hidden>
|
||||
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 865 KiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 228 KiB |
@@ -0,0 +1,93 @@
|
||||
# 影工坊整站方案 · 施工员版「流水台」
|
||||
|
||||
> 一张桌面走完「选工作流 → 放素材写提示词 → 生成 → 挑结果」,不跳页、不重复找历史。
|
||||
> 隔离原型:本目录 `index.html`(双击即可在 Chrome / Edge 打开,不连后端,数据取自测试站真实工作流与任务记录)。
|
||||
|
||||
## 一、和已有方案的区别
|
||||
|
||||
| 方案 | 布局 | 交互方式 |
|
||||
|---|---|---|
|
||||
| 旧「纸上制片厂」「信号控制台」 | 同一副骨架:左侧文字导航 + 卡片网格 + 三栏表单 | 只换配色 |
|
||||
| 美术师「放映厅」 | 作品全幅居中的剧场播放器,控件退到边缘 | 以看片挑片为主 |
|
||||
| **施工员「流水台」** | **60px 图标轨 + 中间记录流 + 底部常驻输入台 + 右侧检视区** | **像对话一样一条条往下生成;工作流决定输入槽;结果就地出现、就地切换** |
|
||||
|
||||
「流水台」主要改的是结构和手感,配色 Token 可以单独替换。白栖知如果更喜欢「放映厅」的色调,可以把「放映厅」的颜色套到「流水台」的结构上。原型当前用的是暖石墨底色,只有主按钮、选中项和「完成」三处用青柠色。
|
||||
|
||||
## 二、现状走查(测试站 192.168.200.15:8080,1.5.8)
|
||||
|
||||
| # | 现状 | 建议 | 理由 |
|
||||
|---|---|---|---|
|
||||
| 1 | 390 宽整站只显示「手机端暂不适配」 | 窄屏改为底部标签栏;输入台收起时只占一行,点开后升成底部抽屉;点结果进入全屏检视 | 主卡验收要求覆盖窄屏 |
|
||||
| 2 | 「项目 / 创作工作台」两个入口,还要多经一个选择页;生图、生视频、生成任务是三个页面,各有一份历史 | 合并成一个「生成台」,页面形态由所选工作流的用途决定;任务页就是生成台的「全部来源」视图,右上角托盘随时看进度;`/image` `/video` 保留为预选入口 | 少跳页,历史只保留一份 |
|
||||
| 3 | 生图页左栏被 8 行红色错误占满,输入图和提示词被挤到首屏以下;1024 宽时「开始生成」压住上传区 | 错误收进工作流选择器的状态标签和检视区「参数」页,输入台只留一行「工作流不可用 · 查看原因」 | 高频操作区不让异常信息占满 |
|
||||
| 4 | 中栏「最近结果」和右栏「历史」是两套列表;视频历史只有缩略图,看不到提示词和状态 | 只保留一条记录流,每条带输入缩略图、提示词、状态和产物 | 一眼能看出哪组参数出了哪个结果 |
|
||||
| 5 | 「没有进行中的任务」横幅和具体任务分在两处,提交后反馈的位置不固定 | 点「生成」后,记录流底部立即出现「排队」记录;同一位置依次显示进度条和阶段(如「采样 2/4」),完成后换成产物 | 反馈出现在视线所在的位置 |
|
||||
| 6 | 上传区是一个大虚线框,9 张参考图会占满左栏;没有「上传中」状态,非法文件的报错位置不明确 | 改成 58px 小槽位,显示「已选 n / 9」和序号,上传中加遮罩,错误提示放在槽位下方;整个输入台都能拖入 | 放满素材后仍看得到提示词和按钮 |
|
||||
| 7 | 点选历史后播放器直接替换,不能用键盘切换 | 点选时 150ms 淡入;↑ ↓ / J K 即时切换,不加动画;空格播放 | 高频操作不加动画(Emil 规范) |
|
||||
| 8 | 每次删除都弹确认框 | 单条记录删除后立即隐藏,并提供 5 秒撤销,窗口结束后才真正调用删除接口;删项目、删工作流等大操作仍然弹确认 | 撤销比确认更快,也更不容易误删 |
|
||||
| 9 | `Qwen_image_2.1_00003.png` 在服务器上已不存在,结果区和任务页显示浏览器裂图 | 显示明确的「文件缺失」状态,并提供「用相同参数重做 / 移除记录」 | 裂图会让人以为系统坏了 |
|
||||
| 10 | 侧栏常驻红色「恒源云 连接失败」,但生图实际走的是已连通的「本机 ComfyUI-4080」;默认连接恰好是连不上的那台 | 顶栏和输入台只显示「本次生成要用的连接」,可以切换;连接不通时,生成按钮旁直接写原因 | 避免让人误以为不能生成 |
|
||||
| 11 | 「minimaxh3-文生图加速版」实际被判定为文生视频 | 选择器按用途分组,并显示输入槽摘要(仅提示词 / 首帧 ×1 / 参考图 1–9 张) | 名称是用户起的,用途以 JSON 为准 |
|
||||
| 12 | 8 个参数全部平铺 | 比例、时长、种子做成输入台上的芯片,其余参数折叠到检视区;每项标注「原值」,改过的高亮并计数 | 多数时候只改提示词和素材 |
|
||||
| 13 | 失败记录把整段英文 HTTP 400 原文铺开 | 先给一行中文结论(如「连接上缺 3 个模型文件」),详情可展开;重试前先提示仍缺模型 | 可读,也不丢失信息 |
|
||||
| 14 | 创作流程里的生成和独立生视频是两套界面 | 「生成本镜视频」打开生成台,并带上下文条(「来自《雨夜便利店》· 镜头 S1,结果回填」)和一键返回 | 同一套输入台,只需学一次 |
|
||||
| 15 | 150px 文字侧栏共 9 项 | 60px 图标轨(悬停显示名称)加 Ctrl K 全局搜索 | 把宽度让给作品。代价是新用户第一次需要悬停识别图标 |
|
||||
|
||||
## 三、结构与操作方式
|
||||
|
||||
- **桌面 ≥1180**:左边 60px 图标轨;顶栏放标题、范围胶囊(全部来源 / 独立创作 / 某项目)、Ctrl K、任务托盘和本次连接;中间记录流按时间从上到下,最新一条在底部,紧贴输入台;右侧 420px 检视区分「结果 / 参数」两个页签。
|
||||
- **中屏 761–1179**:检视区改为右侧浮层,点结果时滑入;其余结构不变。
|
||||
- **窄屏 ≤760**:底部标签栏(项目 / 创作 / 生成 / 更多);输入台收起时只有一行,聚焦后升成底部抽屉;工作流面板改为底部弹层;检视区全屏显示,可关闭返回。
|
||||
- **工作流选择**:点输入台上的工作流芯片,面板从芯片处弹出,按五类用途分组,每行显示输入槽摘要、绑定连接和状态(可用 / 未试运行 / 上次失败 / 不可用)。支持输入过滤、↑↓ 选择、Enter 使用,底部是「导入 ComfyUI 工作流 JSON…」。
|
||||
- **导入**:右侧抽屉。粘贴、拖入或选择文件后自动检查,依次是:1 文件结构 → 2 用途与输入槽(不确定时让用户选)→ 3 对照连接上的节点与模型 → 4 可选试运行(只有拿到可下载、类型正确的产物才算通过)。保存后自动选中,输入台立刻按这份 JSON 显示输入槽。
|
||||
- **输入槽只随 JSON 出现**:文生图 / 文生视频没有图片槽;图生图显示「输入图 ×1」;图生视频显示「首帧图 ×1」;参考生视频显示「参考图 1–9 张」。原型不画参考视频 / 音频入口,因为当前三份真实 JSON 没有这类连线。切换工作流时已放入的图片会保留,按新槽位的上限截断。
|
||||
- **结果切换**:点记录会在检视区淡入;键盘 ↑ ↓ 即时切换;「复用」把工作流、素材和参数放回输入台;图片结果可以「送入当前图片槽」。
|
||||
|
||||
## 四、动效(按 emil-skills `animate` 与 `review-animations/STANDARDS` 核对)
|
||||
|
||||
| 场景 | 频率 | 做法 | 参数 |
|
||||
|---|---|---|---|
|
||||
| 按钮 / 芯片按下 | 高 | `scale(0.97)` | 120ms · `cubic-bezier(0.23,1,0.32,1)` |
|
||||
| 点芯片打开工作流面板 | 中 | 从芯片方向 `scale(0.96)`→1,同时淡入 | 180ms · 同上 |
|
||||
| Ctrl K 打开面板 / ↑↓ 切换结果 | 高·键盘 | **不做动画** | 0 |
|
||||
| 导入抽屉 / 中屏检视区 | 偶尔 | `translateX(100%)`→0,用 transition,中途可反向 | 280ms · `cubic-bezier(0.32,0.72,0,1)` |
|
||||
| 提交后新记录出现 | 每次 | 淡入 + 上移 8px(`@starting-style`) | 220ms · ease-out |
|
||||
| 进度条 | 持续 | `transform: scaleX()` | 400ms · linear |
|
||||
| 完成后缩略图替换 / 点选切换结果 | 每次 | 透明度交叉淡入 | 150ms · ease |
|
||||
| 删除记录 | 偶尔 | 左移 12px 并淡出,Toast 提供撤销 | 200ms / 5 秒撤销窗口 |
|
||||
| Toast | 偶尔 | 从下方进出,用 transition 不用 keyframes,可被打断 | 220ms |
|
||||
| 导入检查结果 | 偶尔 | 逐条出现,间隔 60ms,不阻塞操作 | 200ms |
|
||||
|
||||
- 只动 `transform` 和 `opacity`;悬停效果只在 `(hover: hover) and (pointer: fine)` 下生效。
|
||||
- 减少动态效果:系统开启 `prefers-reduced-motion` 时自动生效,原型顶栏也有手动开关。开启后去掉所有位移,只保留不超过 120ms 的透明度变化。
|
||||
- 全部动效用 CSS transition 实现,**不引入新依赖**。
|
||||
|
||||
**性能风险与代价**:① 记录流可能有上百条视频。缩略图用封面图,检视区只挂一个 `<video preload="metadata">`;超过约 200 条时要做分页或虚拟列表(中等工作量)。② SSE 进度只更新对应那一条记录,不重渲染整个列表。③ `@starting-style` 需要 Chrome 117+ / Safari 17.5+,更旧的浏览器没有入场动画,功能不受影响。④ 撤销删除需要延迟 5 秒才发送删除请求,页面关闭时用 `pagehide` + `keepalive` 补发;后端不用改。代价是多个标签页之间会短暂不一致。⑤ 不做大面积 `backdrop-filter` 模糊。
|
||||
|
||||
## 五、能力边界
|
||||
|
||||
原型只画测试站已有的能力:ComfyUI 工作流(本机 / 局域网 / 可达云端地址)、五类用途识别、导入检查与试运行、参考图最多 9 张、删除、点选播放、失败重试。原型里**没有** gpt-image2、nano-banana、即梦、可灵等云端媒体入口。「Qwen Image 2.1 图片编辑(用户提供)」按测试站真实状态标为不可用,列出了 3 个真实报错。原型中新提交任务的结果是「演示产物」(取自测试站历史结果,页面上有标注),进度和耗时都是演示加速,不代表真实生成速度。
|
||||
|
||||
## 六、改造清单(calliope-web)
|
||||
|
||||
| 文件 / 组件 | 改动 |
|
||||
|---|---|
|
||||
| `src/routes/app.css`、`tailwind.config.js` | 设计 Token 统一来源:颜色、圆角、动效曲线与时长的 CSS 变量;清理组件内硬编码色值 |
|
||||
| `shell/AppShell.svelte`、`NavRail.svelte`、`AppHeader.svelte` | 图标轨 + 顶栏(范围胶囊、Ctrl K、任务托盘、本次连接);窄屏底部标签栏;去掉「手机端暂不适配」拦截 |
|
||||
| `studio/StudioGenerate.svelte`(2763 行) | 拆成 `Composer`、`WorkflowPicker`、`SlotTray`(复用 `MediaSlot` / `RefImageGrid`)、`TakeFeed` / `TakeCard`、`Inspector`;表单逻辑复用 `ComfyDynamicForm.svelte`,只改展示方式(常用芯片 + 高级折叠) |
|
||||
| `routes/image`、`routes/video`、`routes/tasks` | 三页合并为生成台的预设入口或视图;托盘复用 `JobRow.svelte` / `ActivityPanel.svelte` |
|
||||
| `routes/connections`、`WorkflowTestDialog.svelte` | 导入改为右侧抽屉,四步检查逐条显示;沿用现有 `/api/workflows/analyze`、`check`、`test` 接口,不改后端 |
|
||||
| `ToastHost.svelte`、`toast.ts` | Toast 支持操作按钮(撤销);`ConfirmDialog.svelte` 只留给不可逆的大操作 |
|
||||
| `routes/+page.svelte`、`workbench/[id]`、`StageStepper.svelte` | 「继续上次」主卡 + 五段轨道;「生成本镜视频」带上下文跳到生成台,结果回填 |
|
||||
| `SafeMedia.svelte`、`ImageLightbox.svelte` | 统一「文件缺失」状态 |
|
||||
| i18n 七个语种 | 补齐新增 key |
|
||||
|
||||
## 七、粗略施工顺序(约 8–9 人日,拍板后再细化)
|
||||
|
||||
1. Token 与动效变量(0.5 天)
|
||||
2. 壳层:图标轨、顶栏、托盘、窄屏底栏(1 天)
|
||||
3. 生成台:输入台、工作流选择器、输入槽(2 天)
|
||||
4. 记录流、检视区、撤销删除、文件缺失状态(1.5 天)
|
||||
5. 导入抽屉四步检查(1 天)
|
||||
6. 项目和创作流程接入生成台上下文(1 天)
|
||||
7. 其余页换成统一组件,窄屏走查,i18n,交总工审核(1.5 天)
|
||||
@@ -0,0 +1,758 @@
|
||||
/* HEL-648 融合原型 · 流水台结构 + 放映厅配色。色值与动效参数以 HEL-647 融合规范为准;
|
||||
设计 Token 统一在 :root,组件只引用变量 */
|
||||
:root {
|
||||
/* 蓝黑四阶:页面底 → 面板/卡片 → 浮起/选中 → 最浮/进度条底 */
|
||||
--bg: #07090e;
|
||||
--bg-2: #0c0f16;
|
||||
--bg-3: #121724;
|
||||
--bg-4: #1a2133;
|
||||
--line: #1b2233;
|
||||
--line-2: #2a3450;
|
||||
--text: #f4f6fa;
|
||||
--text-2: #aab2c5;
|
||||
--text-3: #6d7690;
|
||||
--page-glow: radial-gradient(1200px 500px at 70% -10%, rgba(38, 52, 92, 0.25), transparent 60%);
|
||||
--float-bg: linear-gradient(180deg, #10152a, var(--bg-2));
|
||||
--composer-bg: linear-gradient(180deg, #0e1220, var(--bg-2));
|
||||
--composer-focus: 0 0 0 1px rgba(242, 199, 132, 0.12), 0 8px 28px rgba(0, 0, 0, 0.4);
|
||||
--rail-bg: rgba(7, 9, 14, 0.6);
|
||||
|
||||
/* 放映金:只给关键动作与选中 */
|
||||
--accent: #f2c784;
|
||||
--accent-ink: #23170a;
|
||||
--accent-soft: rgba(242, 199, 132, 0.13);
|
||||
--accent-hi: #f7d9a2;
|
||||
--accent-lo: #ecaf63;
|
||||
--accent-glow: rgba(236, 175, 99, 0.28);
|
||||
--accent-grad: linear-gradient(180deg, var(--accent-hi), var(--accent-lo));
|
||||
--accent-grad-hover: linear-gradient(180deg, #fbe3b6, #f0b968);
|
||||
--accent-wash: linear-gradient(180deg, rgba(242, 199, 132, 0.05), transparent 40%);
|
||||
--accent-line: rgba(242, 199, 132, 0.55);
|
||||
--accent-ring: 0 0 0 1px rgba(242, 199, 132, 0.7), 0 0 22px var(--accent-glow);
|
||||
--btn-glow: 0 2px 14px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
--btn-glow-hover: 0 4px 18px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
--sel-shadow: 0 0 0 1px rgba(242, 199, 132, 0.15), 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
|
||||
/* 信号色:青=进行中/在线、绿=完成、红=失败、琥珀=待确认/缺失 */
|
||||
--run: #54dfd2;
|
||||
--run-soft: rgba(84, 223, 210, 0.13);
|
||||
--run-grad: linear-gradient(90deg, #3bbfb4, var(--run));
|
||||
--ok: #49cb90;
|
||||
--ok-soft: rgba(73, 203, 144, 0.13);
|
||||
--err: #f0645a;
|
||||
--err-soft: rgba(240, 100, 90, 0.13);
|
||||
--err-line: rgba(240, 100, 90, 0.6);
|
||||
--warn: #f0b45c;
|
||||
--warn-soft: rgba(240, 180, 92, 0.13);
|
||||
|
||||
/* 放映厅专属符号:胶片齿孔、放映光晕 */
|
||||
--sprocket: radial-gradient(circle 2px, var(--line-2) 98%, transparent);
|
||||
--stage-bg: radial-gradient(60% 45% at 50% 38%, rgba(242, 199, 132, 0.07), transparent 70%), radial-gradient(80% 70% at 50% 50%, #0d1220, #05070c);
|
||||
|
||||
/* 叠在媒体上的遮罩 */
|
||||
--media-scrim: rgba(7, 9, 14, 0.65);
|
||||
--overlay: rgba(3, 5, 9, 0.6);
|
||||
--on-media: #fff;
|
||||
--on-media-line: rgba(255, 255, 255, 0.05);
|
||||
|
||||
--r-sm: 6px;
|
||||
--r-md: 10px;
|
||||
--r-lg: 14px;
|
||||
--shadow-float: 0 14px 44px rgba(0, 0, 0, 0.6), 0 0 0 1px var(--line-2);
|
||||
|
||||
--font: "PingFang SC", "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--mono: ui-monospace, "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
|
||||
|
||||
/* 动效参数:HEL-647 动效参数总表 */
|
||||
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
|
||||
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.45, 0.44, 1);
|
||||
--d-press: 120ms;
|
||||
--d-state: 150ms;
|
||||
--d-pop: 180ms;
|
||||
--d-leave: 200ms;
|
||||
--d-enter: 220ms;
|
||||
--d-drawer: 280ms;
|
||||
--d-flash: 900ms;
|
||||
--d-progress: 400ms;
|
||||
--press-scale: 0.97;
|
||||
}
|
||||
|
||||
/* 减少动态:位移/缩放全部取消,只留 ≤120ms 透明度 */
|
||||
:root[data-reduce="1"] {
|
||||
--d-press: 0ms;
|
||||
--d-state: 120ms;
|
||||
--d-pop: 120ms;
|
||||
--d-leave: 120ms;
|
||||
--d-enter: 120ms;
|
||||
--d-drawer: 120ms;
|
||||
--d-flash: 0ms;
|
||||
--d-progress: 0ms;
|
||||
--press-scale: 1;
|
||||
--ease-spring: ease;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
background: var(--page-glow), var(--bg);
|
||||
color: var(--text);
|
||||
font: 13px/1.55 var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
button, input, textarea, select { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; background: none; border: 0; padding: 0; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--r-sm); }
|
||||
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
.muted { color: var(--text-3); }
|
||||
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||
|
||||
/* ---------- 壳层 ---------- */
|
||||
.app { display: grid; grid-template-columns: 60px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); height: 100vh; height: 100dvh; }
|
||||
.rail {
|
||||
position: relative; display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
padding: 12px 0; border-right: 1px solid var(--line); background: var(--rail-bg);
|
||||
}
|
||||
.rail .logo {
|
||||
width: 34px; height: 34px; border-radius: 9px; margin-bottom: 14px;
|
||||
display: grid; place-items: center; font-weight: 700; font-size: 16px;
|
||||
background: linear-gradient(160deg, var(--accent-hi), var(--accent-lo)); color: var(--accent-ink);
|
||||
box-shadow: 0 2px 12px var(--accent-glow);
|
||||
}
|
||||
.rail .spacer { flex: 1; }
|
||||
.rail-btn {
|
||||
position: relative; width: 44px; height: 44px; border-radius: var(--r-md);
|
||||
display: grid; place-items: center; color: var(--text-3);
|
||||
transition: color var(--d-state) ease, background-color var(--d-state) ease, transform var(--d-press) var(--ease-out);
|
||||
}
|
||||
.rail-btn:active { transform: scale(var(--press-scale)); }
|
||||
.rail-btn svg { width: 20px; height: 20px; }
|
||||
.rail-btn[aria-current="page"] { color: var(--text); background: var(--bg-3); }
|
||||
.rail-ind {
|
||||
position: absolute; left: 0; top: 0; width: 3px; height: 20px; border-radius: 0 3px 3px 0;
|
||||
background: var(--accent-grad); box-shadow: 0 0 8px var(--accent-glow); pointer-events: none; opacity: 0;
|
||||
transform: translateY(var(--y, 0));
|
||||
transition: transform var(--d-pop) var(--ease-out), opacity var(--d-state) ease;
|
||||
}
|
||||
.rail-ind.on { opacity: 1; }
|
||||
:root[data-reduce="1"] .rail-ind { transition: opacity var(--d-state) ease; }
|
||||
.rail-btn .tip {
|
||||
position: absolute; left: 52px; white-space: nowrap; pointer-events: none;
|
||||
background: var(--bg-4); color: var(--text); padding: 4px 8px; border-radius: var(--r-sm);
|
||||
font-size: 12px; opacity: 0; transform: translateX(-4px);
|
||||
transition: opacity var(--d-pop) var(--ease-out), transform var(--d-pop) var(--ease-out);
|
||||
z-index: 30;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.rail-btn:hover { color: var(--text); background: var(--bg-2); }
|
||||
.rail-btn:hover .tip { opacity: 1; transform: none; transition-delay: 350ms; }
|
||||
}
|
||||
|
||||
.main { display: grid; grid-template-rows: 52px minmax(0, 1fr); min-width: 0; }
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 10px; padding: 0 16px;
|
||||
border-bottom: 1px solid var(--line); background: var(--bg-2);
|
||||
}
|
||||
.crumb { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.crumb h1 { font-size: 15px; font-weight: 600; margin: 0; white-space: nowrap; }
|
||||
.capsule {
|
||||
display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 10px;
|
||||
border: 1px solid var(--line-2); border-radius: 999px; color: var(--text-2); white-space: nowrap;
|
||||
background: var(--bg-3);
|
||||
transition: border-color var(--d-state) ease, color var(--d-state) ease, transform var(--d-press) var(--ease-out);
|
||||
}
|
||||
.capsule:active { transform: scale(var(--press-scale)); }
|
||||
.capsule .dot { position: relative; width: 7px; height: 7px; border-radius: 50%; background: var(--run); box-shadow: 0 0 6px var(--run-soft); transition: background-color var(--d-state) ease; }
|
||||
.capsule .dot.run { background: var(--run); }
|
||||
.capsule .dot.err { background: var(--err); }
|
||||
/* 任务数变化:胶囊轻弹一次 + 状态灯扩散一圈,只播一次 */
|
||||
.capsule.bump { animation: bump 320ms var(--ease-out); }
|
||||
.capsule.bump .dot::after {
|
||||
content: ""; position: absolute; inset: -1px; border-radius: 50%; border: 2px solid currentColor;
|
||||
color: inherit; animation: ping 700ms var(--ease-out) forwards;
|
||||
}
|
||||
.capsule.bump .dot.run::after { color: var(--run); }
|
||||
@keyframes bump { 40% { transform: scale(1.06); } }
|
||||
@keyframes ping { from { transform: scale(1); opacity: 0.9; } to { transform: scale(3.2); opacity: 0; } }
|
||||
:root[data-reduce="1"] .capsule.bump, :root[data-reduce="1"] .capsule.bump .dot::after { animation: none; }
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.capsule:hover { border-color: var(--text-3); color: var(--text); }
|
||||
}
|
||||
.topbar .grow { flex: 1; }
|
||||
.kbd {
|
||||
font: 11px var(--mono); color: var(--text-3); border: 1px solid var(--line-2);
|
||||
border-bottom-width: 2px; border-radius: 4px; padding: 0 5px; line-height: 16px;
|
||||
}
|
||||
.search-btn { min-width: 240px; justify-content: space-between; }
|
||||
|
||||
/* 通用按钮 */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
height: 32px; padding: 0 12px; border-radius: var(--r-md);
|
||||
border: 1px solid var(--line-2); color: var(--text); background: var(--bg-3);
|
||||
transition: transform var(--d-press) var(--ease-out), background-color var(--d-state) ease, border-color var(--d-state) ease, box-shadow var(--d-state) ease, color var(--d-state) ease, opacity var(--d-state) ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
.btn:active:not(:disabled) { transform: scale(var(--press-scale)); }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.btn.primary { background: var(--accent-grad); color: var(--accent-ink); border-color: transparent; font-weight: 600; box-shadow: var(--btn-glow); }
|
||||
.btn.primary:disabled { box-shadow: none; }
|
||||
.btn.ghost { background: transparent; border-color: transparent; color: var(--text-2); }
|
||||
.btn.danger { color: var(--err); }
|
||||
.btn.danger-solid { background: var(--err); border-color: transparent; color: var(--on-media); font-weight: 600; }
|
||||
.btn.sm { height: 26px; padding: 0 8px; font-size: 12px; border-radius: var(--r-sm); }
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.btn:hover:not(:disabled) { border-color: var(--text-3); }
|
||||
.btn.primary:hover:not(:disabled) { background: var(--accent-grad-hover); box-shadow: var(--btn-glow-hover); }
|
||||
.btn.danger-solid:hover:not(:disabled) { box-shadow: 0 0 0 1px var(--err-line), 0 0 18px var(--err-soft); }
|
||||
.btn.ghost:hover:not(:disabled) { background: var(--bg-4); color: var(--text); }
|
||||
.btn.ghost.danger:hover:not(:disabled) { background: var(--err-soft); color: var(--err); }
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 7px;
|
||||
border-radius: 999px; font-size: 11px; color: var(--text-2); background: var(--bg-4);
|
||||
white-space: nowrap;
|
||||
transition: color var(--d-state) ease, background-color var(--d-state) ease;
|
||||
}
|
||||
.tag.swap { animation: tag-swap var(--d-enter) var(--ease-out); }
|
||||
@keyframes tag-swap { from { opacity: 0.2; transform: translateY(3px); } }
|
||||
:root[data-reduce="1"] .tag.swap { animation: fade-in var(--d-state) ease; }
|
||||
@keyframes fade-in { from { opacity: 0; } }
|
||||
.tag.accent { color: var(--accent); background: var(--accent-soft); }
|
||||
.tag.ok { color: var(--ok); background: var(--ok-soft); }
|
||||
.tag.run { color: var(--run); background: var(--run-soft); }
|
||||
.tag.err { color: var(--err); background: var(--err-soft); }
|
||||
.tag.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
|
||||
/* ---------- 视图切换 ---------- */
|
||||
.view { display: none; min-height: 0; }
|
||||
.view.on { display: grid; animation: fade-in var(--d-enter) var(--ease-out); }
|
||||
.page.on { animation: view-in var(--d-enter) var(--ease-out); }
|
||||
@keyframes view-in { from { opacity: 0; transform: translateY(6px); } }
|
||||
:root[data-reduce="1"] .page.on { animation: fade-in var(--d-state) ease; }
|
||||
|
||||
/* ---------- 生成台 ---------- */
|
||||
.desk {
|
||||
grid-template-columns: minmax(0, 1fr) 420px; grid-template-rows: minmax(0, 1fr); min-height: 0;
|
||||
transition: grid-template-columns var(--d-drawer) var(--ease-drawer);
|
||||
}
|
||||
.desk.insp-off { grid-template-columns: minmax(0, 1fr) 0px; }
|
||||
:root[data-reduce="1"] .desk { transition: none; }
|
||||
.desk-center { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
||||
.context-bar {
|
||||
display: none; align-items: center; gap: 10px; margin: 10px 20px 0; padding: 8px 12px;
|
||||
border-radius: var(--r-md); background: var(--run-soft); color: var(--text); font-size: 12px;
|
||||
}
|
||||
.context-bar.on { display: flex; }
|
||||
.context-bar b { color: var(--run); font-weight: 600; }
|
||||
.filters { display: flex; align-items: center; gap: 6px; padding: 10px 20px; border-bottom: 1px solid var(--line); }
|
||||
|
||||
/* 分段控件:选中底块在选项间滑动 */
|
||||
.seg { position: relative; display: inline-flex; padding: 2px; border-radius: var(--r-md); background: var(--bg-2); border: 1px solid var(--line); }
|
||||
.seg-pill {
|
||||
position: absolute; top: 2px; bottom: 2px; left: 0; width: var(--w, 0); border-radius: 8px;
|
||||
background: var(--bg-4); box-shadow: inset 0 0 0 1px var(--line-2); pointer-events: none;
|
||||
transform: translateX(var(--x, 0));
|
||||
transition: transform var(--d-pop) var(--ease-out), width var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.seg-pill.instant, :root[data-reduce="1"] .seg-pill { transition: none; }
|
||||
.seg button {
|
||||
position: relative; height: 26px; padding: 0 10px; border-radius: 8px; color: var(--text-3);
|
||||
transition: color var(--d-state) ease;
|
||||
}
|
||||
.seg button[aria-pressed="true"] { color: var(--text); }
|
||||
.filters .count { margin-left: auto; color: var(--text-3); font-size: 12px; }
|
||||
.insp-open { display: none; }
|
||||
.desk.insp-off .insp-open { display: inline-flex; }
|
||||
|
||||
.feed { position: relative; flex: 1; overflow-y: auto; padding: 16px 20px 8px; scroll-behavior: smooth; }
|
||||
:root[data-reduce="1"] .feed { scroll-behavior: auto; }
|
||||
.feed-day { color: var(--text-3); font-size: 12px; margin: 10px 0 8px; display: flex; align-items: center; gap: 8px; }
|
||||
.feed-day::after { content: ""; flex: 1; height: 1px; background: var(--line); }
|
||||
|
||||
/* 选中框:金色描边从上一条记录滑到新选中的记录(app.js slideMark) */
|
||||
.sel-mark {
|
||||
position: absolute; inset: -1px; z-index: 3; border: 1px solid var(--accent-line); border-radius: inherit;
|
||||
pointer-events: none; display: none;
|
||||
}
|
||||
.take.selected > .sel-mark { display: block; }
|
||||
|
||||
.take {
|
||||
position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px 14px;
|
||||
padding: 12px 14px; margin-bottom: 8px; border-radius: var(--r-lg);
|
||||
border: 1px solid var(--line); background: var(--bg-2); cursor: pointer;
|
||||
transition: border-color var(--d-state) ease, background-color var(--d-state) ease, opacity var(--d-enter) var(--ease-out), transform var(--d-enter) var(--ease-out);
|
||||
}
|
||||
.take.new { animation: take-in var(--d-enter) var(--ease-out); }
|
||||
@keyframes take-in { from { opacity: 0; transform: translateY(8px); } }
|
||||
:root[data-reduce="1"] .take.new { animation: fade-in var(--d-state) ease; }
|
||||
.take.selected { background: var(--accent-wash), var(--bg-3); border-color: transparent; box-shadow: var(--sel-shadow); }
|
||||
.take.leaving { opacity: 0; transform: translateX(-10px); pointer-events: none; transition-duration: var(--d-leave); transition-timing-function: var(--ease-drawer); }
|
||||
:root[data-reduce="1"] .take.leaving { transform: none; }
|
||||
.take.done-flash { animation: done-flash var(--d-flash) var(--ease-out); }
|
||||
@keyframes done-flash { from { box-shadow: var(--accent-ring); } }
|
||||
.take.shake { animation: shake 320ms var(--ease-out); }
|
||||
@keyframes shake { 20% { transform: translateX(-4px); } 45% { transform: translateX(4px); } 70% { transform: translateX(-2px); } }
|
||||
:root[data-reduce="1"] .take.shake, :root[data-reduce="1"] .take.done-flash { animation: none; }
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.take:hover:not(.selected) { border-color: var(--line-2); background: var(--bg-3); }
|
||||
}
|
||||
.take-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.take-head .name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.take-head .meta { color: var(--text-3); font-size: 12px; white-space: nowrap; }
|
||||
.take-prompt {
|
||||
grid-column: 1; color: var(--text-2); font-size: 12.5px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.take-inputs { display: flex; gap: 4px; align-items: center; }
|
||||
.take-inputs .mini { width: 26px; height: 26px; border-radius: 5px; background-size: cover; background-position: center; border: 1px solid var(--line-2); }
|
||||
.take-inputs .more { font-size: 11px; color: var(--text-3); padding-left: 2px; }
|
||||
.take-out { grid-column: 2; grid-row: 1 / span 3; align-self: start; }
|
||||
.thumb {
|
||||
position: relative; width: 150px; aspect-ratio: 16 / 9; border-radius: var(--r-md); overflow: hidden;
|
||||
background: var(--bg-4) center / cover no-repeat; box-shadow: inset 0 0 0 1px var(--on-media-line);
|
||||
}
|
||||
.thumb.portrait { width: 96px; aspect-ratio: 3 / 4; }
|
||||
.thumb.fresh { animation: thumb-in 320ms var(--ease-out); }
|
||||
@keyframes thumb-in { from { opacity: 0; transform: scale(0.96); } }
|
||||
:root[data-reduce="1"] .thumb.fresh { animation: fade-in var(--d-state) ease; }
|
||||
.thumb .badge {
|
||||
position: absolute; right: 6px; bottom: 6px; font: 11px var(--mono); padding: 1px 5px; border-radius: 4px;
|
||||
background: var(--media-scrim); color: var(--on-media);
|
||||
}
|
||||
.thumb .play {
|
||||
position: absolute; left: 6px; bottom: 6px; width: 22px; height: 22px; border-radius: 50%;
|
||||
display: grid; place-items: center; background: var(--media-scrim);
|
||||
}
|
||||
.thumb .play svg { width: 10px; height: 10px; fill: var(--on-media); }
|
||||
.thumb.missing, .thumb.pending {
|
||||
display: grid; place-items: center; text-align: center; font-size: 11px; color: var(--text-3);
|
||||
border: 1px dashed var(--line-2); background: var(--bg-2); padding: 6px;
|
||||
}
|
||||
.thumb.pending { border-style: solid; overflow: hidden; color: var(--run); }
|
||||
.thumb.pending::after {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: linear-gradient(100deg, transparent 30%, var(--run-soft) 50%, transparent 70%);
|
||||
animation: sheen 1.4s linear infinite;
|
||||
}
|
||||
@keyframes sheen { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
|
||||
:root[data-reduce="1"] .thumb.pending::after { animation: none; }
|
||||
|
||||
.take-state { grid-column: 1; display: flex; align-items: center; gap: 8px; font-size: 12px; min-height: 22px; }
|
||||
.take-state .txt { color: var(--text-2); }
|
||||
.take-state .err-txt { color: var(--err); }
|
||||
.bar { position: relative; flex: 0 0 140px; height: 4px; border-radius: 4px; background: var(--bg-4); overflow: hidden; }
|
||||
.bar i {
|
||||
position: absolute; inset: 0; background: var(--run-grad); box-shadow: 0 0 8px var(--run-soft); transform-origin: left;
|
||||
transform: scaleX(var(--p, 0)); transition: transform var(--d-progress) linear;
|
||||
}
|
||||
.take-actions { display: flex; gap: 4px; margin-left: auto; opacity: 0; transition: opacity var(--d-state) ease; }
|
||||
.take:focus-within .take-actions, .take.selected .take-actions { opacity: 1; }
|
||||
@media (hover: hover) and (pointer: fine) { .take:hover .take-actions { opacity: 1; } }
|
||||
@media (hover: none) { .take-actions { opacity: 1; } }
|
||||
/* 高度展开/收起:0fr ↔ 1fr,可中途反向 */
|
||||
.collapse {
|
||||
display: grid; grid-template-rows: 0fr; opacity: 0;
|
||||
transition: grid-template-rows var(--d-enter) var(--ease-out), opacity var(--d-state) ease;
|
||||
}
|
||||
.collapse.open { grid-template-rows: 1fr; opacity: 1; }
|
||||
.collapse > .collapse-in { overflow: hidden; min-height: 0; }
|
||||
:root[data-reduce="1"] .collapse { transition: opacity var(--d-state) ease; }
|
||||
.take > .collapse { grid-column: 1 / -1; margin-top: -10px; }
|
||||
.err-detail { margin: 10px 0 0; padding: 8px 10px 8px 26px; border-radius: var(--r-md); background: var(--err-soft); color: var(--text-2); font-size: 12px; }
|
||||
.err-detail li { margin: 2px 0; }
|
||||
|
||||
/* ---------- 输入台 ---------- */
|
||||
.composer-wrap { position: relative; padding: 12px 20px 16px; }
|
||||
/* 胶片齿孔:只出现在记录流与输入台之间 */
|
||||
.composer-wrap::before {
|
||||
content: ""; position: absolute; left: 24px; right: 24px; top: 3px; height: 6px;
|
||||
background-image: var(--sprocket); background-size: 18px 6px; background-repeat: repeat-x; background-position: center;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.composer {
|
||||
border: 1px solid var(--line-2); border-radius: 16px; background: var(--composer-bg);
|
||||
transition: border-color var(--d-state) ease, box-shadow var(--d-state) ease;
|
||||
}
|
||||
.composer:focus-within { border-color: var(--accent-line); box-shadow: var(--composer-focus); }
|
||||
.composer.drag { border-color: var(--accent-line); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.slots { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 12px 0; overflow: hidden; }
|
||||
.slots:empty { padding-top: 0; }
|
||||
.slot-group.enter { animation: view-in var(--d-enter) var(--ease-out); }
|
||||
:root[data-reduce="1"] .slot-group.enter { animation: fade-in var(--d-state) ease; }
|
||||
.slot-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.slot-label { font-size: 11.5px; color: var(--text-3); display: flex; gap: 6px; align-items: center; }
|
||||
.slot-label .req { color: var(--warn); }
|
||||
.slot-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tile {
|
||||
position: relative; width: 58px; height: 58px; border-radius: var(--r-md);
|
||||
background: var(--bg-3) center / cover no-repeat; border: 1px solid var(--line-2);
|
||||
display: grid; place-items: center; color: var(--text-3);
|
||||
transition: border-color var(--d-state) ease, background-color var(--d-state) ease, color var(--d-state) ease, transform var(--d-press) var(--ease-out), opacity var(--d-state) ease;
|
||||
}
|
||||
.tile.enter { animation: tile-in var(--d-enter) var(--ease-spring); }
|
||||
@keyframes tile-in { from { opacity: 0; transform: scale(0.86); } }
|
||||
:root[data-reduce="1"] .tile.enter { animation: fade-in var(--d-state) ease; }
|
||||
.tile.out { opacity: 0; transform: scale(0.86); pointer-events: none; }
|
||||
:root[data-reduce="1"] .tile.out { transform: none; }
|
||||
.tile.add { border-style: dashed; }
|
||||
.tile.add svg { width: 18px; height: 18px; }
|
||||
.tile.add:active { transform: scale(var(--press-scale)); }
|
||||
.tile.drag { border-color: var(--accent); background-color: var(--accent-soft); color: var(--accent); }
|
||||
.tile.bad { border-color: var(--err); }
|
||||
.tile.need { border-color: var(--warn); animation: shake 320ms var(--ease-out); }
|
||||
:root[data-reduce="1"] .tile.need { animation: none; }
|
||||
/* 上传:真实进度环 → 成功勾 + 一次金色光晕 */
|
||||
.tile .ring {
|
||||
position: absolute; inset: 0; border-radius: inherit; background: var(--media-scrim);
|
||||
display: grid; place-items: center; transition: opacity var(--d-state) ease;
|
||||
}
|
||||
.tile .ring i {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
background: conic-gradient(var(--accent) calc(var(--p, 0) * 1turn), rgba(255, 255, 255, 0.16) 0);
|
||||
-webkit-mask: radial-gradient(circle, transparent 9.5px, #000 10.5px);
|
||||
mask: radial-gradient(circle, transparent 9.5px, #000 10.5px);
|
||||
}
|
||||
.tile .ring.done { opacity: 0; }
|
||||
.tile.flash { animation: tile-flash var(--d-flash) var(--ease-out); }
|
||||
@keyframes tile-flash { from { box-shadow: var(--accent-ring); border-color: var(--accent); } }
|
||||
:root[data-reduce="1"] .tile.flash { animation: none; }
|
||||
.tile .ok-badge {
|
||||
position: absolute; right: 3px; bottom: 3px; width: 16px; height: 16px; border-radius: 50%;
|
||||
display: grid; place-items: center; background: var(--ok); color: var(--bg);
|
||||
animation: pop-in var(--d-enter) var(--ease-spring); transition: opacity var(--d-state) ease;
|
||||
}
|
||||
.tile .ok-badge svg { width: 10px; height: 10px; }
|
||||
.tile .ok-badge.gone { opacity: 0; }
|
||||
@keyframes pop-in { from { opacity: 0; transform: scale(0.4); } }
|
||||
:root[data-reduce="1"] .tile .ok-badge { animation: fade-in var(--d-state) ease; }
|
||||
.tile .x {
|
||||
position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%;
|
||||
background: var(--bg-4); border: 1px solid var(--line-2); display: grid; place-items: center; opacity: 0;
|
||||
transition: opacity var(--d-state) ease;
|
||||
}
|
||||
.tile .x svg { width: 9px; height: 9px; }
|
||||
.tile:focus-within .x { opacity: 1; }
|
||||
@media (hover: hover) and (pointer: fine) { .tile:hover .x { opacity: 1; } .tile.add:hover { border-color: var(--text-3); color: var(--text); } }
|
||||
@media (hover: none) { .tile .x { opacity: 1; } }
|
||||
.tile .idx { position: absolute; left: 3px; top: 2px; font: 10px var(--mono); color: var(--on-media); text-shadow: 0 1px 2px #000; }
|
||||
.slot-hint { font-size: 11px; color: var(--text-3); }
|
||||
.slot-hint.err { color: var(--err); }
|
||||
.slot-label .cnt { transition: color var(--d-state) ease; }
|
||||
.slot-label .cnt.tick { color: var(--accent); }
|
||||
|
||||
.prompt {
|
||||
display: block; width: 100%; resize: none; border: 0; outline: 0; background: transparent;
|
||||
padding: 12px 14px 6px; min-height: 52px; max-height: 180px; line-height: 1.6;
|
||||
}
|
||||
.prompt::placeholder { color: var(--text-3); }
|
||||
.composer-foot { display: flex; align-items: center; gap: 6px; padding: 6px 8px 8px 8px; flex-wrap: wrap; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; border-radius: 999px;
|
||||
background: var(--bg-3); color: var(--text-2); font-size: 12px; white-space: nowrap; max-width: 280px;
|
||||
transition: background-color var(--d-state) ease, color var(--d-state) ease, transform var(--d-press) var(--ease-out);
|
||||
}
|
||||
.chip:active { transform: scale(var(--press-scale)); }
|
||||
.chip .lbl { overflow: hidden; text-overflow: ellipsis; }
|
||||
.chip svg { width: 14px; height: 14px; flex: none; }
|
||||
.chip.wf { color: var(--text); background: var(--bg-4); font-weight: 500; box-shadow: inset 0 0 0 1px var(--line-2); }
|
||||
.chip.wf.flash { animation: chip-flash 700ms var(--ease-out); }
|
||||
@keyframes chip-flash { from { background: var(--accent-soft); color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent-line); } }
|
||||
:root[data-reduce="1"] .chip.wf.flash { animation: none; }
|
||||
.chip.warn { color: var(--warn); }
|
||||
.chip.err { color: var(--err); }
|
||||
.chip.changed { color: var(--accent); }
|
||||
@media (hover: hover) and (pointer: fine) { .chip:hover { background: var(--bg-4); color: var(--text); } }
|
||||
.submit-group { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
||||
.why { font-size: 12px; color: var(--warn); white-space: nowrap; text-decoration: underline dotted; text-underline-offset: 3px; }
|
||||
.why.err { color: var(--err); }
|
||||
/* 生成按钮:按下即缩,提交后文字换成「已提交」约 0.9 秒,期间仍可再次提交 */
|
||||
.gen-btn { height: 34px; padding: 0 18px; border-radius: 999px; display: inline-grid; min-width: 84px; }
|
||||
.gen-btn > span { grid-area: 1 / 1; display: inline-flex; align-items: center; justify-content: center; gap: 4px; transition: opacity var(--d-state) ease, transform var(--d-pop) var(--ease-out); }
|
||||
.gen-btn > span svg { width: 13px; height: 13px; }
|
||||
.gen-btn .l-sent { opacity: 0; transform: translateY(8px); }
|
||||
.gen-btn.sent .l-idle { opacity: 0; transform: translateY(-8px); }
|
||||
.gen-btn.sent .l-sent { opacity: 1; transform: none; }
|
||||
:root[data-reduce="1"] .gen-btn > span { transform: none !important; }
|
||||
|
||||
/* ---------- 右侧检视区 ---------- */
|
||||
.inspector {
|
||||
border-left: 1px solid var(--line); display: flex; flex-direction: column; align-items: flex-end;
|
||||
min-height: 0; min-width: 0; overflow: hidden; background: var(--bg);
|
||||
transition: opacity var(--d-drawer) ease;
|
||||
}
|
||||
.inspector > * { width: 420px; max-width: 100%; flex: none; }
|
||||
.inspector > .insp-body { flex: 1; }
|
||||
.desk.insp-off .inspector { opacity: 0; pointer-events: none; }
|
||||
.insp-tabs { display: flex; gap: 4px; padding: 10px 12px; border-bottom: 1px solid var(--line); align-items: center; }
|
||||
.insp-body { overflow-y: auto; padding: 14px; }
|
||||
.insp-body.slide-l { animation: slide-l var(--d-pop) var(--ease-out); }
|
||||
.insp-body.slide-r { animation: slide-r var(--d-pop) var(--ease-out); }
|
||||
@keyframes slide-l { from { opacity: 0; transform: translateX(-10px); } }
|
||||
@keyframes slide-r { from { opacity: 0; transform: translateX(10px); } }
|
||||
:root[data-reduce="1"] .insp-body.slide-l, :root[data-reduce="1"] .insp-body.slide-r { animation: fade-in var(--d-state) ease; }
|
||||
.stage { position: relative; border-radius: var(--r-lg); overflow: hidden; background: var(--stage-bg); aspect-ratio: 16 / 9; box-shadow: 0 0 0 1px var(--line); transition: box-shadow var(--d-state) ease; }
|
||||
.stage.playing { box-shadow: 0 0 0 1px var(--accent-line); }
|
||||
.stage-badge {
|
||||
position: absolute; left: 10px; top: 10px; z-index: 4; display: inline-flex; align-items: center; gap: 6px;
|
||||
height: 24px; padding: 0 9px; border-radius: 999px; font-size: 11px; color: var(--accent);
|
||||
background: var(--media-scrim); box-shadow: inset 0 0 0 1px var(--accent-line); pointer-events: none;
|
||||
opacity: 0; transition: opacity var(--d-state) ease;
|
||||
}
|
||||
.stage.playing .stage-badge { opacity: 1; }
|
||||
.stage-badge .eq { display: inline-flex; gap: 2px; align-items: flex-end; height: 10px; }
|
||||
.stage-badge .eq i { width: 2px; background: currentColor; border-radius: 1px; animation: eq 900ms ease-in-out infinite; }
|
||||
.stage-badge .eq i:nth-child(2) { animation-delay: -300ms; }
|
||||
.stage-badge .eq i:nth-child(3) { animation-delay: -600ms; }
|
||||
@keyframes eq { 0%, 100% { height: 3px; } 50% { height: 10px; } }
|
||||
:root[data-reduce="1"] .stage-badge .eq i { animation: none; height: 7px; }
|
||||
.stage-progress { position: absolute; left: 16px; right: 16px; bottom: 16px; height: 3px; border-radius: 3px; background: var(--bg-4); overflow: hidden; }
|
||||
.stage-progress i { position: absolute; inset: 0; background: var(--run-grad); transform-origin: left; transform: scaleX(var(--p, 0)); transition: transform var(--d-progress) linear; }
|
||||
.stage.portrait { aspect-ratio: 3 / 4; max-height: 480px; margin: 0 auto; }
|
||||
.stage > img, .stage > video, .stage > .stage-empty { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; transition: opacity var(--d-state) ease; }
|
||||
.stage .fade-out { opacity: 0; }
|
||||
.stage-empty { display: grid; place-items: center; color: var(--text-3); font-size: 12px; background: var(--bg-2); }
|
||||
.stage-meta { display: flex; flex-wrap: wrap; gap: 6px 12px; margin: 12px 0; font-size: 12px; color: var(--text-3); }
|
||||
.stage-meta b { color: var(--text-2); font-weight: 500; }
|
||||
.stage-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.section-title { font-size: 12px; color: var(--text-3); margin: 18px 0 8px; display: flex; align-items: center; gap: 8px; }
|
||||
.kv { display: grid; grid-template-columns: 92px 1fr; gap: 6px 10px; font-size: 12px; }
|
||||
.kv dt { color: var(--text-3); }
|
||||
.kv dd { margin: 0; color: var(--text-2); word-break: break-all; }
|
||||
|
||||
/* 参数表单(按工作流 JSON 生成) */
|
||||
.field { display: grid; gap: 6px; margin-bottom: 12px; }
|
||||
.field label { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-2); }
|
||||
.field label .orig { color: var(--text-3); font-family: var(--mono); font-size: 11px; }
|
||||
.input {
|
||||
height: 32px; border-radius: var(--r-sm); border: 1px solid var(--line-2); background: var(--bg-3);
|
||||
padding: 0 10px; outline: 0; width: 100%;
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.input:focus { border-color: var(--text-3); }
|
||||
.input.changed { border-color: var(--accent-line); }
|
||||
textarea.input { height: auto; min-height: 64px; padding: 8px 10px; resize: vertical; }
|
||||
.fold { border-top: 1px solid var(--line); margin-top: 6px; }
|
||||
.fold-head {
|
||||
width: 100%; padding: 10px 0; display: flex; align-items: center; gap: 6px; color: var(--text-2); font-size: 12px; text-align: left;
|
||||
transition: color var(--d-state) ease;
|
||||
}
|
||||
.fold-head svg { width: 12px; height: 12px; transition: transform var(--d-pop) var(--ease-out); }
|
||||
.fold-head[aria-expanded="true"] svg { transform: rotate(90deg); }
|
||||
@media (hover: hover) and (pointer: fine) { .fold-head:hover { color: var(--text); } }
|
||||
.fold .collapse-in > :first-child { margin-top: 2px; }
|
||||
.note { font-size: 12px; color: var(--text-3); padding: 8px 10px; border-radius: var(--r-md); background: var(--bg-3); margin-bottom: 12px; }
|
||||
.note.warn { color: var(--warn); background: var(--warn-soft); }
|
||||
.note.err { color: var(--err); background: var(--err-soft); }
|
||||
.note ul { margin: 6px 0 0; padding-left: 18px; }
|
||||
|
||||
/* ---------- 浮层:工作流面板 ---------- */
|
||||
.pop {
|
||||
position: fixed; z-index: 50; width: 520px; max-height: min(560px, 70vh); display: flex; flex-direction: column;
|
||||
border-radius: var(--r-lg); background: var(--float-bg); box-shadow: var(--shadow-float);
|
||||
opacity: 0; transform: scale(0.96); pointer-events: none; transform-origin: var(--origin, bottom left);
|
||||
transition: opacity var(--d-pop) var(--ease-out), transform var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.pop.on { opacity: 1; transform: none; pointer-events: auto; }
|
||||
.pop.instant { transition: none; }
|
||||
:root[data-reduce="1"] .pop { transform: none; }
|
||||
.pop-search { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
|
||||
.pop-search input { flex: 1; background: transparent; border: 0; outline: 0; font-size: 14px; }
|
||||
.pop-list { overflow-y: auto; padding: 6px; }
|
||||
.pop-group { font-size: 11px; color: var(--text-3); padding: 10px 10px 4px; display: flex; gap: 6px; }
|
||||
.wf-row {
|
||||
display: grid; grid-template-columns: 1fr auto; gap: 2px 10px; width: 100%; text-align: left;
|
||||
padding: 8px 10px; border-radius: var(--r-md);
|
||||
}
|
||||
.wf-row[aria-selected="true"] { background: var(--bg-4); }
|
||||
.wf-row .n .cur { color: var(--accent); }
|
||||
.wf-row .n { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.wf-row .s { grid-column: 1; display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.wf-row .r { grid-column: 2; grid-row: 1 / span 2; align-self: center; font-size: 11px; text-align: right; }
|
||||
.wf-row.off .n { color: var(--text-3); }
|
||||
.pop-foot { border-top: 1px solid var(--line); padding: 8px; display: flex; gap: 6px; align-items: center; }
|
||||
.pop-foot .hint { margin-left: auto; font-size: 11px; color: var(--text-3); display: flex; gap: 6px; align-items: center; }
|
||||
|
||||
.menu { width: 260px; }
|
||||
.menu .pop-list button { display: flex; width: 100%; padding: 8px 10px; border-radius: var(--r-sm); gap: 8px; align-items: center; transition: background-color var(--d-state) ease; }
|
||||
.menu .pop-list button:hover, .menu .pop-list button[aria-selected="true"] { background: var(--bg-4); }
|
||||
.menu .pop-list button[aria-selected="true"] { color: var(--accent); }
|
||||
|
||||
/* 删除确认:只用于删工作流/删项目这类不可逆的大操作;屏幕居中,从中心 scale(.96)→1 */
|
||||
.danger-zone { margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.confirm { z-index: 62; width: 400px; padding: 16px 16px 14px; gap: 10px; border: 1px solid var(--err-line); }
|
||||
.confirm h3 { margin: 0; font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||||
.confirm h3::before { content: "!"; width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 700; color: var(--err); background: var(--err-soft); }
|
||||
.confirm p { margin: 0; color: var(--text-2); font-size: 12px; }
|
||||
.confirm .row-actions { justify-content: flex-end; }
|
||||
|
||||
/* ---------- 抽屉:导入工作流 ---------- */
|
||||
.scrim { position: fixed; inset: 0; background: var(--overlay); opacity: 0; pointer-events: none; transition: opacity var(--d-drawer) ease; z-index: 60; }
|
||||
.scrim.on { opacity: 1; pointer-events: auto; }
|
||||
.drawer {
|
||||
position: fixed; top: 0; right: 0; bottom: 0; width: 520px; max-width: 100vw; z-index: 61;
|
||||
background: var(--float-bg); border-left: 1px solid var(--line-2); display: flex; flex-direction: column;
|
||||
transform: translateX(100%); transition: transform var(--d-drawer) var(--ease-drawer);
|
||||
}
|
||||
.drawer.on { transform: none; }
|
||||
:root[data-reduce="1"] .drawer { transform: none; opacity: 0; pointer-events: none; transition: opacity var(--d-drawer) ease; }
|
||||
:root[data-reduce="1"] .drawer.on { opacity: 1; pointer-events: auto; }
|
||||
.drawer-head { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--line); }
|
||||
.drawer-head h2 { font-size: 15px; margin: 0; flex: 1; }
|
||||
.drawer-body { flex: 1; overflow-y: auto; padding: 16px; }
|
||||
.drawer-foot { display: flex; gap: 8px; justify-content: flex-end; padding: 12px 16px; border-top: 1px solid var(--line); }
|
||||
.drop {
|
||||
border: 1px dashed var(--line-2); border-radius: var(--r-lg); padding: 14px; display: grid; gap: 10px;
|
||||
transition: border-color var(--d-state) ease, background-color var(--d-state) ease;
|
||||
}
|
||||
.drop.drag { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.drop textarea { width: 100%; min-height: 110px; background: var(--bg); border: 1px solid var(--line); border-radius: var(--r-md); padding: 10px; font: 12px/1.5 var(--mono); color: var(--text-2); resize: vertical; outline: 0; }
|
||||
.checks { display: grid; gap: 8px; margin-top: 14px; }
|
||||
.check { display: grid; grid-template-columns: 22px 1fr; gap: 2px 10px; padding: 10px 12px; border-radius: var(--r-md); background: var(--bg); border: 1px solid var(--line); }
|
||||
.check .ic { width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-4); color: var(--text-3); font-size: 11px; transition: background-color var(--d-state) ease, color var(--d-state) ease; }
|
||||
.check.ok .ic { background: var(--ok-soft); color: var(--ok); }
|
||||
.check.bad .ic { background: var(--err-soft); color: var(--err); }
|
||||
.check.run .ic { background: var(--run-soft); color: var(--run); }
|
||||
.check .t { font-weight: 500; }
|
||||
.check .d { grid-column: 2; color: var(--text-3); font-size: 12px; }
|
||||
.check .d ul { margin: 4px 0 0; padding-left: 16px; }
|
||||
.slot-map { display: grid; gap: 6px; margin-top: 8px; }
|
||||
.slot-map div { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 6px 8px; border-radius: var(--r-sm); background: var(--bg-3); }
|
||||
.usage-pick { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.usage-pick button { height: 28px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--line-2); color: var(--text-2); font-size: 12px; }
|
||||
.usage-pick button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
/* ---------- Toast ---------- */
|
||||
.toasts { position: fixed; right: 24px; bottom: 24px; z-index: 80; display: grid; gap: 8px; pointer-events: none; max-width: 400px; }
|
||||
.toast {
|
||||
display: flex; align-items: center; gap: 12px; padding: 10px 12px 10px 14px; border-radius: var(--r-md);
|
||||
background: var(--float-bg); box-shadow: var(--shadow-float); pointer-events: auto; min-width: 280px;
|
||||
transition: opacity var(--d-enter) var(--ease-drawer), transform var(--d-enter) var(--ease-drawer);
|
||||
}
|
||||
@starting-style { .toast { opacity: 0; transform: translateY(100%); } }
|
||||
.toast.out { opacity: 0; transform: translateY(100%); }
|
||||
:root[data-reduce="1"] .toast, :root[data-reduce="1"] .toast.out { transform: none; }
|
||||
.toast .grow { flex: 1; }
|
||||
.toast .ic { width: 18px; height: 18px; border-radius: 50%; flex: none; display: grid; place-items: center; background: var(--ok-soft); color: var(--ok); }
|
||||
.toast .ic svg { width: 10px; height: 10px; }
|
||||
.toast .ic.err { background: var(--err-soft); color: var(--err); }
|
||||
.toast button { color: var(--accent); font-weight: 600; }
|
||||
|
||||
/* ---------- 任务托盘 ---------- */
|
||||
.tray { width: 340px; }
|
||||
.tray-row { display: grid; grid-template-columns: 36px 1fr auto; gap: 10px; align-items: center; padding: 8px 10px; border-radius: var(--r-md); width: 100%; text-align: left; }
|
||||
.tray-row:hover { background: var(--bg-3); }
|
||||
.tray-row .t { width: 36px; height: 36px; border-radius: 6px; background: var(--bg-4) center / cover; }
|
||||
.tray-row .n { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tray-row .m { font-size: 11px; color: var(--text-3); }
|
||||
|
||||
/* ---------- 项目 / 创作流程 ---------- */
|
||||
.page { overflow-y: auto; padding: 24px 28px 40px; align-content: start; gap: 18px; }
|
||||
.hero {
|
||||
display: grid; grid-template-columns: 300px 1fr; gap: 20px; padding: 16px; border-radius: var(--r-lg); background: var(--bg-2); border: 1px solid var(--line); overflow: hidden;
|
||||
}
|
||||
.hero .cover { aspect-ratio: 16 / 9; border-radius: var(--r-md); background: var(--bg-4) center / cover; box-shadow: inset 0 0 0 1px var(--on-media-line); }
|
||||
.hero h2 { margin: 2px 0 6px; font-size: 20px; }
|
||||
.hero p { margin: 0 0 12px; color: var(--text-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.track { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; margin: 10px 0 14px; }
|
||||
.track button { text-align: left; padding: 8px 10px; border-radius: var(--r-md); background: var(--bg-3); font-size: 12px; color: var(--text-2); transition: background-color var(--d-state) ease; }
|
||||
.track button .k { display: block; font-size: 11px; color: var(--text-3); }
|
||||
.track button.done .k { color: var(--ok); }
|
||||
.track button.cur { background: var(--accent-wash), var(--bg-4); color: var(--text); box-shadow: inset 0 -2px 0 var(--accent-lo); }
|
||||
.track button.cur .k { color: var(--accent); }
|
||||
.track button[aria-current="step"] { outline: 1px solid var(--text-3); }
|
||||
.proj-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
|
||||
.proj { display: grid; gap: 6px; padding: 12px; border-radius: var(--r-lg); background: var(--bg-2); border: 1px solid var(--line); text-align: left; transition: border-color var(--d-state) ease, background-color var(--d-state) ease, transform var(--d-press) var(--ease-out); }
|
||||
.proj:active { transform: scale(var(--press-scale)); }
|
||||
@media (hover: hover) and (pointer: fine) { .proj:hover { border-color: var(--line-2); background: var(--bg-3); } }
|
||||
.proj .pips { display: flex; gap: 3px; }
|
||||
.proj .pips i { flex: 1; height: 3px; border-radius: 3px; background: var(--bg-4); }
|
||||
.proj .pips i.d { background: var(--ok); }
|
||||
.proj .pips i.c { background: var(--accent); }
|
||||
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.shot { display: grid; grid-template-columns: 44px 1fr auto; gap: 12px; padding: 12px 14px; border-radius: var(--r-lg); background: var(--bg-2); border: 1px solid var(--line); align-items: start; }
|
||||
.shot .no { font: 600 13px var(--mono); color: var(--accent-lo); }
|
||||
.shot .p { color: var(--text-2); font-size: 12.5px; }
|
||||
.shot .en { margin-top: 6px; font: 11.5px/1.5 var(--mono); color: var(--text-3); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.placeholder { place-items: center; text-align: center; color: var(--text-3); padding: 40px; }
|
||||
.placeholder h2 { color: var(--text); font-size: 16px; }
|
||||
|
||||
/* 原型标记 */
|
||||
.proto-flag { font-size: 11px; color: var(--text-3); border: 1px dashed var(--line-2); padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
|
||||
.switch { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-2); cursor: pointer; white-space: nowrap; }
|
||||
.switch input { accent-color: var(--accent-lo); }
|
||||
|
||||
/* 底部标签栏(窄屏) */
|
||||
.tabbar { display: none; }
|
||||
.mobile-only { display: none; }
|
||||
|
||||
/* ---------- 中屏:检视区改为右侧浮层 ---------- */
|
||||
@media (max-width: 1180px) {
|
||||
.desk, .desk.insp-off { grid-template-columns: minmax(0, 1fr); }
|
||||
.desk.insp-off .inspector { opacity: 1; pointer-events: auto; }
|
||||
.desk.insp-off .insp-open, .insp-toggle { display: none; }
|
||||
.inspector {
|
||||
position: fixed; top: 52px; right: 0; bottom: 0; width: 400px; z-index: 40; box-shadow: var(--shadow-float);
|
||||
transform: translateX(100%); transition: transform var(--d-drawer) var(--ease-drawer);
|
||||
}
|
||||
.inspector.on { transform: none; }
|
||||
:root[data-reduce="1"] .inspector { transform: none; opacity: 0; pointer-events: none; transition: opacity var(--d-drawer) ease; }
|
||||
:root[data-reduce="1"] .inspector.on { opacity: 1; pointer-events: auto; }
|
||||
.insp-close { display: inline-flex !important; }
|
||||
.search-btn { min-width: 0; }
|
||||
.search-btn .lbl { display: none; }
|
||||
}
|
||||
|
||||
/* ---------- 窄屏:底部标签栏 + 输入台底部抽屉 ---------- */
|
||||
@media (max-width: 760px) {
|
||||
.app { grid-template-columns: 1fr; }
|
||||
.rail { display: none; }
|
||||
.main { grid-template-rows: 48px minmax(0, 1fr) 56px; }
|
||||
.topbar { padding: 0 12px; gap: 8px; }
|
||||
.topbar .hide-sm { display: none; }
|
||||
.mobile-only { display: inline-flex; }
|
||||
.tabbar {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); background: var(--bg-2);
|
||||
}
|
||||
.tabbar button { display: grid; place-items: center; gap: 2px; font-size: 11px; color: var(--text-3); padding-top: 6px; }
|
||||
.tabbar button svg { width: 20px; height: 20px; }
|
||||
.tabbar button[aria-current="page"] { color: var(--accent); }
|
||||
.filters { padding: 8px 12px; overflow-x: auto; }
|
||||
.filters .count { display: none; }
|
||||
.feed { padding: 12px 12px 4px; }
|
||||
.take { grid-template-columns: minmax(0, 1fr) auto; padding: 10px; }
|
||||
.take-head { flex-wrap: wrap; row-gap: 2px; }
|
||||
.take-out { grid-row: 1 / span 2; }
|
||||
.take-state { grid-column: 1 / -1; flex-wrap: wrap; }
|
||||
.take-state .txt, .take-state .err-txt { white-space: nowrap; }
|
||||
.take-actions { margin-left: auto; }
|
||||
.toasts { left: 12px; right: 12px; bottom: 124px; max-width: none; }
|
||||
.thumb { width: 108px; }
|
||||
.thumb.portrait { width: 72px; }
|
||||
.bar { flex-basis: 80px; }
|
||||
.composer-wrap { padding: 6px 10px 10px; }
|
||||
.composer:not(.expanded) .slots, .composer:not(.expanded) .opt-chip { display: none; }
|
||||
.composer:not(.expanded) .prompt { min-height: 40px; max-height: 40px; padding: 10px 12px 2px; white-space: nowrap; overflow: hidden; }
|
||||
.composer.expanded { position: fixed; left: 0; right: 0; bottom: 56px; z-index: 45; border-radius: 16px 16px 0 0; border-bottom: 0; box-shadow: var(--shadow-float); }
|
||||
.chip { max-width: 170px; }
|
||||
.inspector { top: 0; width: 100vw; bottom: 0; z-index: 70; }
|
||||
.pop { left: 0 !important; right: 0; top: auto !important; bottom: 0 !important; width: 100vw; border-radius: 16px 16px 0 0; max-height: 78vh; --origin: bottom center; }
|
||||
.drawer { width: 100vw; }
|
||||
.page { padding: 16px 12px 24px; }
|
||||
.hero { grid-template-columns: 1fr; }
|
||||
.track { grid-template-columns: repeat(5, minmax(64px, 1fr)); overflow-x: auto; }
|
||||
.shot { grid-template-columns: 30px 1fr; }
|
||||
.shot .btn { grid-column: 2; justify-self: start; }
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg-4); border-radius: 6px; border: 2px solid var(--bg); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--line-2); }
|
||||
:root[data-reduce="1"] .composer-wrap::before { opacity: 0.3; }
|
||||
|
||||
/* 系统「减少动态效果」由 app.js 同步为 :root[data-reduce="1"],所有降级规则只写一处 */
|
||||
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,170 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>影工坊 · 融合原型:流水台交互 × 放映厅配色(HEL-648,隔离原型)</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
<svg width="0" height="0" style="position:absolute">
|
||||
<defs>
|
||||
<symbol id="i-home" viewBox="0 0 24 24"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-flow" viewBox="0 0 24 24"><path d="M4 6h5v5H4zM15 13h5v5h-5zM9 8.5h3.5a2 2 0 0 1 2 2V13" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-spark" viewBox="0 0 24 24"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8zM18.5 16l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-list" viewBox="0 0 24 24"><path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-agent" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M4 20c1.5-4 4.5-6 8-6s6.5 2 8 6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M12 2.5v3M12 18.5v3M21.5 12h-3M5.5 12h-3M18.7 5.3l-2.1 2.1M7.4 16.6l-2.1 2.1M18.7 18.7l-2.1-2.1M7.4 7.4L5.3 5.3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24"><path d="M7 4l13 8-13 8z"/></symbol>
|
||||
<symbol id="i-down" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-right" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-sliders" viewBox="0 0 24 24"><path d="M4 7h10M18 7h2M4 17h4M12 17h8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="16" cy="7" r="2" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="10" cy="17" r="2" fill="none" stroke="currentColor" stroke-width="1.8"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 16l4 4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 15V4M7 9l5-5 5 5M5 19h14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-dice" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="9" cy="9" r="1.2"/><circle cx="15" cy="15" r="1.2"/><circle cx="15" cy="9" r="1.2"/><circle cx="9" cy="15" r="1.2"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="i-panel" viewBox="0 0 24 24"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M14.5 4.5v15" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
|
||||
<symbol id="i-back" viewBox="0 0 24 24"><path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div class="app">
|
||||
<nav class="rail" aria-label="主导航">
|
||||
<div class="logo" aria-hidden="true">影</div>
|
||||
<span class="rail-ind" aria-hidden="true"></span>
|
||||
<button class="rail-btn" data-nav="projects"><svg><use href="#i-home"/></svg><span class="tip">项目</span><span class="sr">项目</span></button>
|
||||
<button class="rail-btn" data-nav="flow"><svg><use href="#i-flow"/></svg><span class="tip">创作流程</span><span class="sr">创作流程</span></button>
|
||||
<button class="rail-btn" data-nav="desk" aria-current="page"><svg><use href="#i-spark"/></svg><span class="tip">生成台(生图 + 生视频)</span><span class="sr">生成台</span></button>
|
||||
<button class="rail-btn" data-nav="tasks"><svg><use href="#i-list"/></svg><span class="tip">全部任务</span><span class="sr">全部任务</span></button>
|
||||
<button class="rail-btn" data-nav="agents"><svg><use href="#i-agent"/></svg><span class="tip">智能体</span><span class="sr">智能体</span></button>
|
||||
<div class="spacer"></div>
|
||||
<button class="rail-btn" data-nav="connections"><svg><use href="#i-link"/></svg><span class="tip">ComfyUI 连接与工作流库</span><span class="sr">连接</span></button>
|
||||
<button class="rail-btn" data-nav="settings"><svg><use href="#i-gear"/></svg><span class="tip">模型与设置</span><span class="sr">设置</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="crumb">
|
||||
<h1 id="page-title">生成台</h1>
|
||||
<button class="capsule" id="scope-btn" aria-haspopup="menu"><span id="scope-label">全部来源</span><svg width="12" height="12"><use href="#i-down"/></svg></button>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<span class="proto-flag hide-sm">隔离原型 · 不连后端</span>
|
||||
<label class="switch hide-sm" title="跟随系统设置,也可手动切换"><input type="checkbox" id="reduce-toggle">减少动态效果</label>
|
||||
<button class="capsule search-btn" id="cmd-btn" title="Ctrl/⌘ + K"><svg width="14" height="14"><use href="#i-search"/></svg><span class="lbl">搜索工作流、项目、任务</span><span class="kbd">Ctrl K</span></button>
|
||||
<button class="capsule" id="tray-btn" aria-haspopup="dialog"><span class="dot" id="tray-dot"></span><span id="tray-label">队列空闲</span></button>
|
||||
<button class="capsule hide-sm" id="conn-btn" title="生成请求由工作台服务器发往该 ComfyUI"><span class="dot"></span>本机 ComfyUI-4080 · 22 ms</button>
|
||||
</header>
|
||||
|
||||
<!-- 生成台 -->
|
||||
<section class="view desk on" id="view-desk" aria-label="生成台">
|
||||
<div class="desk-center">
|
||||
<div class="context-bar" id="context-bar">
|
||||
<button class="btn sm ghost" id="ctx-back"><svg><use href="#i-back"/></svg>返回镜头脚本</button>
|
||||
<span>来自 <b>《雨夜便利店》· 镜头 S1</b>,生成结果会回填到该镜头</span>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<div class="seg" role="group" aria-label="按类型筛选">
|
||||
<button data-filter="all" aria-pressed="true">全部</button>
|
||||
<button data-filter="image" aria-pressed="false">图片</button>
|
||||
<button data-filter="video" aria-pressed="false">视频</button>
|
||||
<button data-filter="failed" aria-pressed="false">失败</button>
|
||||
</div>
|
||||
<span class="count" id="feed-count"></span>
|
||||
<button class="btn sm insp-open" id="insp-open" title="展开检视区(])"><svg><use href="#i-panel"/></svg>展开检视区</button>
|
||||
</div>
|
||||
<div class="feed" id="feed" role="listbox" aria-label="生成记录(↑↓ 切换)" tabindex="0"></div>
|
||||
<div class="composer-wrap">
|
||||
<div class="composer" id="composer">
|
||||
<div class="slots" id="slots"></div>
|
||||
<textarea class="prompt" id="prompt" rows="2" placeholder="描述想要的画面…(Ctrl/⌘ + Enter 生成,/ 快速聚焦)"></textarea>
|
||||
<div class="composer-foot">
|
||||
<button class="chip wf" id="wf-chip" aria-haspopup="listbox"><svg><use href="#i-flow"/></svg><span class="lbl" id="wf-chip-label"></span><svg><use href="#i-down"/></svg></button>
|
||||
<span id="common-chips" style="display:contents"></span>
|
||||
<button class="chip opt-chip" id="params-chip"><svg><use href="#i-sliders"/></svg><span id="params-chip-label">全部参数</span></button>
|
||||
<div class="submit-group">
|
||||
<button class="why" id="why" hidden></button>
|
||||
<button class="btn primary gen-btn" id="gen-btn"><span class="l-idle">生成</span><span class="l-sent" aria-hidden="true"><svg><use href="#i-check"/></svg>已提交</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="inspector" id="inspector" aria-label="检视区">
|
||||
<div class="insp-tabs">
|
||||
<div class="seg" role="tablist">
|
||||
<button data-tab="preview" aria-pressed="true">结果</button>
|
||||
<button data-tab="params" aria-pressed="false">参数</button>
|
||||
</div>
|
||||
<div class="grow" style="flex:1"></div>
|
||||
<span class="muted" style="font-size:11px" id="insp-hint">↑ ↓ 切换 · 空格播放</span>
|
||||
<button class="btn sm ghost insp-toggle" id="insp-toggle" aria-expanded="true" aria-controls="inspector" title="收起检视区(])"><svg><use href="#i-panel"/></svg><span class="sr">收起检视区</span></button>
|
||||
<button class="btn sm ghost insp-close" id="insp-close" style="display:none" aria-label="关闭"><svg><use href="#i-x"/></svg></button>
|
||||
</div>
|
||||
<div class="insp-body" id="insp-preview"></div>
|
||||
<div class="insp-body" id="insp-params" hidden></div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<!-- 项目 -->
|
||||
<section class="view page" id="view-projects" aria-label="项目"></section>
|
||||
<!-- 创作流程 -->
|
||||
<section class="view page" id="view-flow" aria-label="创作流程"></section>
|
||||
<!-- 其他页占位 -->
|
||||
<section class="view page placeholder" id="view-other"></section>
|
||||
|
||||
<nav class="tabbar" aria-label="主导航(窄屏)">
|
||||
<button data-nav="projects"><svg><use href="#i-home"/></svg>项目</button>
|
||||
<button data-nav="flow"><svg><use href="#i-flow"/></svg>创作</button>
|
||||
<button data-nav="desk" aria-current="page"><svg><use href="#i-spark"/></svg>生成</button>
|
||||
<button data-nav="more"><svg><use href="#i-list"/></svg>更多</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工作流面板 -->
|
||||
<div class="pop" id="wf-pop" role="dialog" aria-label="选择工作流">
|
||||
<div class="pop-search"><svg width="16" height="16"><use href="#i-search"/></svg><input id="wf-search" placeholder="搜索工作流,或输入用途:图生视频、参考图…" autocomplete="off"></div>
|
||||
<div class="pop-list" id="wf-list" role="listbox"></div>
|
||||
<div class="pop-foot">
|
||||
<button class="btn sm" id="import-open"><svg><use href="#i-upload"/></svg>导入 ComfyUI 工作流 JSON…</button>
|
||||
<span class="hint"><span class="kbd">↑↓</span>选择 <span class="kbd">Enter</span>使用 <span class="kbd">Esc</span>关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通用小菜单 -->
|
||||
<div class="pop menu" id="menu-pop" role="menu"><div class="pop-list" id="menu-list"></div></div>
|
||||
<!-- 删除确认 -->
|
||||
<div class="pop confirm" id="confirm-pop" role="alertdialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-desc">
|
||||
<h3 id="confirm-title">删除这个工作流?</h3>
|
||||
<p id="confirm-desc"></p>
|
||||
<div class="row-actions">
|
||||
<button class="btn sm" id="confirm-cancel">取消</button>
|
||||
<button class="btn sm danger-solid" id="confirm-ok">确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 任务托盘 -->
|
||||
<div class="pop tray" id="tray-pop" role="dialog" aria-label="任务托盘"><div class="pop-list" id="tray-list"></div></div>
|
||||
|
||||
<!-- 导入抽屉 -->
|
||||
<div class="scrim" id="scrim"></div>
|
||||
<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title">
|
||||
<div class="drawer-head">
|
||||
<h2 id="drawer-title">导入 ComfyUI 工作流</h2>
|
||||
<button class="btn sm ghost" id="drawer-close" aria-label="关闭"><svg><use href="#i-x"/></svg></button>
|
||||
</div>
|
||||
<div class="drawer-body" id="drawer-body"></div>
|
||||
<div class="drawer-foot" id="drawer-foot"></div>
|
||||
</aside>
|
||||
|
||||
<input type="file" id="file-input" accept="image/png,image/jpeg,image/webp" multiple hidden>
|
||||
<input type="file" id="json-input" accept=".json,application/json" hidden>
|
||||
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 389 KiB |