Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bd8c29faa | ||
|
|
4f3293fc02 | ||
|
|
4c87c6dea0 | ||
|
|
d882fd62d8 | ||
|
|
b4c7334f45 | ||
|
|
6064bd805d | ||
|
|
c3deaf90bb | ||
|
|
949cd2ecad | ||
|
|
0c620e61fe | ||
|
|
6f4d50f4f0 | ||
|
|
8b03291327 | ||
|
|
225fcb49ea | ||
|
|
70a860ce2a | ||
|
|
e42212a16f | ||
|
|
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
|
||||
@@ -7,7 +7,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs",
|
||||
"test": "node scripts/check-storage-ssr.mjs && node scripts/check-random-uuid.mjs && node scripts/check-skill-frontmatter.mjs && node scripts/check-export-frame.mjs",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pure-logic checks for 导出尾帧 (lib/media/exportFrame.ts): which media
|
||||
* times get tried near the end, how the PNG is named, and when a sampled
|
||||
* frame counts as black / empty. The decode + download path itself runs in
|
||||
* a real browser (e2e/lastframe.mjs).
|
||||
*
|
||||
* Run with `npm test` (or `node scripts/check-export-frame.mjs`).
|
||||
*/
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const MODULE = join(HERE, "..", "src", "lib", "media", "exportFrame.ts");
|
||||
|
||||
const {
|
||||
tailSeekTimes,
|
||||
videoBaseName,
|
||||
frameFileName,
|
||||
blankKind,
|
||||
statusErrorCode,
|
||||
TAIL_OFFSETS,
|
||||
} = await import(pathToFileURL(MODULE).href);
|
||||
|
||||
let failures = 0;
|
||||
function check(name, cond, extra = "") {
|
||||
if (cond) console.log(`ok ${name}`);
|
||||
else {
|
||||
failures += 1;
|
||||
console.error(`FAIL ${name}${extra ? ` — ${extra}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- seek targets -------------------------------------------------------
|
||||
|
||||
const t6 = tailSeekTimes(6.04);
|
||||
check(
|
||||
"first try is ~1 frame before the end, never the end itself",
|
||||
t6[0] < 6.04 && 6.04 - t6[0] <= 0.05 + 1e-9,
|
||||
JSON.stringify(t6),
|
||||
);
|
||||
check(
|
||||
"later tries step strictly further back",
|
||||
t6.every((t, i) => i === 0 || t < t6[i - 1]),
|
||||
JSON.stringify(t6),
|
||||
);
|
||||
check(
|
||||
"tries stay within ~1s of the end",
|
||||
t6.length === TAIL_OFFSETS.length && 6.04 - t6[t6.length - 1] <= 1 + 1e-9,
|
||||
);
|
||||
check(
|
||||
"short clip clamps to 0 without duplicates",
|
||||
JSON.stringify(tailSeekTimes(0.1)) === "[0.05,0]",
|
||||
);
|
||||
check(
|
||||
"unknown / zero duration falls back to t=0",
|
||||
JSON.stringify(tailSeekTimes(NaN)) === "[0]" &&
|
||||
JSON.stringify(tailSeekTimes(0)) === "[0]",
|
||||
);
|
||||
|
||||
// ---- file names ---------------------------------------------------------
|
||||
|
||||
const api =
|
||||
"/api/file?path=" +
|
||||
encodeURIComponent(
|
||||
"/opt/aigc-home/data/assets/1/video/05_ref2va_4step_sla_00035_.mp4",
|
||||
);
|
||||
check(
|
||||
"base name from /api/file?path=…",
|
||||
videoBaseName(api) === "05_ref2va_4step_sla_00035_",
|
||||
);
|
||||
check(
|
||||
"base name from a Windows path",
|
||||
videoBaseName(
|
||||
"/api/file?path=" +
|
||||
encodeURIComponent("D:\\calliope\\assets\\镜头 01.webm"),
|
||||
) === "镜头 01",
|
||||
);
|
||||
check(
|
||||
"base name from a plain URL",
|
||||
videoBaseName("http://x.test/media/clip-9.mov?x=1") === "clip-9",
|
||||
);
|
||||
check(
|
||||
"unsafe characters are replaced",
|
||||
!/[:*?"<>|]/.test(
|
||||
videoBaseName("/api/file?path=" + encodeURIComponent('a:b*c?"d<e>f|g.mp4')),
|
||||
),
|
||||
);
|
||||
check(
|
||||
"blob URL without a name falls back to 'video'",
|
||||
videoBaseName("blob:http://x.test/") === "video",
|
||||
);
|
||||
const fname = frameFileName(api, 5.958, "尾帧");
|
||||
check(
|
||||
"PNG name names the video, the tag and the frame time",
|
||||
fname === "05_ref2va_4step_sla_00035__尾帧_5.958s.png",
|
||||
fname,
|
||||
);
|
||||
check(
|
||||
"spaces in the tag become dashes",
|
||||
frameFileName(api, 1, "last frame").includes("_last-frame_1.000s.png"),
|
||||
);
|
||||
|
||||
// ---- black / empty detection -------------------------------------------
|
||||
|
||||
function frame(n, rgba) {
|
||||
const out = new Uint8ClampedArray(n * 4);
|
||||
for (let i = 0; i < n; i++) out.set(rgba(i), i * 4);
|
||||
return out;
|
||||
}
|
||||
const N = 64 * 64;
|
||||
check(
|
||||
"fully transparent sample → empty",
|
||||
blankKind(frame(N, () => [0, 0, 0, 0])) === "empty",
|
||||
);
|
||||
check(
|
||||
"pure black → black",
|
||||
blankKind(frame(N, () => [0, 0, 0, 255])) === "black",
|
||||
);
|
||||
check(
|
||||
"near-black encoder noise (16..18) → black",
|
||||
blankKind(frame(N, (i) => [16 + (i % 3), 16, 16, 255])) === "black",
|
||||
);
|
||||
check(
|
||||
"mid-grey picture → not blank",
|
||||
blankKind(frame(N, () => [128, 128, 128, 255])) === null,
|
||||
);
|
||||
check(
|
||||
"dim but visible picture (≈50) → not blank",
|
||||
blankKind(frame(N, () => [50, 48, 52, 255])) === null,
|
||||
);
|
||||
check(
|
||||
"dark night scene with a bright highlight → not blank",
|
||||
blankKind(
|
||||
frame(N, (i) => (i === 100 ? [250, 240, 200, 255] : [4, 4, 6, 255])),
|
||||
) === null,
|
||||
);
|
||||
|
||||
// ---- HTTP status classification ----------------------------------------
|
||||
|
||||
check("404 → not-found", statusErrorCode(404) === "not-found");
|
||||
check(
|
||||
"403 → forbidden",
|
||||
statusErrorCode(403) === "forbidden" && statusErrorCode(401) === "forbidden",
|
||||
);
|
||||
check("500 → network", statusErrorCode(500) === "network");
|
||||
check(
|
||||
"206 / 200 are not errors",
|
||||
statusErrorCode(206) === null && statusErrorCode(200) === null,
|
||||
);
|
||||
|
||||
if (failures) {
|
||||
console.error(`\n${failures} export-frame check(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("export-frame checks passed.");
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { jobsApi, type Job } from '$lib/api';
|
||||
import type { CalliopeEvent, EventConnectionState } from '$lib/events';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import JobRow from './JobRow.svelte';
|
||||
import Button from './ui/Button.svelte';
|
||||
import Icon from './ui/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
logs: CalliopeEvent[];
|
||||
projectId: number;
|
||||
/** Real SSE connection state from events.ts — drives the live pip. */
|
||||
connState: EventConnectionState;
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { logs, projectId, connState, collapsed = false, onToggle }: Props = $props();
|
||||
|
||||
type Tone = 'info' | 'ok' | 'warn' | 'err' | 'work';
|
||||
|
||||
type LogEntry = {
|
||||
key: string;
|
||||
ts: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
tone: Tone;
|
||||
type: string;
|
||||
};
|
||||
|
||||
function str(v: unknown): string {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatEvent(ev: CalliopeEvent, index: number): LogEntry | null {
|
||||
// Poll spam — progress lives in the jobProgress store, not the log
|
||||
if (ev.type === 'job.progress') return null;
|
||||
|
||||
const d = ev.data ?? {};
|
||||
const msg = str(d.message).trim();
|
||||
const kind = str(d.kind);
|
||||
const jobId = d.job_id != null ? `#${d.job_id}` : '';
|
||||
const err = str(d.error).trim();
|
||||
const paths = Array.isArray(d.paths) ? d.paths.length : 0;
|
||||
const outputs = Array.isArray(d.outputs) ? d.outputs.length : 0;
|
||||
const time = ev.ts?.includes('T') ? ev.ts.slice(11, 19) : ev.ts?.slice(0, 8) || '';
|
||||
|
||||
let title = ev.type;
|
||||
let detail = msg;
|
||||
let tone: Tone = 'info';
|
||||
|
||||
switch (ev.type) {
|
||||
case 'agent.thinking':
|
||||
title = t('activity.agentWorking');
|
||||
detail = msg || t('activity.working');
|
||||
tone = 'work';
|
||||
break;
|
||||
case 'story.ready':
|
||||
title = t('activity.storyReady');
|
||||
detail = msg || t('activity.storylineDrafted');
|
||||
tone = 'ok';
|
||||
break;
|
||||
case 'job.created':
|
||||
title = t('activity.queued');
|
||||
detail = msg || [kind, jobId].filter(Boolean).join(' ');
|
||||
tone = 'info';
|
||||
break;
|
||||
case 'job.started':
|
||||
title = t('activity.running');
|
||||
detail = msg || [kind, jobId].filter(Boolean).join(' ');
|
||||
tone = 'work';
|
||||
break;
|
||||
case 'job.completed':
|
||||
title = t('activity.finished');
|
||||
detail =
|
||||
msg ||
|
||||
[kind, jobId, outputs ? t('activity.files', { count: outputs }) : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
tone = 'ok';
|
||||
break;
|
||||
case 'job.failed':
|
||||
title = t('activity.failed');
|
||||
detail = err || msg || [kind, jobId].filter(Boolean).join(' ');
|
||||
tone = 'err';
|
||||
break;
|
||||
case 'asset.ready':
|
||||
title = t('activity.assetReady');
|
||||
detail = msg || (paths ? t('activity.filesSaved', { count: paths }) : t('activity.outputSaved'));
|
||||
tone = 'ok';
|
||||
break;
|
||||
case 'job.deleted':
|
||||
title = t('activity.removed');
|
||||
detail = msg || jobId || t('activity.jobDeleted');
|
||||
tone = 'warn';
|
||||
break;
|
||||
default:
|
||||
title = ev.type.replace(/\./g, ' · ');
|
||||
detail = msg || err || kind;
|
||||
tone = 'info';
|
||||
}
|
||||
|
||||
return {
|
||||
key: `${ev.ts}-${ev.type}-${index}`,
|
||||
ts: time,
|
||||
title,
|
||||
detail,
|
||||
tone,
|
||||
type: ev.type,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = $derived.by(() => {
|
||||
const raw = logs
|
||||
.slice(-120)
|
||||
.map((ev, i) => formatEvent(ev, i))
|
||||
.filter((e): e is LogEntry => e != null);
|
||||
|
||||
// Collapse consecutive identical agent lines
|
||||
const collapsed: LogEntry[] = [];
|
||||
for (const e of raw) {
|
||||
const prev = collapsed[collapsed.length - 1];
|
||||
if (prev && prev.type === e.type && prev.detail === e.detail) {
|
||||
continue;
|
||||
}
|
||||
collapsed.push(e);
|
||||
}
|
||||
return collapsed.slice(-60).reverse();
|
||||
});
|
||||
|
||||
// Same query key as QueueStage — refreshed by SSE invalidation from the
|
||||
// project page (job.* events), not by polling.
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs', projectId],
|
||||
queryFn: () => jobsApi.list(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
const allJobs = $derived(($jobsQuery.data ?? []) as Job[]);
|
||||
|
||||
function activeWeight(job: Job): number {
|
||||
if (job.status === 'running') return 0;
|
||||
if (job.status === 'pending') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Active jobs first, then most recent — capped so the log keeps room.
|
||||
const queueRows = $derived(
|
||||
[...allJobs].sort((a, b) => activeWeight(a) - activeWeight(b) || b.id - a.id).slice(0, 6),
|
||||
);
|
||||
|
||||
const stats = $derived({
|
||||
running: allJobs.filter((j) => j.status === 'running').length,
|
||||
queued: allJobs.filter((j) => j.status === 'pending').length,
|
||||
done: allJobs.filter((j) => j.status === 'done').length,
|
||||
failed: allJobs.filter((j) => j.status === 'failed').length,
|
||||
});
|
||||
const busyCount = $derived(stats.running + stats.queued);
|
||||
|
||||
const connected = $derived(connState === 'open');
|
||||
</script>
|
||||
|
||||
<aside class="activity" class:collapsed aria-label={t('activity.title')}>
|
||||
{#if collapsed}
|
||||
<button
|
||||
type="button"
|
||||
class="rail-toggle"
|
||||
onclick={onToggle}
|
||||
aria-expanded="false"
|
||||
title={t('activity.show')}
|
||||
>
|
||||
<span class="pip rail-pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
|
||||
{#if busyCount > 0}
|
||||
<span class="rail-count" title={t('activity.busyCount', { count: busyCount })}>{busyCount}</span>
|
||||
{/if}
|
||||
<span class="rail-label">{t('activity.title')}</span>
|
||||
<span class="rail-chevron" aria-hidden="true">‹</span>
|
||||
</button>
|
||||
{:else}
|
||||
<header class="head">
|
||||
<div class="head-title">
|
||||
<span class="pip" class:live={connected} class:reconnecting={connState === 'reconnecting'} aria-hidden="true"></span>
|
||||
<strong>{t('activity.title')}</strong>
|
||||
{#if connState === 'reconnecting'}
|
||||
<span class="conn-state warn">{t('activity.reconnecting')}</span>
|
||||
{:else if connState === 'connecting'}
|
||||
<span class="conn-state">{t('activity.connecting')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onclick={onToggle} title={t('activity.hide')}>
|
||||
{t('activity.hide')} <Icon name="chevron-right" size={13} />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<section class="queue" aria-label={t('activity.queue')}>
|
||||
<div class="section-head">
|
||||
<p class="eyebrow">{t('activity.queue')}</p>
|
||||
</div>
|
||||
{#if queueRows.length === 0}
|
||||
<p class="queue-empty">{t('activity.queueIdle')}</p>
|
||||
{:else}
|
||||
<div class="job-list">
|
||||
{#each queueRows as job (job.id)}
|
||||
<JobRow {job} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="stats-strip" aria-label={t('activity.queueStats')}>
|
||||
<span><span class="sd sd-running" aria-hidden="true"></span>{stats.running} {t('activity.running').toLowerCase()}</span>
|
||||
<span><span class="sd sd-queued" aria-hidden="true"></span>{stats.queued} {t('activity.queued').toLowerCase()}</span>
|
||||
<span><span class="sd sd-done" aria-hidden="true"></span>{stats.done} {t('activity.done')}</span>
|
||||
<span><span class="sd sd-failed" aria-hidden="true"></span>{stats.failed} {t('activity.failed').toLowerCase()}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="log-section" aria-label={t('activity.agentLog')}>
|
||||
<p class="eyebrow">{t('activity.agentLog')}</p>
|
||||
{#if entries.length === 0}
|
||||
<div class="empty">
|
||||
<p>{t('activity.quiet')}</p>
|
||||
<p class="hint">{t('activity.quietHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="log">
|
||||
{#each entries as ev (ev.key)}
|
||||
<li class="entry tone-{ev.tone}">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<div class="meta">
|
||||
<div class="row">
|
||||
<span class="title">{ev.title}</span>
|
||||
<time class="ts" datetime={ev.ts}>{ev.ts}</time>
|
||||
</div>
|
||||
{#if ev.detail}
|
||||
<p class="detail">{ev.detail}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.activity {
|
||||
--panel-w: 320px;
|
||||
--rail-w: 44px;
|
||||
width: var(--panel-w);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--border);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(139, 92, 246, 0.06) 0%, transparent 120px),
|
||||
var(--bg-surface);
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.activity.collapsed {
|
||||
width: var(--rail-w);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.rail-toggle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 16px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rail-toggle:hover {
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rail-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.rail-label {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rail-chevron {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rail-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pip {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pip.live {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
animation: live-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.pip.reconnecting {
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.18);
|
||||
}
|
||||
|
||||
@keyframes live-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.65;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.activity {
|
||||
transition: none;
|
||||
}
|
||||
.pip.live {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.head-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.head-title strong {
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.conn-state {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conn-state.warn {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 10px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.queue {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.job-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 216px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.queue-empty {
|
||||
margin: 0 6px 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stats-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
margin: 10px 6px 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stats-strip > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sd {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sd-running {
|
||||
background: var(--info);
|
||||
}
|
||||
.sd-queued {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.sd-done {
|
||||
background: var(--success);
|
||||
}
|
||||
.sd-failed {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.log-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 8px 6px;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty .hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.log {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 4px 8px 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 8px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.entry:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.tone-ok .dot {
|
||||
background: var(--success);
|
||||
}
|
||||
.tone-err .dot {
|
||||
background: var(--error);
|
||||
}
|
||||
.tone-warn .dot {
|
||||
background: var(--warning);
|
||||
}
|
||||
.tone-work .dot {
|
||||
background: var(--accent);
|
||||
}
|
||||
.tone-info .dot {
|
||||
background: var(--info);
|
||||
}
|
||||
|
||||
.meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ts {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 3px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tone-err .detail {
|
||||
color: #fca5a5;
|
||||
}
|
||||
</style>
|
||||
@@ -15,8 +15,8 @@ interface Props {
|
||||
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<a class="logo" href="/canvas" title="Calliope Lab">
|
||||
Calliope <span>Lab</span>
|
||||
<a class="logo" href="/" title={t('shell.brand')}>
|
||||
{t('shell.brand')}
|
||||
</a>
|
||||
{#if crumb}
|
||||
<span class="crumb">{crumb}</span>
|
||||
@@ -28,7 +28,7 @@ interface Props {
|
||||
{/if}
|
||||
<nav class="top-nav" aria-label={t('nav.primary')}>
|
||||
<a class="nav-link" class:active={active === 'canvas'} href="/canvas">{t('nav.canvas')}</a>
|
||||
<a class="nav-link" class:active={active === 'projects'} href="/projects">{t('nav.projects')}</a>
|
||||
<a class="nav-link" class:active={active === 'projects'} href="/">{t('nav.projects')}</a>
|
||||
<a class="nav-link" class:active={active === 'build-scene'} href="/build-scene">{t('nav.buildScene')}</a>
|
||||
<a class="nav-link" class:active={active === 'library'} href="/library">{t('nav.library')}</a>
|
||||
<a class="nav-link" class:active={active === 'agents'} href="/agents">{t('nav.agents')}</a>
|
||||
@@ -69,18 +69,14 @@ interface Props {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logo {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-family: var(--font);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logo span {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.crumb {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
@@ -122,7 +118,7 @@ interface Props {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 760px) {
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
|
||||
interface Props {
|
||||
src: string | null;
|
||||
alt: string;
|
||||
placeholder: string;
|
||||
wide?: boolean;
|
||||
/** Taller 4:3 media-first crop (asset card grids). */
|
||||
tall?: boolean;
|
||||
/** Latest generation-job state for this asset, if the parent tracks jobs. */
|
||||
jobState?: 'generating' | 'failed' | null;
|
||||
onPreview?: (src: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
src,
|
||||
alt,
|
||||
placeholder,
|
||||
wide = false,
|
||||
tall = false,
|
||||
jobState = null,
|
||||
onPreview,
|
||||
}: Props = $props();
|
||||
|
||||
let available = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
void src;
|
||||
available = false;
|
||||
});
|
||||
|
||||
// Job state (live from the queue) wins over the media-derived state.
|
||||
const chip = $derived.by((): { status: string; label: string } => {
|
||||
if (jobState === 'generating') return { status: 'generating', label: t('common.generating') };
|
||||
if (jobState === 'failed') return { status: 'failed', label: t('common.failed') };
|
||||
if (available) return { status: 'ready', label: t('common.ready') };
|
||||
if (src) return { status: 'missing', label: t('common.missing') };
|
||||
return { status: 'pending', label: t('common.pending') };
|
||||
});
|
||||
|
||||
function open() {
|
||||
if (available && src && onPreview) onPreview(src);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="thumb"
|
||||
class:wide
|
||||
class:tall
|
||||
class:clickable={available && !!onPreview}
|
||||
role={available && onPreview ? 'button' : undefined}
|
||||
tabindex={available && onPreview ? 0 : undefined}
|
||||
onclick={open}
|
||||
onkeydown={available && onPreview ? onKey : undefined}
|
||||
>
|
||||
<SafeMedia
|
||||
class="fill"
|
||||
{src}
|
||||
{alt}
|
||||
label={placeholder}
|
||||
onAvailable={() => (available = true)}
|
||||
onUnavailable={() => (available = false)}
|
||||
/>
|
||||
<span class="thumb-chip">
|
||||
<StatusChip status={chip.status} label={chip.label} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.thumb {
|
||||
aspect-ratio: 1;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.thumb.wide {
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
.thumb.tall {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
.thumb.clickable {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.thumb.clickable:hover {
|
||||
border-color: var(--accent, #a78bfa);
|
||||
}
|
||||
.thumb.clickable:focus-visible {
|
||||
outline: 2px solid var(--accent, #a78bfa);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.thumb :global(.fill) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.thumb :global(.safe-media-ph.fill) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.thumb-chip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -136,9 +136,9 @@
|
||||
|
||||
const projectList = $derived(($projectsQuery.data ?? []) as Project[]);
|
||||
|
||||
// Stage-URL contract: images land on Assets, clips on Video.
|
||||
// Five-stage workbench URL contract: images land on 素材资产, clips on 镜头脚本.
|
||||
const stageLink = $derived(
|
||||
attached ? `/project/${attached.projectId}?stage=${isVideo ? 'video' : 'assets'}` : '',
|
||||
attached ? `/workbench/${attached.projectId}?stage=${isVideo ? 'shot' : 'assets'}` : '',
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -267,13 +267,13 @@
|
||||
.attached {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: var(--ok-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent);
|
||||
border-radius: var(--r-md);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.attached-check {
|
||||
display: flex;
|
||||
@@ -282,8 +282,8 @@
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
color: #052e12;
|
||||
background: var(--ok);
|
||||
color: var(--bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.attached-text {
|
||||
@@ -293,23 +293,26 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.attached-text strong {
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 普通链接走 ghost(§1.2):默认 --text-2,悬停 --text,不用金色文字链接。 */
|
||||
.attached-link {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.attached-link:hover {
|
||||
color: var(--text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.attached-link:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.attached-dismiss {
|
||||
display: flex;
|
||||
@@ -319,15 +322,15 @@
|
||||
height: 22px;
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.attached-dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.attached-dismiss:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -336,20 +339,20 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-5);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-1);
|
||||
}
|
||||
.err {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
margin: var(--sp-1) 0 0;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--err);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
allowUpload?: boolean;
|
||||
/** Force required-field error rings visible (e.g. after a blocked Generate). */
|
||||
showErrors?: boolean;
|
||||
/** Show each field's workflow value (原值) and mark fields that differ from it. */
|
||||
showOrig?: boolean;
|
||||
/** Extra per-field messages keyed by nodeId (e.g. range checks); shown with showErrors. */
|
||||
errors?: Record<string, string>;
|
||||
/** Emits labels of required-but-empty inputs whenever the set changes. */
|
||||
onValidityChange?: (missing: string[]) => void;
|
||||
onChange?: (values: Record<string, string | number>) => void;
|
||||
@@ -30,10 +34,23 @@
|
||||
assetOptions = [],
|
||||
allowUpload = false,
|
||||
showErrors = false,
|
||||
showOrig = false,
|
||||
errors = {},
|
||||
onValidityChange,
|
||||
onChange,
|
||||
}: Props = $props();
|
||||
|
||||
function hasOrig(inp: ComfyDynamicInput): boolean {
|
||||
return inp.defaultValue !== undefined && inp.defaultValue !== '';
|
||||
}
|
||||
|
||||
function isChanged(inp: ComfyDynamicInput): boolean {
|
||||
if (!showOrig) return false;
|
||||
const v = values[inp.nodeId];
|
||||
if (isBlank(v)) return hasOrig(inp);
|
||||
return !hasOrig(inp) || String(v) !== String(inp.defaultValue);
|
||||
}
|
||||
|
||||
// Unique per mounted form so label/id pairs never collide across instances.
|
||||
const uid = Math.random().toString(36).slice(2, 8);
|
||||
let touched = $state<Record<string, boolean>>({});
|
||||
@@ -80,6 +97,11 @@
|
||||
return isMissing(inp) && (showErrors || !!touched[inp.nodeId]);
|
||||
}
|
||||
|
||||
function extraError(inp: ComfyDynamicInput): string | null {
|
||||
const msg = errors[inp.nodeId]?.trim();
|
||||
return msg && !isMissing(inp) && (showErrors || !!touched[inp.nodeId]) ? msg : null;
|
||||
}
|
||||
|
||||
function thumbFor(current: string): string | null {
|
||||
if (!current) return null;
|
||||
const opt = assetOptions.find((o) => o.path === current);
|
||||
@@ -224,7 +246,9 @@
|
||||
{#each inputs as inp (inp.nodeId)}
|
||||
{@const current = String(values[inp.nodeId] ?? '')}
|
||||
{@const id = inputId(inp.nodeId)}
|
||||
{@const errored = showError(inp)}
|
||||
{@const extra = extraError(inp)}
|
||||
{@const errored = showError(inp) || !!extra}
|
||||
{@const changed = isChanged(inp)}
|
||||
<div class="field">
|
||||
<label class="label" for={id}>
|
||||
{inp.label}
|
||||
@@ -235,6 +259,9 @@
|
||||
{#if inp.role}<span class="role">{inp.role}</span>{/if}
|
||||
<span class="kind">{inp.kind}</span>
|
||||
{/if}
|
||||
{#if showOrig && hasOrig(inp)}
|
||||
<span class="orig">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{#if readonly}
|
||||
<div class="readonly">{values[inp.nodeId] ?? inp.defaultValue ?? '—'}</div>
|
||||
@@ -243,6 +270,7 @@
|
||||
{id}
|
||||
rows="3"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={current}
|
||||
@@ -254,6 +282,7 @@
|
||||
{id}
|
||||
type="number"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={values[inp.nodeId] ?? ''}
|
||||
@@ -264,6 +293,7 @@
|
||||
<select
|
||||
{id}
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
value={current || String(inp.defaultValue ?? '')}
|
||||
onchange={(e) => setValue(inp.nodeId, e.currentTarget.value)}
|
||||
onblur={() => markTouched(inp.nodeId)}
|
||||
@@ -445,6 +475,7 @@
|
||||
{id}
|
||||
type="text"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={current}
|
||||
@@ -453,7 +484,7 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if errored}
|
||||
<span class="error-msg" role="alert">{t('cdf.requiredMsg', { label: inp.label })}</span>
|
||||
<span class="error-msg" role="alert">{extra ?? t('cdf.requiredMsg', { label: inp.label })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -516,11 +547,23 @@
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
input.changed,
|
||||
textarea.changed,
|
||||
select.changed {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
input.invalid,
|
||||
textarea.invalid,
|
||||
select.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.orig {
|
||||
float: right;
|
||||
font-weight: 400;
|
||||
font-size: var(--fs-mini);
|
||||
font-family: var(--mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
input.invalid:focus-visible,
|
||||
textarea.invalid:focus-visible,
|
||||
select.invalid:focus-visible {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
|
||||
interface Props {
|
||||
src: string | null;
|
||||
@@ -61,6 +63,17 @@
|
||||
}
|
||||
return alt ? `${alt.trim().replace(/\s+/g, '-').toLowerCase()}.png` : 'media';
|
||||
});
|
||||
|
||||
let frameBusy = $state(false);
|
||||
async function exportTail() {
|
||||
if (!src || frameBusy) return;
|
||||
frameBusy = true;
|
||||
try {
|
||||
await exportLastFrame(src);
|
||||
} finally {
|
||||
frameBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={src ? onKeydown : undefined} />
|
||||
@@ -90,6 +103,26 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
|
||||
<Icon name="zoom-in" size={17} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if isVideo}
|
||||
<button
|
||||
class="tool"
|
||||
type="button"
|
||||
aria-label={frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
|
||||
title={frameBusy ? t('lastFrame.exporting') : `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
|
||||
disabled={frameBusy}
|
||||
aria-busy={frameBusy}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
exportTail();
|
||||
}}
|
||||
>
|
||||
{#if frameBusy}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<Icon name="image" size={17} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<a
|
||||
class="tool"
|
||||
href={src}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { jobsApi, type Job } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { toast } from '$lib/toast';
|
||||
import Button from './ui/Button.svelte';
|
||||
import Icon from './ui/Icon.svelte';
|
||||
import type { IconName } from './ui/icons';
|
||||
import ProgressBar from './ui/ProgressBar.svelte';
|
||||
import StatusChip from './ui/StatusChip.svelte';
|
||||
|
||||
interface Props {
|
||||
job: Job;
|
||||
/** Optional override for the leading label (defaults to "Kind #id"). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let { job, label }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
let busy = $state(false);
|
||||
|
||||
const kindIcon: IconName = $derived(
|
||||
job.kind === 'video'
|
||||
? 'video'
|
||||
: job.kind === 'image'
|
||||
? 'image'
|
||||
: job.kind === 'export'
|
||||
? 'film'
|
||||
: 'queue',
|
||||
);
|
||||
const kindLabel = $derived(
|
||||
job.kind === 'export'
|
||||
? t('job.exportFilm')
|
||||
: job.kind.charAt(0).toUpperCase() + job.kind.slice(1),
|
||||
);
|
||||
const title = $derived(label ?? t('job.title', { kind: kindLabel, id: String(job.id) }));
|
||||
const active = $derived(job.status === 'pending' || job.status === 'running');
|
||||
const entry = $derived(progressFor(job.id));
|
||||
|
||||
async function refresh() {
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
busy = true;
|
||||
try {
|
||||
await jobsApi.cancel(job.id);
|
||||
await refresh();
|
||||
toast.info(t('job.cancelled', { id: String(job.id) }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('job.cancelFailed'));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
busy = true;
|
||||
try {
|
||||
await jobsApi.retry(job.id);
|
||||
await refresh();
|
||||
toast.success(t('job.requeued', { id: String(job.id) }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('job.retryFailed'));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="job-row">
|
||||
<span class="kind" aria-hidden="true"><Icon name={kindIcon} size={14} /></span>
|
||||
<div class="main">
|
||||
<div class="top">
|
||||
<span class="label" {title}>{title}</span>
|
||||
<StatusChip status={job.status} />
|
||||
</div>
|
||||
{#if active}
|
||||
<div class="bar">
|
||||
<ProgressBar
|
||||
size="sm"
|
||||
value={entry?.progress ?? 0}
|
||||
indeterminate={job.status === 'running' && entry == null}
|
||||
label={entry?.message}
|
||||
/>
|
||||
</div>
|
||||
{:else if job.status === 'failed' && job.error}
|
||||
<p class="err" title={job.error}>{job.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if active}
|
||||
<Button size="sm" variant="ghost" loading={busy} title={t('job.cancelJob')} onclick={cancel}>
|
||||
<Icon name="close" size={13} />
|
||||
</Button>
|
||||
{:else if job.status === 'failed'}
|
||||
<Button size="sm" variant="ghost" loading={busy} title={t('job.retryJob')} onclick={retry}>
|
||||
<Icon name="retry" size={13} />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.job-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.kind {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 20px;
|
||||
}
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bar {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.err {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--error);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,137 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
|
||||
const items = $derived(
|
||||
[
|
||||
{ id: 'story', key: 'nav.story', icon: 'story' },
|
||||
{ id: 'assets', key: 'nav.assets', icon: 'assets' },
|
||||
{ id: 'script', key: 'nav.script', icon: 'script' },
|
||||
{ id: 'video', key: 'nav.video', icon: 'video' },
|
||||
] as { id: string; key: string; icon: IconName }[],
|
||||
);
|
||||
|
||||
interface Props {
|
||||
active?: string;
|
||||
onSelect?: (id: string) => void;
|
||||
/** Optional per-stage counts — a nonzero value shows a small pill on the icon. */
|
||||
badges?: Record<string, number>;
|
||||
}
|
||||
|
||||
let { active = 'story', onSelect, badges = {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
<nav class="nav" aria-label={t('nav.projectStages')}>
|
||||
<button
|
||||
class="nav-item home"
|
||||
title={t('nav.projects')}
|
||||
aria-label={t('nav.backProjects')}
|
||||
onclick={() => goto('/projects')}
|
||||
>
|
||||
<span class="icon"><Icon name="home" size={20} /></span>
|
||||
<span class="label">{t('nav.home')}</span>
|
||||
</button>
|
||||
<div class="divider" aria-hidden="true"></div>
|
||||
{#each items as item (item.id)}
|
||||
{@const count = badges[item.id] ?? 0}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={active === item.id}
|
||||
title={t(item.key)}
|
||||
aria-current={active === item.id ? 'page' : undefined}
|
||||
onclick={() => onSelect?.(item.id)}
|
||||
>
|
||||
<span class="icon">
|
||||
<Icon name={item.icon} size={20} />
|
||||
{#if count > 0}
|
||||
<span class="badge" aria-label={t('nav.countActive', { count })}>
|
||||
{count > 9 ? '9+' : count}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="label">{t(item.key)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.nav {
|
||||
width: 64px;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-md) 0;
|
||||
gap: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.divider {
|
||||
width: 32px;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 2px 0 4px;
|
||||
}
|
||||
.nav-item {
|
||||
width: 56px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
box-shadow: 0 0 16px var(--accent-glow);
|
||||
}
|
||||
.nav-item.home:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.nav-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
line-height: 1;
|
||||
}
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -12px;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 0 2px var(--bg-surface);
|
||||
}
|
||||
.nav-item.active .badge {
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,478 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { assetUrl, projects, type Project } from '$lib/api';
|
||||
import { toast } from '$lib/toast';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
const { project }: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
const stats = $derived(
|
||||
project.stats ?? {
|
||||
scene_count: 0,
|
||||
character_count: 0,
|
||||
asset_ready_count: 0,
|
||||
asset_total_count: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// Explicit project cover wins; otherwise fall back to the first asset image.
|
||||
// The project prop is stable for the lifetime of a card, so the eager
|
||||
// queryKey capture below is intentional.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const assetsQuery = createQuery({
|
||||
queryKey: ['assets', project.id],
|
||||
queryFn: () => projects.getAssets(project.id),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const thumbUrl = $derived.by(() => {
|
||||
if (project.cover_path) return assetUrl(project.cover_path);
|
||||
const data = $assetsQuery.data;
|
||||
if (!data) return null;
|
||||
const char = data.characters.find((c) => c.portrait_path || c.sheet_path);
|
||||
const path =
|
||||
char?.portrait_path ??
|
||||
char?.sheet_path ??
|
||||
data.locations.find((l) => l.reference_image_path)?.reference_image_path ??
|
||||
null;
|
||||
return assetUrl(path);
|
||||
});
|
||||
|
||||
// A broken cover (deleted file, 404) falls back to the gradient tile.
|
||||
let thumbFailedFor = $state<string | null>(null);
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
|
||||
if (mins < 1) return t('projectCard.timeJustNow');
|
||||
if (mins < 60) return t('projectCard.timeM', { count: mins });
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return t('projectCard.timeH', { count: hours });
|
||||
const days = Math.round(hours / 24);
|
||||
if (days < 30) return t('projectCard.timeD', { count: days });
|
||||
const months = Math.round(days / 30);
|
||||
if (months < 12) return t('projectCard.timeMo', { count: months });
|
||||
return t('projectCard.timeY', { count: Math.round(months / 12) });
|
||||
}
|
||||
|
||||
const updatedLabel = $derived(relativeTime(project.updated_at));
|
||||
const updatedTitle = $derived(new Date(project.updated_at).toLocaleString());
|
||||
|
||||
// Overflow menu (⋯) — closes on outside click, focus leaving, or Esc.
|
||||
let menuOpen = $state(false);
|
||||
let menuEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') menuOpen = false;
|
||||
};
|
||||
const onFocusIn = (e: FocusEvent) => {
|
||||
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
|
||||
};
|
||||
window.addEventListener('click', onDocClick);
|
||||
window.addEventListener('keydown', onKey);
|
||||
document.addEventListener('focusin', onFocusIn);
|
||||
return () => {
|
||||
window.removeEventListener('click', onDocClick);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.removeEventListener('focusin', onFocusIn);
|
||||
};
|
||||
});
|
||||
|
||||
let renameOpen = $state(false);
|
||||
let renameValue = $state('');
|
||||
|
||||
const renameMutation = createMutation({
|
||||
mutationFn: (title: string) => projects.update(project.id, { title }),
|
||||
onSuccess: (updated) => {
|
||||
client.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success(t('projectCard.renamedTo', { title: updated.title }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.renameFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
function openRename() {
|
||||
menuOpen = false;
|
||||
renameValue = project.title;
|
||||
renameOpen = true;
|
||||
}
|
||||
|
||||
function submitRename() {
|
||||
const title = renameValue.trim();
|
||||
renameOpen = false;
|
||||
if (!title || title === project.title) return;
|
||||
$renameMutation.mutate(title);
|
||||
}
|
||||
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
const deleteMutation = createMutation({
|
||||
mutationFn: () => projects.delete(project.id),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success(t('projectCard.deleted', { title: project.title }));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projectCard.deleteFailed'));
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<article class="project-card">
|
||||
<button
|
||||
type="button"
|
||||
class="card-main"
|
||||
aria-label={t('projectCard.open', { title: project.title })}
|
||||
onclick={() => goto(`/project/${project.id}`)}
|
||||
>
|
||||
<div class="card-thumb">
|
||||
{#if thumbUrl && thumbFailedFor !== thumbUrl}
|
||||
<img
|
||||
class="thumb-img"
|
||||
src={thumbUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onerror={() => (thumbFailedFor = thumbUrl)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="card-thumb-icon"><Icon name="video" size={26} /></div>
|
||||
{/if}
|
||||
<div class="card-status">
|
||||
<StatusChip status={project.status} label={t(`projectCard.status.${project.status}`)} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
{project.title}
|
||||
<span class="open-arrow">→</span>
|
||||
</div>
|
||||
<p class="card-desc">{project.idea || t('projectCard.noIdea')}</p>
|
||||
<div class="card-meta">
|
||||
<div class="card-stats">
|
||||
<span class="card-stat">
|
||||
<Icon name="script" size={13} />
|
||||
{t('projectCard.scenes', { count: stats.scene_count })}
|
||||
</span>
|
||||
<span class="card-stat">
|
||||
<Icon name="assets" size={13} />
|
||||
{t('projectCard.characters', { count: stats.character_count })}
|
||||
</span>
|
||||
<span class="card-stat">
|
||||
<Icon name="image" size={13} />
|
||||
{t('projectCard.assets', {
|
||||
ready: stats.asset_ready_count,
|
||||
total: stats.asset_total_count,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<span title={updatedTitle}>{updatedLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="menu-wrap" bind:this={menuEl}>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-btn"
|
||||
aria-label={t('projectCard.actions', { title: project.title })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div class="menu" role="menu" aria-label={t('projectCard.menuLabel')}>
|
||||
<button type="button" role="menuitem" class="menu-item" onclick={openRename}>
|
||||
<Icon name="edit" size={14} />
|
||||
{t('common.rename')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="menu-item"
|
||||
onclick={() => {
|
||||
menuOpen = false;
|
||||
goto(`/project/${project.id}?stage=assets`);
|
||||
}}
|
||||
>
|
||||
<Icon name="image" size={14} />
|
||||
{t('projectCard.changeCover')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="menu-item danger"
|
||||
onclick={() => {
|
||||
menuOpen = false;
|
||||
deleteOpen = true;
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<Modal bind:open={renameOpen} title={t('projectCard.renameTitle')}>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitRename();
|
||||
}}
|
||||
>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('common.title')}</span>
|
||||
<input class="field-input" bind:value={renameValue} required />
|
||||
</label>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (renameOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!renameValue.trim() || $renameMutation.isPending}
|
||||
onclick={submitRename}
|
||||
>
|
||||
{t('common.rename')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('projectCard.deleteTitle')}
|
||||
message={t('projectCard.deleteMessage', { title: project.title })}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onconfirm={() => $deleteMutation.mutate()}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.project-card {
|
||||
position: relative;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.project-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.project-card:hover .open-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.card-main {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-main:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.card-thumb {
|
||||
height: 170px;
|
||||
background: linear-gradient(135deg, var(--bg-elevated) 0%, #1a1625 100%);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-thumb::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, rgba(139, 92, 246, 0.12) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 70%, rgba(59, 130, 246, 0.08) 0%, transparent 40%);
|
||||
}
|
||||
.thumb-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.card-thumb-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.card-status {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
.card-body {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--space-xs);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.open-arrow {
|
||||
color: var(--accent);
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
}
|
||||
.card-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 var(--space-md);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.menu-wrap {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 3;
|
||||
}
|
||||
.menu-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(10, 10, 12, 0.55);
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.project-card:hover .menu-btn,
|
||||
.menu-btn:focus-visible,
|
||||
.menu-btn[aria-expanded='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
.menu-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.menu-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
left: 0;
|
||||
min-width: 140px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.menu-item.danger {
|
||||
color: var(--error);
|
||||
}
|
||||
.menu-item.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.project-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,867 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { assetUrl, jobsApi, projects, type Job, type Scene, type Clip } from '$lib/api';
|
||||
import { estimateTargetSeconds } from '$lib/durationBudget';
|
||||
import { agentDeepLink } from '$lib/agentTasks';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
let { projectId }: Props = $props();
|
||||
const client = useQueryClient();
|
||||
let editing = $state<Scene | null>(null);
|
||||
let editOpen = $state(false);
|
||||
let highlightId = $state<number | null>(null);
|
||||
let adding = $state(false);
|
||||
let deletingId = $state<number | null>(null);
|
||||
let sceneToDelete = $state<Scene | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
|
||||
const scenesQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['scenes', projectId],
|
||||
queryFn: () => projects.getScenes(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
// Shared cache key with QueueStage — clip job state shows up on scene cards
|
||||
// too. Refreshed via SSE invalidation from the project page, not polling.
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs', projectId],
|
||||
queryFn: () => jobsApi.list(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
// Cache-shared with the parent's story query — only used for the target-length hint.
|
||||
const storyQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['story', projectId],
|
||||
queryFn: () => projects.getStory(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
// Cache-shared with AssetsStage — resolves scene location chips.
|
||||
const assetsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['assets', projectId],
|
||||
queryFn: () => projects.getAssets(projectId),
|
||||
})),
|
||||
);
|
||||
|
||||
const scenes = $derived($scenesQuery.data?.scenes ?? []);
|
||||
const sceneCount = $derived(scenes.length);
|
||||
const totalClips = $derived(scenes.reduce((n, s) => n + (s.clips?.length ?? 0), 0));
|
||||
const totalSec = $derived($scenesQuery.data?.estimated_duration_sec ?? 0);
|
||||
const targetSec = $derived.by(() => {
|
||||
const target = $storyQuery.data?.project?.target_duration;
|
||||
return target ? estimateTargetSeconds(target) : 0;
|
||||
});
|
||||
|
||||
const busy = $derived(adding || deletingId != null);
|
||||
|
||||
// ── Break into shots (coverage expansion) ────────────────────────────
|
||||
let expandingIds = $state<number[]>([]);
|
||||
let expandingAll = $state(false);
|
||||
let expandedIds = $state<number[]>([]);
|
||||
|
||||
function isExpanded(scene: Scene): boolean {
|
||||
return (scene.clips?.length ?? 0) > 1 || expandedIds.includes(scene.id);
|
||||
}
|
||||
|
||||
async function breakIntoShots(scene: Scene) {
|
||||
if (expandingIds.length > 0 || expandingAll) return;
|
||||
expandingIds = [...expandingIds, scene.id];
|
||||
try {
|
||||
const res = await projects.expandClips(projectId, { scene_ids: [scene.id] });
|
||||
expandedIds = [...new Set([...expandedIds, scene.id])];
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(
|
||||
t('script.toast.breakIntoShots', {
|
||||
num: scene.order_index,
|
||||
clips: res.scenes?.[0]?.clip_count ?? '?',
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.breakFail'));
|
||||
} finally {
|
||||
expandingIds = expandingIds.filter((id) => id !== scene.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function breakAllIntoShots() {
|
||||
if (expandingIds.length > 0 || expandingAll) return;
|
||||
if (
|
||||
!window.confirm(t('script.toast.breakAllConfirm', { count: sceneCount }))
|
||||
)
|
||||
return;
|
||||
expandingAll = true;
|
||||
try {
|
||||
const res = await projects.expandClips(projectId, { all: true });
|
||||
expandedIds = scenes.map((s) => s.id);
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.boardExpanded', { count: res.total_clips }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.breakAllFail'));
|
||||
} finally {
|
||||
expandingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClip(clip: Clip, scene: Scene) {
|
||||
try {
|
||||
await projects.deleteClip(projectId, clip.id);
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.deletedShot', { num: `${scene.order_index}.${clip.order_index}` }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.deleteClipFail'));
|
||||
}
|
||||
}
|
||||
|
||||
const saveMutation = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateScene(projectId, editing!.id, {
|
||||
heading: editing!.heading,
|
||||
action: editing!.action,
|
||||
dialog: editing!.dialog,
|
||||
duration_sec: editing!.duration_sec,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editing = null;
|
||||
editOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.sceneSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.saveSceneFail'));
|
||||
},
|
||||
});
|
||||
|
||||
// Chain-from-previous toggle — persists on the scene; consumed at render time.
|
||||
let chainPendingId = $state<number | null>(null);
|
||||
async function toggleChain(scene: Scene) {
|
||||
if (chainPendingId != null) return;
|
||||
chainPendingId = scene.id;
|
||||
try {
|
||||
await projects.updateScene(projectId, scene.id, {
|
||||
chain_from_prev: !scene.chain_from_prev,
|
||||
});
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.updateSceneFail'));
|
||||
} finally {
|
||||
chainPendingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function jobForScene(sceneId: number): Job | undefined {
|
||||
const jobs = ($jobsQuery.data ?? []).filter(
|
||||
(j) => j.scene_id === sceneId && j.kind === 'video',
|
||||
);
|
||||
if (jobs.length === 0) return undefined;
|
||||
return [...jobs].sort((a, b) => b.id - a.id)[0];
|
||||
}
|
||||
|
||||
function jobForClip(clipId: number): Job | undefined {
|
||||
const jobs = ($jobsQuery.data ?? []).filter(
|
||||
(j) => (j as Job & { clip_id?: number | null }).clip_id === clipId && j.kind === 'video',
|
||||
);
|
||||
if (jobs.length === 0) return undefined;
|
||||
return [...jobs].sort((a, b) => b.id - a.id)[0];
|
||||
}
|
||||
|
||||
/** Per-scene production status — only uses fields the API actually exposes. */
|
||||
function sceneStatus(scene: Scene): { status: string; label: string } {
|
||||
const job = jobForScene(scene.id);
|
||||
if (job && (job.status === 'pending' || job.status === 'running')) {
|
||||
return { status: 'generating', label: t('script.status.rendering') };
|
||||
}
|
||||
if (job?.status === 'failed') return { status: 'failed', label: t('script.status.renderFailed') };
|
||||
if (scene.video_path || job?.status === 'done') {
|
||||
return { status: 'ready', label: t('script.status.clipReady') };
|
||||
}
|
||||
return { status: 'idle', label: t('script.status.noClip') };
|
||||
}
|
||||
|
||||
function locationName(scene: Scene): string | null {
|
||||
if (scene.location_id == null) return null;
|
||||
const locs = $assetsQuery.data?.locations ?? [];
|
||||
return locs.find((l) => l.id === scene.location_id)?.name ?? null;
|
||||
}
|
||||
|
||||
function avatarFor(char: { portrait_path: string | null; sheet_path: string | null }) {
|
||||
return assetUrl(char.portrait_path ?? char.sheet_path);
|
||||
}
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0].toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Hide a broken avatar image (portrait moved/deleted on disk), revealing initials. */
|
||||
function hideBrokenAvatar(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement | null;
|
||||
if (img) img.style.display = 'none';
|
||||
}
|
||||
|
||||
function formatClock(sec: number): string {
|
||||
const s = Math.max(0, Math.round(sec));
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${r.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function move(sceneId: number, dir: -1 | 1) {
|
||||
if (busy) return;
|
||||
const list = $scenesQuery.data?.scenes ?? [];
|
||||
const idx = list.findIndex((s) => s.id === sceneId);
|
||||
const swap = idx + dir;
|
||||
if (idx < 0 || swap < 0 || swap >= list.length) return;
|
||||
const ids = list.map((s) => s.id);
|
||||
[ids[idx], ids[swap]] = [ids[swap], ids[idx]];
|
||||
await projects.reorderScenes(projectId, ids);
|
||||
client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
}
|
||||
|
||||
/** Hand off script regeneration to a fresh, project-linked agent chat. */
|
||||
function requestRegenerate() {
|
||||
if (busy) return;
|
||||
goto(agentDeepLink(projectId, 'script'));
|
||||
}
|
||||
|
||||
function openEdit(scene: Scene) {
|
||||
editing = { ...scene };
|
||||
editOpen = true;
|
||||
}
|
||||
|
||||
async function addScene() {
|
||||
if (busy) return;
|
||||
adding = true;
|
||||
try {
|
||||
const existing = $scenesQuery.data?.scenes ?? [];
|
||||
const nextIndex = existing.length + 1;
|
||||
const created = await projects.createScene(projectId, {
|
||||
order_index: nextIndex,
|
||||
heading: `NEW SCENE ${nextIndex}`,
|
||||
action: '',
|
||||
dialog: '',
|
||||
duration_sec: 5,
|
||||
});
|
||||
// Keep order_index contiguous 1..n so Regenerate sees the real board size
|
||||
const ids = [...existing.map((s) => s.id), created.id];
|
||||
await projects.reorderScenes(projectId, ids);
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
await client.refetchQueries({ queryKey: ['scenes', projectId] });
|
||||
editing = { ...created, order_index: nextIndex, heading: `NEW SCENE ${nextIndex}` };
|
||||
editOpen = true;
|
||||
highlightId = created.id;
|
||||
await tick();
|
||||
document
|
||||
.getElementById(`scene-${created.id}`)
|
||||
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
toast.success(t('script.toast.sceneAdded', { num: nextIndex }));
|
||||
window.setTimeout(() => {
|
||||
if (highlightId === created.id) highlightId = null;
|
||||
}, 2800);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.addSceneFail'));
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestDelete(scene: Scene) {
|
||||
if (busy) return;
|
||||
sceneToDelete = scene;
|
||||
deleteOpen = true;
|
||||
}
|
||||
|
||||
async function confirmDeleteScene() {
|
||||
const scene = sceneToDelete;
|
||||
sceneToDelete = null;
|
||||
if (!scene || busy) return;
|
||||
const label = scene.heading?.trim() || `Scene #${scene.order_index}`;
|
||||
deletingId = scene.id;
|
||||
try {
|
||||
await projects.deleteScene(projectId, scene.id);
|
||||
if (editing?.id === scene.id) {
|
||||
editing = null;
|
||||
editOpen = false;
|
||||
}
|
||||
const remaining = ($scenesQuery.data?.scenes ?? [])
|
||||
.filter((s) => s.id !== scene.id)
|
||||
.map((s) => s.id);
|
||||
if (remaining.length > 0) {
|
||||
await projects.reorderScenes(projectId, remaining);
|
||||
}
|
||||
await client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
toast.success(t('script.toast.deleted', { name: label }));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('script.toast.deleteFailed'));
|
||||
} finally {
|
||||
deletingId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="stage-header">
|
||||
<div>
|
||||
<h2>3. Script</h2>
|
||||
<p class="muted">
|
||||
Estimated duration: {formatClock(totalSec)}
|
||||
{#if targetSec > 0}
|
||||
/ target ~{formatClock(targetSec)}
|
||||
{/if}
|
||||
{#if sceneCount > 0}
|
||||
· {sceneCount} scenes
|
||||
{#if totalClips > 0}
|
||||
/ {totalClips} shot clips
|
||||
{/if}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="stage-actions">
|
||||
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
|
||||
<Icon name="plus" size={14} /> {t('script.addScene')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy || expandingAll || expandingIds.length > 0 || sceneCount === 0}
|
||||
loading={expandingAll}
|
||||
onclick={breakAllIntoShots}
|
||||
title={t('script.breakAllTitle')}
|
||||
>
|
||||
<Icon name="film" size={14} /> {t('script.breakAllIntoShots')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={busy} onclick={requestRegenerate}>
|
||||
<Icon name="sparkle" size={15} /> {t('script.regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="script-panel">
|
||||
{#if $scenesQuery.isLoading}
|
||||
<div class="card">{t('script.loadingScenes')}</div>
|
||||
{:else if sceneCount === 0}
|
||||
<EmptyState
|
||||
title={t('script.noScenesTitle')}
|
||||
body={t('script.noScenesBody')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Icon name="script" size={28} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
|
||||
<Icon name="plus" size={14} /> {t('script.addScene')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
{#each scenes as scene, i (scene.id)}
|
||||
{@const st = sceneStatus(scene)}
|
||||
{@const locName = locationName(scene)}
|
||||
<article
|
||||
id="scene-{scene.id}"
|
||||
class="card scene"
|
||||
class:fresh={highlightId === scene.id}
|
||||
class:deleting={deletingId === scene.id}
|
||||
>
|
||||
<div class="scene-head">
|
||||
<div class="scene-title">
|
||||
<span class="grip" aria-hidden="true"><Icon name="drag" size={14} /></span>
|
||||
<span class="num">#{scene.order_index}</span>
|
||||
<strong>{scene.heading || t('script.untitledScene')}</strong>
|
||||
{#if highlightId === scene.id}
|
||||
<span class="fresh-tag">{t('script.justAdded')}</span>
|
||||
{/if}
|
||||
<StatusChip status={st.status} label={st.label} />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('script.moveSceneUp')}
|
||||
title={t('script.moveSceneUp')}
|
||||
disabled={busy || i === 0}
|
||||
onclick={() => move(scene.id, -1)}
|
||||
>
|
||||
<Icon name="chevron-up" size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('script.moveSceneDown')}
|
||||
title={t('script.moveSceneDown')}
|
||||
disabled={busy || i === sceneCount - 1}
|
||||
onclick={() => move(scene.id, 1)}
|
||||
>
|
||||
<Icon name="chevron-down" size={15} />
|
||||
</button>
|
||||
<Button variant="secondary" size="sm" disabled={busy} onclick={() => openEdit(scene)}>
|
||||
{t('script.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
loading={deletingId === scene.id}
|
||||
onclick={() => requestDelete(scene)}
|
||||
>
|
||||
{t('script.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if scene.action}
|
||||
<p class="muted">{scene.action}</p>
|
||||
{:else}
|
||||
<p class="muted empty-line">{t('script.noActionYet')}</p>
|
||||
{/if}
|
||||
{#if scene.dialog}
|
||||
<pre class="dialog">{scene.dialog}</pre>
|
||||
{/if}
|
||||
<div class="clip-block">
|
||||
<div class="clip-head">
|
||||
<span class="clip-title">
|
||||
<Icon name="film" size={12} />
|
||||
{(scene.clips ?? []).length}
|
||||
{((scene.clips ?? []).length === 1 ? t('script.shotClipSingular') : t('script.shotClipPlural', { n: (scene.clips ?? []).length }))}
|
||||
{#if (scene.clips ?? []).some((c) => c.clip_path)}
|
||||
· {t('script.renderedCount', { count: (scene.clips ?? []).filter((c) => c.clip_path).length })}
|
||||
{/if}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={busy || expandingIds.length > 0 || expandingAll}
|
||||
loading={expandingIds.includes(scene.id)}
|
||||
onclick={() => breakIntoShots(scene)}
|
||||
title={isExpanded(scene) ? t('script.rebreakTitle') : t('script.breakTitle')}
|
||||
>
|
||||
{isExpanded(scene) ? t('script.rebreak') : t('script.break')}
|
||||
</Button>
|
||||
</div>
|
||||
{#if isExpanded(scene) && (scene.clips ?? []).length > 1}
|
||||
<ul class="clip-list">
|
||||
{#each scene.clips ?? [] as clip (clip.id)}
|
||||
{@const clipJob = jobForClip(clip.id)}
|
||||
<li class="clip-row">
|
||||
<span class="clip-num" class:ready={Boolean(clip.clip_path)}>
|
||||
#{scene.order_index}.{clip.order_index}
|
||||
</span>
|
||||
<span class="clip-desc">{clip.description || t('script.untitledShot')}</span>
|
||||
{#if clip.shot_size}
|
||||
<span class="chip">{clip.shot_size}</span>
|
||||
{/if}
|
||||
{#if clipJob && (clipJob.status === 'pending' || clipJob.status === 'running')}
|
||||
<span class="chip chip-render">{t('script.rendering')}</span>
|
||||
{:else if clip.clip_path}
|
||||
<span class="chip chip-ok">✓ {t('script.clipDone')}</span>
|
||||
{/if}
|
||||
{#if clip.duration_sec}
|
||||
<span class="clip-dur">{clip.duration_sec}s</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('script.deleteShotAria', { id: `${scene.order_index}.${clip.order_index}` })}
|
||||
title={t('script.deleteShotTitle')}
|
||||
onclick={() => deleteClip(clip, scene)}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="chips">
|
||||
{#each scene.characters ?? [] as c (c.id)}
|
||||
{@const avatar = avatarFor(c)}
|
||||
<span class="chip">
|
||||
<span class="avatar">
|
||||
<span class="initials" aria-hidden="true">{initialsOf(c.name)}</span>
|
||||
{#if avatar}
|
||||
<img src={avatar} alt="" loading="lazy" onerror={hideBrokenAvatar} />
|
||||
{/if}
|
||||
</span>
|
||||
{c.name}
|
||||
</span>
|
||||
{/each}
|
||||
{#if locName}
|
||||
<span class="chip"><Icon name="folder" size={12} /> {locName}</span>
|
||||
{/if}
|
||||
{#if scene.duration_sec}
|
||||
<span class="chip"><Icon name="clock" size={12} /> {formatClock(scene.duration_sec)}</span>
|
||||
{/if}
|
||||
{#if i > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="chip chip-toggle"
|
||||
class:chip-on={Boolean(scene.chain_from_prev)}
|
||||
disabled={chainPendingId === scene.id}
|
||||
title={t('script.chainTitle')}
|
||||
onclick={() => toggleChain(scene)}
|
||||
>
|
||||
<Icon name="film" size={12} />
|
||||
{Boolean(scene.chain_from_prev) ? t('script.chainOn') : t('script.chainOff')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
bind:open={editOpen}
|
||||
title={editing ? t('script.editSceneNumbered', { n: editing.order_index }) : t('script.editScene')}
|
||||
onclose={() => (editing = null)}
|
||||
>
|
||||
{#if editing}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.headingField')}</span>
|
||||
<input class="field-input" bind:value={editing.heading} placeholder={t('script.headingPlaceholder')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.actionField')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={editing.action}
|
||||
rows="4"
|
||||
placeholder={t('script.actionPlaceholder')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.dialogField')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={editing.dialog}
|
||||
rows="4"
|
||||
placeholder={t('script.dialogPlaceholder')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('script.durationField')}</span>
|
||||
<input class="field-input" type="number" bind:value={editing.duration_sec} min="1" />
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (editOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={$saveMutation.isPending}
|
||||
onclick={() => $saveMutation.mutate()}
|
||||
>
|
||||
{t('script.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('script.deleteSceneTitle')}
|
||||
message={sceneToDelete
|
||||
? t('script.deleteSceneMessage', {
|
||||
heading: sceneToDelete.heading?.trim() || t('script.sceneNumber', { n: sceneToDelete.order_index }),
|
||||
})
|
||||
: ''}
|
||||
confirmLabel={t('script.delete')}
|
||||
danger
|
||||
onconfirm={() => void confirmDeleteScene()}
|
||||
oncancel={() => (sceneToDelete = null)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.stage-header {
|
||||
/* Pins inside the stage scroll container so Add Scene / Regenerate
|
||||
stay reachable while scrolling long scripts. */
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
padding: 8px 0 12px;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.stage-header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stage-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
.script-panel {
|
||||
position: relative;
|
||||
min-height: 220px;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: var(--space-md);
|
||||
scroll-margin-top: 84px;
|
||||
scroll-margin-bottom: 24px;
|
||||
}
|
||||
.scene {
|
||||
transition:
|
||||
border-color 0.25s ease,
|
||||
box-shadow 0.25s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
.scene.fresh {
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--accent),
|
||||
0 0 24px var(--accent-glow);
|
||||
animation: scene-in 0.45s ease-out;
|
||||
}
|
||||
.scene.deleting {
|
||||
opacity: 0.45;
|
||||
}
|
||||
@keyframes scene-in {
|
||||
from {
|
||||
opacity: 0.35;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scene.fresh {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.scene-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.scene-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.grip {
|
||||
display: inline-flex;
|
||||
color: var(--text-muted);
|
||||
cursor: grab;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.num {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.fresh-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
background: var(--accent-glow);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.icon-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.empty-line {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dialog {
|
||||
background: var(--bg-elevated);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.chip-toggle {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.chip-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: #52525b;
|
||||
}
|
||||
.chip-toggle:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.chip-on {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-elevated));
|
||||
}
|
||||
.clip-block {
|
||||
margin-top: 10px;
|
||||
border-top: 1px dashed var(--border);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.clip-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.clip-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.clip-list {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.clip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.clip-num {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.clip-num.ready {
|
||||
color: var(--accent);
|
||||
}
|
||||
.clip-desc {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.clip-dur {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-ok {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.chip-render {
|
||||
color: var(--warning, #eab308);
|
||||
border-color: var(--warning, #eab308);
|
||||
}
|
||||
.avatar {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.avatar img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.initials {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,947 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
|
||||
import {
|
||||
projects,
|
||||
type Beat,
|
||||
type Character,
|
||||
type Item,
|
||||
type Location,
|
||||
type StoryData,
|
||||
} from '$lib/api';
|
||||
import {
|
||||
buildStoryDraftPromptPreview,
|
||||
estimateTargetSeconds,
|
||||
recommendBeatCount,
|
||||
recommendSceneCount,
|
||||
} from '$lib/durationBudget';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { agentDeepLink } from '$lib/agentTasks';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
story: StoryData;
|
||||
configured: boolean;
|
||||
onGoAssets?: () => void;
|
||||
}
|
||||
|
||||
let { projectId, story, configured, onGoAssets }: Props = $props();
|
||||
const client = useQueryClient();
|
||||
|
||||
const GENRES = [
|
||||
'Adventure / Mystery',
|
||||
'Drama',
|
||||
'Sci-Fi',
|
||||
'Fantasy',
|
||||
'Horror',
|
||||
'Romance',
|
||||
'Thriller',
|
||||
];
|
||||
const LENGTHS_HINT = $derived(t('story.lengthsHint'));
|
||||
const TONES = [
|
||||
'Cinematic, atmospheric',
|
||||
'Dark, tense',
|
||||
'Whimsical, warm',
|
||||
'Gritty, realistic',
|
||||
'Epic, sweeping',
|
||||
];
|
||||
|
||||
const GENRE_KEYS: Record<string, string> = {
|
||||
'Adventure / Mystery': 'genres.adventure',
|
||||
Drama: 'genres.drama',
|
||||
'Sci-Fi': 'genres.scifi',
|
||||
Fantasy: 'genres.fantasy',
|
||||
Horror: 'genres.horror',
|
||||
Romance: 'genres.romance',
|
||||
Thriller: 'genres.thriller',
|
||||
};
|
||||
const TONE_KEYS: Record<string, string> = {
|
||||
'Cinematic, atmospheric': 'tones.cinematic',
|
||||
'Dark, tense': 'tones.dark',
|
||||
'Whimsical, warm': 'tones.whimsical',
|
||||
'Gritty, realistic': 'tones.gritty',
|
||||
'Epic, sweeping': 'tones.epic',
|
||||
};
|
||||
function genreLabel(v: string): string {
|
||||
return t(GENRE_KEYS[v] ?? v);
|
||||
}
|
||||
function toneLabel(v: string): string {
|
||||
return t(TONE_KEYS[v] ?? v);
|
||||
}
|
||||
|
||||
const EXAMPLE = {
|
||||
idea: 'A lone cartographer discovers a map that rewrites itself every midnight, leading her into a forgotten city beneath the desert.',
|
||||
genre: 'Adventure / Mystery',
|
||||
tone: 'Cinematic, atmospheric',
|
||||
target_duration: '2 minutes',
|
||||
};
|
||||
|
||||
let editingBeat = $state<Beat | null>(null);
|
||||
let editingChar = $state<Character | null>(null);
|
||||
let editingLoc = $state<Location | null>(null);
|
||||
let editingItem = $state<Item | null>(null);
|
||||
let beatModalOpen = $state(false);
|
||||
let charModalOpen = $state(false);
|
||||
let locModalOpen = $state(false);
|
||||
let itemModalOpen = $state(false);
|
||||
let confirmExampleOpen = $state(false);
|
||||
let deletingBeat = $state<Beat | null>(null);
|
||||
let beatDeleteOpen = $state(false);
|
||||
let addingBeat = $state(false);
|
||||
// Static defaults — the $effect below syncs all drafts from the story prop.
|
||||
let ideaDraft = $state('');
|
||||
let genreDraft = $state(GENRES[0]);
|
||||
let toneDraft = $state(TONES[0]);
|
||||
let lengthDraft = $state('2 minutes');
|
||||
let showDraftPrompt = $state(false);
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
let saveState = $state<SaveState>('idle');
|
||||
let savedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const beatBudget = $derived(recommendBeatCount(lengthDraft));
|
||||
const sceneBudget = $derived(recommendSceneCount(lengthDraft));
|
||||
const secsBudget = $derived(estimateTargetSeconds(lengthDraft));
|
||||
const draftPromptPreview = $derived(
|
||||
buildStoryDraftPromptPreview({
|
||||
title: story.project.title,
|
||||
idea: ideaDraft,
|
||||
genre: genreDraft,
|
||||
tone: toneDraft,
|
||||
targetDuration: lengthDraft,
|
||||
}),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
ideaDraft = story.project.idea ?? '';
|
||||
genreDraft = story.project.genre || GENRES[0];
|
||||
toneDraft = story.project.tone || TONES[0];
|
||||
lengthDraft = story.project.target_duration || '2 minutes';
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (savedTimer) clearTimeout(savedTimer);
|
||||
};
|
||||
});
|
||||
|
||||
function armSavedTimer() {
|
||||
if (savedTimer) clearTimeout(savedTimer);
|
||||
savedTimer = setTimeout(() => {
|
||||
if (saveState === 'saved') saveState = 'idle';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const saveProject = createMutation({
|
||||
mutationFn: (payload: {
|
||||
idea?: string;
|
||||
genre?: string;
|
||||
tone?: string;
|
||||
target_duration?: string;
|
||||
}) => projects.update(projectId, payload),
|
||||
onMutate: () => {
|
||||
saveState = 'saving';
|
||||
},
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
saveState = 'saved';
|
||||
armSavedTimer();
|
||||
},
|
||||
onError: (err) => {
|
||||
saveState = 'error';
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveSettingsFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
/** Autosave path — returns false on failure (mutation onError already toasted). */
|
||||
async function persistSettings(): Promise<boolean> {
|
||||
try {
|
||||
await $saveProject.mutateAsync({
|
||||
idea: ideaDraft,
|
||||
genre: genreDraft || undefined,
|
||||
tone: toneDraft || undefined,
|
||||
target_duration: lengthDraft || undefined,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExample() {
|
||||
ideaDraft = EXAMPLE.idea;
|
||||
genreDraft = EXAMPLE.genre;
|
||||
toneDraft = EXAMPLE.tone;
|
||||
lengthDraft = EXAMPLE.target_duration;
|
||||
const saved = await persistSettings();
|
||||
if (saved) toast.success(t('story.exampleLoaded'));
|
||||
}
|
||||
|
||||
function requestLoadExample() {
|
||||
if (ideaDraft.trim()) confirmExampleOpen = true;
|
||||
else void loadExample();
|
||||
}
|
||||
|
||||
/** Hand off to a fresh, project-linked agent chat with the composer pre-filled. */
|
||||
async function draftStoryline() {
|
||||
if (!configured) return;
|
||||
const saved = await persistSettings();
|
||||
if (!saved) return; // persistSettings already toasted the failure
|
||||
goto(agentDeepLink(projectId, 'story'));
|
||||
}
|
||||
|
||||
function openBeat(beat: Beat) {
|
||||
editingBeat = { ...beat };
|
||||
beatModalOpen = true;
|
||||
}
|
||||
|
||||
async function addBeat() {
|
||||
if (addingBeat) return;
|
||||
addingBeat = true;
|
||||
try {
|
||||
const nextIndex =
|
||||
story.beats.reduce((max, b) => Math.max(max, b.order_index), 0) + 1;
|
||||
// No api.ts binding exists yet for POST /beats — call the endpoint directly.
|
||||
const res = await fetch(`/api/projects/${projectId}/beats`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
order_index: nextIndex,
|
||||
title: t('story.newBeat', { n: nextIndex }),
|
||||
description: '',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => 'unknown error');
|
||||
throw new Error(`${res.status}: ${body}`);
|
||||
}
|
||||
const created = (await res.json()) as Beat;
|
||||
await client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.beatAdded', { index: nextIndex }));
|
||||
openBeat(created);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('story.addBeatFailed'));
|
||||
} finally {
|
||||
addingBeat = false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestDeleteBeat(beat: Beat) {
|
||||
deletingBeat = beat;
|
||||
beatDeleteOpen = true;
|
||||
}
|
||||
|
||||
const deleteBeatMutation = createMutation({
|
||||
mutationFn: (beatId: number) => projects.deleteBeat(projectId, beatId),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.beatDeleted'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.deleteBeatFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveBeat = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateBeat(projectId, editingBeat!.id, {
|
||||
title: editingBeat!.title,
|
||||
description: editingBeat!.description,
|
||||
order_index: editingBeat!.order_index,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingBeat = null;
|
||||
beatModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.beatSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveBeatFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveChar = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateCharacter(projectId, editingChar!.id, {
|
||||
name: editingChar!.name,
|
||||
role: editingChar!.role,
|
||||
age: editingChar!.age,
|
||||
appearance: editingChar!.appearance,
|
||||
personality: editingChar!.personality,
|
||||
consistency_prompt: editingChar!.consistency_prompt,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingChar = null;
|
||||
charModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.characterSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveCharacterFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveLoc = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateLocation(projectId, editingLoc!.id, {
|
||||
name: editingLoc!.name,
|
||||
description: editingLoc!.description,
|
||||
consistency_prompt: editingLoc!.consistency_prompt,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingLoc = null;
|
||||
locModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.locationSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveLocationFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const saveItem = createMutation({
|
||||
mutationFn: () =>
|
||||
projects.updateItem(projectId, editingItem!.id, {
|
||||
name: editingItem!.name,
|
||||
description: editingItem!.description,
|
||||
consistency_prompt: editingItem!.consistency_prompt,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
editingItem = null;
|
||||
itemModalOpen = false;
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
toast.success(t('story.itemSaved'));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('story.saveItemFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
function goToAssets() {
|
||||
goto('?stage=assets', { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
function beatCountLabel(n: number): string {
|
||||
return t('story.beats', { n });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet saveIndicator()}
|
||||
{#if saveState === 'saving'}
|
||||
<span class="save-ind"><Spinner size="sm" /> {t('common.saving')}</span>
|
||||
{:else if saveState === 'saved'}
|
||||
<span class="save-ind saved"><Icon name="check" size={13} /> {t('common.saved')}</span>
|
||||
{:else if saveState === 'error'}
|
||||
<span class="save-ind err">
|
||||
<Icon name="alert" size={13} /> {t('common.saveFailed')}
|
||||
<Button variant="ghost" size="sm" onclick={() => void persistSettings()}>{t('common.retry')}</Button>
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<header class="stage-header">
|
||||
<h2>{t('story.stageTitle')}</h2>
|
||||
<div class="stage-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={$saveProject.isPending}
|
||||
onclick={requestLoadExample}
|
||||
>
|
||||
{t('story.loadExample')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!configured} onclick={() => void draftStoryline()}>
|
||||
<Icon name="sparkle" size={15} /> {t('story.draftStoryline')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if !configured}
|
||||
<div class="banner">{t('story.llmBanner')}</div>
|
||||
{/if}
|
||||
|
||||
<div class="stack-col">
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.ideaTitle')}</h3>
|
||||
{@render saveIndicator()}
|
||||
{/snippet}
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={ideaDraft}
|
||||
rows="5"
|
||||
placeholder={t('story.ideaPlaceholder')}
|
||||
onblur={() => void persistSettings()}
|
||||
></textarea>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.settingsTitle')}</h3>
|
||||
{@render saveIndicator()}
|
||||
{/snippet}
|
||||
<div class="grid-3">
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.genre')}</span>
|
||||
<select
|
||||
class="field-select"
|
||||
bind:value={genreDraft}
|
||||
onchange={() => void persistSettings()}
|
||||
>
|
||||
{#each GENRES as g}
|
||||
<option value={g}>{genreLabel(g)}</option>
|
||||
{/each}
|
||||
{#if genreDraft && !GENRES.includes(genreDraft)}
|
||||
<option value={genreDraft}>{genreLabel(genreDraft)}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.targetLength')}</span>
|
||||
<input
|
||||
class="field-input"
|
||||
type="text"
|
||||
bind:value={lengthDraft}
|
||||
placeholder={LENGTHS_HINT}
|
||||
onblur={() => void persistSettings()}
|
||||
/>
|
||||
<span class="field-hint beat-hint">
|
||||
{t('story.lengthHint', { secs: secsBudget, beats: beatBudget, scenes: sceneBudget })}
|
||||
</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.tone')}</span>
|
||||
<select
|
||||
class="field-select"
|
||||
bind:value={toneDraft}
|
||||
onchange={() => void persistSettings()}
|
||||
>
|
||||
{#each TONES as tone}
|
||||
<option value={tone}>{toneLabel(tone)}</option>
|
||||
{/each}
|
||||
{#if toneDraft && !TONES.includes(toneDraft)}
|
||||
<option value={toneDraft}>{toneLabel(toneDraft)}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.draftPromptTitle')}</h3>
|
||||
<Button variant="ghost" size="sm" onclick={() => (showDraftPrompt = !showDraftPrompt)}>
|
||||
{showDraftPrompt ? t('story.hidePrompt') : t('story.showPrompt')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#if showDraftPrompt}
|
||||
<pre class="prompt-preview">{draftPromptPreview}</pre>
|
||||
<p class="field-hint">
|
||||
{t('story.promptExplain')}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="field-hint prompt-collapsed">
|
||||
{t('story.promptCollapsedHint')}
|
||||
</p>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.beatsTitle')}</h3>
|
||||
<div class="head-actions">
|
||||
{#if story.beats.length}
|
||||
<StatusChip status="ready" label={beatCountLabel(story.beats.length)} />
|
||||
{/if}
|
||||
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
|
||||
<Icon name="plus" size={14} /> {t('story.addBeat')}
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#if story.beats.length === 0}
|
||||
<EmptyState
|
||||
title={t('story.noBeats')}
|
||||
body={t('story.noBeatsBody')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Icon name="story" size={28} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
|
||||
<Icon name="plus" size={14} /> {t('story.addBeatManual')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="beat-list">
|
||||
{#each story.beats as beat (beat.id)}
|
||||
<div class="beat-row">
|
||||
<button type="button" class="beat" onclick={() => openBeat(beat)}>
|
||||
<div class="beat-number">{beat.order_index}</div>
|
||||
<div class="beat-body">
|
||||
<h4>{beat.title}</h4>
|
||||
<p>{beat.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
<div class="beat-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={t('story.deleteBeat', { index: beat.order_index })}
|
||||
onclick={() => requestDeleteBeat(beat)}
|
||||
>
|
||||
<Icon name="trash" size={14} /><span class="sr-only">{t('story.deleteBeat', { index: beat.order_index })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="beats-footer">
|
||||
<Button variant="primary" onclick={goToAssets}>
|
||||
{t('story.continueAssets')} <Icon name="chevron-right" size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
{#if story.characters.length || story.locations.length || story.items.length}
|
||||
<Card>
|
||||
{#snippet header()}
|
||||
<h3 class="card-h">{t('story.extracted')}</h3>
|
||||
<Button variant="ghost" size="sm" onclick={goToAssets}>
|
||||
{t('story.openAssets')} <Icon name="chevron-right" size={14} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="chips">
|
||||
{#each story.characters as char (char.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => {
|
||||
editingChar = { ...char };
|
||||
charModalOpen = true;
|
||||
}}
|
||||
title={char.appearance ?? ''}
|
||||
>
|
||||
<span class="chip-tag">{t('story.charTag')}</span>
|
||||
{char.name}
|
||||
</button>
|
||||
{/each}
|
||||
{#each story.locations as loc (loc.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => {
|
||||
editingLoc = { ...loc };
|
||||
locModalOpen = true;
|
||||
}}
|
||||
title={loc.description ?? ''}
|
||||
>
|
||||
<span class="chip-tag">{t('story.envTag')}</span>
|
||||
{loc.name}
|
||||
</button>
|
||||
{/each}
|
||||
{#each story.items as item (item.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
onclick={() => {
|
||||
editingItem = { ...item };
|
||||
itemModalOpen = true;
|
||||
}}
|
||||
title={item.description ?? ''}
|
||||
>
|
||||
<span class="chip-tag">{t('story.itemTag')}</span>
|
||||
{item.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal bind:open={beatModalOpen} title={t('story.editBeatTitle')} onclose={() => (editingBeat = null)}>
|
||||
{#if editingBeat}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldTitle')}</span>
|
||||
<input class="field-input" bind:value={editingBeat.title} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldDescription')}</span>
|
||||
<textarea class="field-textarea" rows="4" bind:value={editingBeat.description}></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (beatModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveBeat.isPending} onclick={() => $saveBeat.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={charModalOpen} title={t('story.editCharTitle')} onclose={() => (editingChar = null)}>
|
||||
{#if editingChar}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldName')}</span>
|
||||
<input class="field-input" bind:value={editingChar.name} placeholder={t('story.fieldName')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldRole')}</span>
|
||||
<input class="field-input" bind:value={editingChar.role} placeholder={t('story.fieldRole')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldAge')}</span>
|
||||
<input class="field-input" bind:value={editingChar.age} placeholder={t('story.fieldAge')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldAppearance')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
bind:value={editingChar.appearance}
|
||||
placeholder={t('story.fieldAppearance')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldPersonality')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingChar.personality}
|
||||
placeholder={t('story.fieldPersonality')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldConsistency')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingChar.consistency_prompt}
|
||||
placeholder={t('story.fieldConsistency')}
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (charModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveChar.isPending} onclick={() => $saveChar.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={locModalOpen} title={t('story.editLocTitle')} onclose={() => (editingLoc = null)}>
|
||||
{#if editingLoc}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldName')}</span>
|
||||
<input class="field-input" bind:value={editingLoc.name} placeholder={t('story.fieldName')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldDescription')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
bind:value={editingLoc.description}
|
||||
placeholder={t('story.fieldDescription')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldConsistency')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingLoc.consistency_prompt}
|
||||
placeholder={t('story.fieldConsistency')}
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (locModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveLoc.isPending} onclick={() => $saveLoc.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={itemModalOpen} title={t('story.editItemTitle')} onclose={() => (editingItem = null)}>
|
||||
{#if editingItem}
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldName')}</span>
|
||||
<input class="field-input" bind:value={editingItem.name} placeholder={t('story.fieldName')} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldDescription')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="3"
|
||||
bind:value={editingItem.description}
|
||||
placeholder={t('story.fieldDescription')}
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('story.fieldConsistency')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
rows="2"
|
||||
bind:value={editingItem.consistency_prompt}
|
||||
placeholder={t('story.fieldConsistency')}
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (itemModalOpen = false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={$saveItem.isPending} onclick={() => $saveItem.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmExampleOpen}
|
||||
title={t('story.confirmExampleTitle')}
|
||||
message={t('story.confirmExampleMsg')}
|
||||
confirmLabel={t('story.loadExample')}
|
||||
onconfirm={() => void loadExample()}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={beatDeleteOpen}
|
||||
title={t('story.confirmDeleteBeatTitle')}
|
||||
message={deletingBeat
|
||||
? t('story.confirmDeleteBeatMsg', { index: deletingBeat.order_index, title: deletingBeat.title })
|
||||
: ''}
|
||||
confirmLabel={t('common.delete')}
|
||||
danger
|
||||
onconfirm={() => {
|
||||
const id = deletingBeat?.id;
|
||||
deletingBeat = null;
|
||||
if (id != null) $deleteBeatMutation.mutate(id);
|
||||
}}
|
||||
oncancel={() => (deletingBeat = null)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.stage-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.stage-header h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stage-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.banner {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
color: var(--warning);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stack-col {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.card-h {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.save-ind {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.save-ind.saved {
|
||||
color: var(--success);
|
||||
animation: saved-fade 2s ease forwards;
|
||||
}
|
||||
.save-ind.err {
|
||||
color: var(--error);
|
||||
}
|
||||
@keyframes saved-fade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
12% {
|
||||
opacity: 1;
|
||||
}
|
||||
78% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.grid-3 .field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.beat-hint {
|
||||
display: block;
|
||||
}
|
||||
.beat-hint strong {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.prompt-preview {
|
||||
margin: 0 0 var(--space-sm);
|
||||
padding: 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
color: var(--text-secondary);
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
.prompt-collapsed {
|
||||
margin: 0;
|
||||
}
|
||||
.beat-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.beat-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.beat {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: var(--space-md);
|
||||
align-items: flex-start;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
.beat:hover {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.beat:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.beat-actions {
|
||||
flex-shrink: 0;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.beat-number {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.beat-body h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.beat-body p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.beats-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-md);
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.chip-tag {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -37,8 +37,13 @@
|
||||
<span class="dot"></span>
|
||||
<Icon name={kindIcon[toast.kind]} size={15} />
|
||||
</span>
|
||||
<span class="msg">{toast.message}</span>
|
||||
<button
|
||||
<span class="msg">{toast.message}</span>
|
||||
{#if toast.action}
|
||||
<button type="button" class="action" onclick={() => (toast.action?.run(), dismissToast(toast.id))}>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="dismiss"
|
||||
aria-label={t('toast.dismiss')}
|
||||
@@ -52,15 +57,17 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* §四.5 Toast:右下角堆叠,220ms 上移入场;成功绿点 / 失败红点。 */
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
/* Above every overlay, incl. the 10000 lightbox, so feedback stays visible. */
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
max-width: min(400px, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
@@ -69,23 +76,10 @@
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
animation: toast-in 0.22s ease-out;
|
||||
}
|
||||
.toast.success {
|
||||
border-color: rgba(34, 197, 94, 0.45);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.45),
|
||||
0 0 0 1px rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.toast.error {
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
.toast.info {
|
||||
border-color: rgba(59, 130, 246, 0.45);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--float-bg);
|
||||
box-shadow: var(--shadow-float);
|
||||
animation: toast-in var(--d-enter) var(--ease-out);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
@@ -101,21 +95,44 @@
|
||||
background: currentColor;
|
||||
}
|
||||
.toast.success .badge {
|
||||
color: var(--success);
|
||||
color: var(--ok);
|
||||
}
|
||||
.toast.error .badge {
|
||||
color: var(--error);
|
||||
color: var(--err);
|
||||
}
|
||||
.toast.info .badge {
|
||||
color: var(--info);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.msg {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* §四.5: Toast 动作(如撤销)为金色文字 */
|
||||
.action {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
padding: var(--sp-1) var(--sp-2);
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.action:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
}
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.dismiss {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -127,10 +144,12 @@
|
||||
padding: 0 2px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
.dismiss:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -139,7 +158,7 @@
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -151,10 +170,12 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
:global(:root[data-reduce='1']) .toast {
|
||||
animation: none;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.toast-host {
|
||||
top: auto;
|
||||
bottom: 20px;
|
||||
bottom: calc(var(--tabbar-h) + var(--sp-5) + env(safe-area-inset-bottom));
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
max-width: none;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,827 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import {
|
||||
apiErrorMessage,
|
||||
assetUrl,
|
||||
playgroundApi,
|
||||
jobsApi,
|
||||
workflows,
|
||||
type ComfyEndpoint,
|
||||
type Job,
|
||||
type Workflow,
|
||||
} from '$lib/api';
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Drawer from '$lib/components/ui/Drawer.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ProgressBar from '$lib/components/ui/ProgressBar.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import { mediaFileProblem, type MediaSlotKind } from '$lib/comfy/imageFile';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 测试工作流 (HEL-643 L3, E 批抽屉化): fill the workflow's REAL input
|
||||
* slots, submit one REAL run to the chosen ComfyUI through the ordinary
|
||||
* job queue, and call it a pass only when at least one downloadable
|
||||
* output of the right media kind came back. Test artifacts are isolated
|
||||
* from projects. Presentation follows HEL-649 §3.8: right-side drawer,
|
||||
* cyan progress + mono stage log while running, result thumbnails in a
|
||||
* horizontal row.
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
workflow: Workflow | null;
|
||||
endpoints: ComfyEndpoint[];
|
||||
onchanged?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
workflow,
|
||||
endpoints,
|
||||
onchanged,
|
||||
}: Props = $props();
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
let endpointId = $state('');
|
||||
let values = $state<Record<string, string | number>>({});
|
||||
let listValues = $state<Record<string, string[]>>({});
|
||||
let sizes = $state<Record<string, number>>({});
|
||||
let uploadingName = $state<Record<string, string>>({});
|
||||
let rejects = $state<Record<string, { name: string; message: string }[]>>({});
|
||||
let submitError = $state('');
|
||||
let confirmOpen = $state(false);
|
||||
let submitting = $state(false);
|
||||
let testJobId = $state<number | null>(null);
|
||||
let errorExpanded = $state(false);
|
||||
let logBox = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const wf = $derived(workflow);
|
||||
const schema = $derived(wf?.input_schema ?? []);
|
||||
const listEntries = $derived(
|
||||
schema.filter(
|
||||
(i) =>
|
||||
i.kind === 'image_list' ||
|
||||
i.kind === 'video_list' ||
|
||||
i.kind === 'audio_list',
|
||||
),
|
||||
);
|
||||
const formEntries = $derived(
|
||||
schema.filter(
|
||||
(i) =>
|
||||
i.kind !== 'image_list' &&
|
||||
i.kind !== 'video_list' &&
|
||||
i.kind !== 'audio_list',
|
||||
),
|
||||
);
|
||||
const hasH3Hint = $derived(
|
||||
listEntries.some((e) => e.media_hint === 'minimax_h3_ref2va'),
|
||||
);
|
||||
const outputKind = $derived<'image' | 'video'>(
|
||||
wf?.kind === 'video' ? 'video' : 'image',
|
||||
);
|
||||
|
||||
const testsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['workflow-tests', wf?.id ?? 0],
|
||||
queryFn: () => workflows.tests(wf!.id),
|
||||
enabled: !!wf && open,
|
||||
refetchInterval: 2000,
|
||||
})),
|
||||
);
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs'],
|
||||
queryFn: () => jobsApi.list(),
|
||||
enabled: !!wf && open,
|
||||
refetchInterval: 5000,
|
||||
})),
|
||||
);
|
||||
const queuedCount = $derived(
|
||||
($jobsQuery.data ?? []).filter(
|
||||
(j) => j.status === 'pending' || j.status === 'running',
|
||||
).length,
|
||||
);
|
||||
|
||||
const history = $derived(($testsQuery.data ?? []) as Job[]);
|
||||
const activeJob = $derived.by(() => {
|
||||
if (testJobId != null) {
|
||||
const mine = history.find((j) => j.id === testJobId);
|
||||
if (mine && (mine.status === 'pending' || mine.status === 'running'))
|
||||
return mine;
|
||||
}
|
||||
return (
|
||||
history.find((j) => j.status === 'pending' || j.status === 'running') ??
|
||||
null
|
||||
);
|
||||
});
|
||||
const finishedJob = $derived.by(() => {
|
||||
if (testJobId != null)
|
||||
return history.find((j) => j.id === testJobId) ?? null;
|
||||
return null;
|
||||
});
|
||||
|
||||
const queueBusy = $derived(!!activeJob || submitting);
|
||||
const liveProgress = $derived(
|
||||
activeJob ? progressFor(activeJob.id) : undefined,
|
||||
);
|
||||
const activeInfo = $derived(activeJob ? jobStatusInfo(activeJob) : null);
|
||||
|
||||
// Rolling mono stage log (§3.8): one line per distinct worker message.
|
||||
let stageLog = $state<string[]>([]);
|
||||
$effect(() => {
|
||||
if (!activeJob || !activeInfo) {
|
||||
stageLog = [];
|
||||
return;
|
||||
}
|
||||
const line = liveProgress?.message || activeInfo.detail;
|
||||
if (!line) return;
|
||||
if (stageLog[stageLog.length - 1] !== line) stageLog = [...stageLog, line];
|
||||
});
|
||||
$effect(() => {
|
||||
stageLog.length;
|
||||
if (logBox) logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open || !wf) return;
|
||||
endpointId = wf.endpoint_id ?? '';
|
||||
values = Object.fromEntries(
|
||||
schema
|
||||
.filter((i) => i.defaultValue !== undefined && i.defaultValue !== null)
|
||||
.map((i) => [i.nodeId, i.defaultValue as string | number]),
|
||||
);
|
||||
listValues = {};
|
||||
sizes = {};
|
||||
rejects = {};
|
||||
uploadingName = {};
|
||||
submitError = '';
|
||||
errorExpanded = false;
|
||||
stageLog = [];
|
||||
testJobId =
|
||||
wf.last_test &&
|
||||
(wf.last_test.status === 'pending' || wf.last_test.status === 'running')
|
||||
? wf.last_test.id
|
||||
: null;
|
||||
});
|
||||
|
||||
function kindOf(entry: { kind: string }): MediaSlotKind {
|
||||
if (entry.kind === 'video_list') return 'video';
|
||||
if (entry.kind === 'audio_list') return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function unitOf(kind: MediaSlotKind): string {
|
||||
return kind === 'image' ? '张' : '段';
|
||||
}
|
||||
|
||||
function setRejects(key: string, next: { name: string; message: string }[]) {
|
||||
rejects = { ...rejects, [key]: next };
|
||||
}
|
||||
|
||||
async function uploadOne(file: File): Promise<string | null> {
|
||||
const res = await playgroundApi.upload(file);
|
||||
sizes = { ...sizes, [res.path]: file.size };
|
||||
return res.path;
|
||||
}
|
||||
|
||||
async function mediaAdd(entry: (typeof listEntries)[number], files: File[]) {
|
||||
const kind = kindOf(entry);
|
||||
const key = entry.nodeId;
|
||||
const max = entry.max ?? 9;
|
||||
const current = listValues[key] ?? [];
|
||||
const ok: string[] = [];
|
||||
const bad: { name: string; message: string }[] = [];
|
||||
for (const f of files) {
|
||||
const problem = mediaFileProblem(kind, f);
|
||||
if (problem) bad.push({ name: f.name, message: problem });
|
||||
else ok.push(f.name);
|
||||
}
|
||||
if (current.length + ok.length > max) {
|
||||
toast.error(
|
||||
t('studio.errRefMax', { max, n: current.length, k: ok.length }),
|
||||
);
|
||||
if (bad.length) setRejects(key, [...(rejects[key] ?? []), ...bad]);
|
||||
return;
|
||||
}
|
||||
setRejects(key, [...(rejects[key] ?? []), ...bad]);
|
||||
for (const f of files) {
|
||||
if (!ok.includes(f.name)) continue;
|
||||
uploadingName = { ...uploadingName, [key]: f.name };
|
||||
try {
|
||||
const path = await uploadOne(f);
|
||||
if (path)
|
||||
listValues = {
|
||||
...listValues,
|
||||
[key]: [...(listValues[key] ?? []), path],
|
||||
};
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
uploadingName = { ...uploadingName, [key]: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mediaReplace(
|
||||
entry: (typeof listEntries)[number],
|
||||
index: number,
|
||||
file: File,
|
||||
) {
|
||||
const kind = kindOf(entry);
|
||||
const key = entry.nodeId;
|
||||
const problem = mediaFileProblem(kind, file);
|
||||
if (problem) {
|
||||
setRejects(key, [
|
||||
...(rejects[key] ?? []),
|
||||
{ name: file.name, message: problem },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
uploadingName = { ...uploadingName, [key]: file.name };
|
||||
try {
|
||||
const path = await uploadOne(file);
|
||||
if (path) {
|
||||
const next = [...(listValues[key] ?? [])];
|
||||
next[index] = path;
|
||||
listValues = { ...listValues, [key]: next };
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
uploadingName = { ...uploadingName, [key]: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function mediaRemove(entry: (typeof listEntries)[number], index: number) {
|
||||
const key = entry.nodeId;
|
||||
listValues = {
|
||||
...listValues,
|
||||
[key]: (listValues[key] ?? []).filter((_, i) => i !== index),
|
||||
};
|
||||
}
|
||||
|
||||
/** Mirrors the backend's validate_input_values for immediate feedback. */
|
||||
function validate(): string[] {
|
||||
const errs: string[] = [];
|
||||
for (const e of schema) {
|
||||
const label = e.label;
|
||||
if (
|
||||
e.kind === 'image_list' ||
|
||||
e.kind === 'video_list' ||
|
||||
e.kind === 'audio_list'
|
||||
) {
|
||||
const n = (listValues[e.nodeId] ?? []).length;
|
||||
const unit = unitOf(kindOf(e));
|
||||
if (e.required && n < (e.min ?? 1))
|
||||
errs.push(t('wft.errListMin', { n: e.min ?? 1, unit, label }));
|
||||
} else if (e.auto && e.required && e.role === 'prompt') {
|
||||
const v = values[e.nodeId];
|
||||
if (
|
||||
!(typeof v === 'string' && v.trim()) &&
|
||||
!String(e.defaultValue ?? '').trim()
|
||||
) {
|
||||
errs.push(t('wft.errRequired', { label }));
|
||||
}
|
||||
} else if (
|
||||
e.required &&
|
||||
(e.kind === 'image' || e.kind === 'video' || e.kind === 'audio') &&
|
||||
e.role !== 'mask'
|
||||
) {
|
||||
if (!values[e.nodeId]) errs.push(t('wft.errRequired', { label }));
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
async function submitTest() {
|
||||
if (!wf) return;
|
||||
const errs = validate();
|
||||
if (errs.length) {
|
||||
submitError = errs.join(';');
|
||||
return;
|
||||
}
|
||||
submitError = '';
|
||||
confirmOpen = true;
|
||||
}
|
||||
|
||||
async function reallyRunTest() {
|
||||
if (!wf) return;
|
||||
submitting = true;
|
||||
try {
|
||||
const inputValues: Record<string, unknown> = { ...values };
|
||||
for (const [k, v] of Object.entries(listValues)) {
|
||||
if (v.length) inputValues[k] = v;
|
||||
}
|
||||
const res = await workflows.test(wf.id, {
|
||||
...(endpointId ? { endpoint_id: endpointId } : {}),
|
||||
input_values: inputValues as Record<string, string | number | string[]>,
|
||||
});
|
||||
testJobId = res.job.id;
|
||||
if (res.reused) toast.info(t('wft.reusedToast'));
|
||||
void $testsQuery.refetch();
|
||||
} catch (err) {
|
||||
submitError = apiErrorMessage(err);
|
||||
} finally {
|
||||
submitting = false;
|
||||
confirmOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelTest() {
|
||||
if (!activeJob) return;
|
||||
try {
|
||||
await jobsApi.cancel(activeJob.id);
|
||||
void $testsQuery.refetch();
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function clearRecords() {
|
||||
if (!wf) return;
|
||||
try {
|
||||
await workflows.clearTests(wf.id);
|
||||
testJobId = null;
|
||||
await $testsQuery.refetch();
|
||||
onchanged?.();
|
||||
toast.success(t('wft.cleared'));
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
function resultLine(job: Job): string {
|
||||
if (job.status === 'done' && (job.output_paths?.length ?? 0) > 0) {
|
||||
return t('wft.passedShort', { n: job.output_paths.length });
|
||||
}
|
||||
if (job.error === 'cancelled') return t('wft.cancelledShort');
|
||||
return job.error ? job.error.slice(0, 120) : t('wft.failedShort');
|
||||
}
|
||||
|
||||
function timeOf(at: string | null): string {
|
||||
if (!at) return '—';
|
||||
const ts = Date.parse(at.replace(' ', 'T') + 'Z');
|
||||
const d = new Date(Number.isFinite(ts) ? ts : at);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const endpointLabel = $derived(
|
||||
endpoints.find((e) => e.id === endpointId)?.name ??
|
||||
t('conn.wfConnectionActive'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<Drawer bind:open title={t('wft.title')} width={420}>
|
||||
{#if wf}
|
||||
<div class="wft">
|
||||
<p class="wft-sub">
|
||||
{wf.name}
|
||||
<span class="mono wft-id">#{wf.id}</span>
|
||||
</p>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">{t('wft.connection')}</span>
|
||||
<select
|
||||
class="a-select full"
|
||||
bind:value={endpointId}
|
||||
disabled={queueBusy}
|
||||
>
|
||||
<option value="">{t('conn.wfConnectionActive')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="field-hint">{t('wft.costNote')}</span>
|
||||
</label>
|
||||
|
||||
{#if hasH3Hint}
|
||||
<div class="wft-note" role="note">
|
||||
<Icon name="info" size={14} />
|
||||
<p>{t('wfMedia.h3Limits')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeJob && activeInfo}
|
||||
<div class="wft-active">
|
||||
<div class="wft-active-head">
|
||||
<span class="chip run">
|
||||
<span class="chip-dot" aria-hidden="true"></span>
|
||||
{t('wft.running')}
|
||||
</span>
|
||||
<span class="wft-active-detail">{activeInfo.detail}</span>
|
||||
<Button variant="secondary" size="sm" onclick={cancelTest}
|
||||
>{t('wft.cancel')}</Button
|
||||
>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={liveProgress?.progress ?? 0}
|
||||
indeterminate={activeJob.status === 'pending' && !liveProgress}
|
||||
label={activeInfo.label}
|
||||
/>
|
||||
{#if stageLog.length}
|
||||
<div class="wft-log mono" bind:this={logBox} aria-live="polite">
|
||||
{#each stageLog as line, i (i)}
|
||||
<p>{line}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if finishedJob && finishedJob.status === 'done' && (finishedJob.output_paths?.length ?? 0) > 0}
|
||||
<div class="wft-result ok">
|
||||
<p class="wft-verdict">
|
||||
{t('wft.passed', { n: finishedJob.output_paths.length })}
|
||||
</p>
|
||||
<div class="wft-outputs">
|
||||
{#each finishedJob.output_paths as path (path)}
|
||||
<a
|
||||
class="wft-out"
|
||||
href={assetUrl(path)}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t('wft.download')}
|
||||
>
|
||||
<SafeMedia
|
||||
kind={outputKind}
|
||||
src={assetUrl(path)}
|
||||
controls={outputKind === 'video'}
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if finishedJob && finishedJob.status !== 'pending' && finishedJob.status !== 'running'}
|
||||
<div class="wft-result bad">
|
||||
<p class="wft-verdict">{t('wft.failed')}</p>
|
||||
<p class="wft-error">
|
||||
{finishedJob.error === 'cancelled'
|
||||
? t('wft.cancelledShort')
|
||||
: errorExpanded || (finishedJob.error ?? '').length <= 160
|
||||
? finishedJob.error
|
||||
: (finishedJob.error ?? '').slice(0, 160) + '…'}
|
||||
</p>
|
||||
{#if finishedJob.error && finishedJob.error !== 'cancelled' && finishedJob.error.length > 160}
|
||||
<button
|
||||
type="button"
|
||||
class="wft-expand"
|
||||
onclick={() => (errorExpanded = !errorExpanded)}
|
||||
>
|
||||
{errorExpanded ? t('wfd.hideDetails') : t('wfd.showDetails')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !queueBusy}
|
||||
<div class="wft-form">
|
||||
{#each listEntries as entry (entry.nodeId)}
|
||||
{@const kind = kindOf(entry)}
|
||||
<RefImageGrid
|
||||
label={entry.label}
|
||||
mediaKind={kind}
|
||||
paths={listValues[entry.nodeId] ?? []}
|
||||
max={entry.max ?? 9}
|
||||
{sizes}
|
||||
uploadingName={uploadingName[entry.nodeId] || undefined}
|
||||
rejects={rejects[entry.nodeId] ?? []}
|
||||
onAdd={(files) => mediaAdd(entry, files)}
|
||||
onReplace={(i, file) => mediaReplace(entry, i, file)}
|
||||
onRemove={(i) => mediaRemove(entry, i)}
|
||||
onDismissReject={(i) =>
|
||||
setRejects(
|
||||
entry.nodeId,
|
||||
(rejects[entry.nodeId] ?? []).filter((_, j) => j !== i),
|
||||
)}
|
||||
/>
|
||||
{/each}
|
||||
{#if formEntries.length}
|
||||
<ComfyDynamicForm
|
||||
inputs={formEntries}
|
||||
bind:values
|
||||
allowUpload
|
||||
showErrors={!!submitError}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if submitError}<span class="field-error" role="alert"
|
||||
>{submitError}</span
|
||||
>{/if}
|
||||
<div class="wft-actions">
|
||||
<Button variant="primary" loading={submitting} onclick={submitTest}>
|
||||
{t('wft.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="wft-history">
|
||||
<div class="wft-history-head">
|
||||
<span class="field-label"
|
||||
>{t('wft.history', { n: history.length })}</span
|
||||
>
|
||||
{#if history.length}
|
||||
<button type="button" class="link-danger" onclick={clearRecords}>
|
||||
{t('wft.clear')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if history.length === 0}
|
||||
<p class="wft-empty">{t('wft.never')}</p>
|
||||
{:else}
|
||||
<ul class="wft-rows">
|
||||
{#each history as job (job.id)}
|
||||
<li class:active={job.id === activeJob?.id}>
|
||||
<span class="mono wft-row-id">#{job.id}</span>
|
||||
<span class="wft-row-time">{timeOf(job.created_at)}</span>
|
||||
<span
|
||||
class="wft-row-result"
|
||||
class:ok={job.status === 'done' &&
|
||||
(job.output_paths?.length ?? 0) > 0}
|
||||
>
|
||||
{resultLine(job)}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Drawer>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={confirmOpen}
|
||||
title={t('wft.confirmTitle')}
|
||||
message={t('wft.confirmBody', { endpoint: endpointLabel, n: queuedCount })}
|
||||
confirmLabel={t('wft.submit')}
|
||||
onconfirm={reallyRunTest}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.wft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wft-sub {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-id {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.a-select.full {
|
||||
width: 100%;
|
||||
}
|
||||
.wft-note {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--run-soft);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--run-soft);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wft-note :global(.icon) {
|
||||
color: var(--run);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.wft-note p {
|
||||
margin: 0;
|
||||
}
|
||||
.wft-active,
|
||||
.wft-result {
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-active {
|
||||
border-color: var(--run-soft);
|
||||
}
|
||||
.wft-active-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip.run {
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.chip-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--run);
|
||||
animation: wft-breathe 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes wft-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
:global([data-reduce='1']) .chip-dot {
|
||||
animation: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chip-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.wft-active-detail {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-log {
|
||||
max-height: 110px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wft-log p {
|
||||
margin: 0;
|
||||
}
|
||||
.wft-result.ok {
|
||||
border-color: var(--ok);
|
||||
}
|
||||
.wft-result.bad {
|
||||
border-color: var(--err);
|
||||
}
|
||||
.wft-verdict {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.wft-result.ok .wft-verdict {
|
||||
color: var(--ok);
|
||||
}
|
||||
.wft-result.bad .wft-verdict {
|
||||
color: var(--err);
|
||||
}
|
||||
.wft-error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--err);
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
.wft-expand {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.wft-expand:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
.wft-outputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.wft-out {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
width: 148px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.wft-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.wft-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-history {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 10px;
|
||||
}
|
||||
.wft-history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.wft-history-head .field-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.link-danger {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--err);
|
||||
cursor: pointer;
|
||||
border-radius: var(--r-sm);
|
||||
opacity: 0.85;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.link-danger:hover {
|
||||
opacity: 1;
|
||||
background: var(--err-soft);
|
||||
}
|
||||
}
|
||||
.wft-empty {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wft-rows {
|
||||
list-style: none;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.wft-rows li {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.wft-rows li.active {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.wft-row-id {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.wft-row-time {
|
||||
color: var(--text-3);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wft-row-result {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wft-row-result.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
</style>
|
||||
@@ -357,16 +357,13 @@
|
||||
.empty-mark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--r-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
background:
|
||||
radial-gradient(circle at 30% 25%, rgba(139, 92, 246, 0.35) 0%, transparent 60%),
|
||||
var(--bg-elevated);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
box-shadow: 0 0 32px var(--accent-glow);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.empty-title {
|
||||
@@ -391,21 +388,24 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
.suggestion {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-2);
|
||||
border-radius: var(--r-pill);
|
||||
padding: var(--sp-3) var(--sp-6);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
transition:
|
||||
color var(--d-state) ease,
|
||||
border-color var(--d-state) ease,
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.suggestion:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface));
|
||||
color: var(--text);
|
||||
border-color: var(--line-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.suggestion:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -430,11 +430,11 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-size: var(--fs-mini);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--agent, var(--accent));
|
||||
color: var(--agent, var(--text-2));
|
||||
margin-bottom: 4px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
@@ -443,7 +443,7 @@
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--accent)) 25%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent, var(--text-2)) 25%, transparent);
|
||||
}
|
||||
.reasoning-wrap {
|
||||
margin-bottom: 4px;
|
||||
@@ -451,28 +451,29 @@
|
||||
}
|
||||
.bubble {
|
||||
max-width: 86%;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 10px 14px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* 对话气泡按规范 §3.2/§3.6:用户 --bg-3,Agent --bg-2 + --line 描边。 */
|
||||
.user-bubble {
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--bg-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
.user-bubble.steering {
|
||||
border-style: dashed;
|
||||
}
|
||||
.steer-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-size: var(--fs-mini);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
color: var(--text-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.user-thumbs {
|
||||
@@ -486,36 +487,37 @@
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.user-thumb.file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.user-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
/* 工作流提及为次级内容:中性底 + --text,不占金色。 */
|
||||
.wf-chip {
|
||||
display: inline;
|
||||
padding: 1px 7px;
|
||||
margin: 0 1px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg-elevated));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
color: var(--accent);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-4);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.assistant-bubble {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--agent, var(--accent));
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--agent, var(--text-2));
|
||||
color: var(--text);
|
||||
}
|
||||
.question-card {
|
||||
display: flex;
|
||||
@@ -525,15 +527,15 @@
|
||||
max-width: 420px;
|
||||
margin: 2px 0 6px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--accent) 7%, var(--bg-surface));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.question-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.question-options {
|
||||
@@ -542,40 +544,39 @@
|
||||
gap: 6px;
|
||||
}
|
||||
.question-option {
|
||||
border: 1px solid color-mix(in srgb, var(--agent, var(--accent)) 45%, transparent);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-3);
|
||||
color: var(--text);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease;
|
||||
background-color var(--d-press) ease,
|
||||
border-color var(--d-press) ease;
|
||||
}
|
||||
.question-option:hover:enabled {
|
||||
background: color-mix(in srgb, var(--agent, var(--accent)) 18%, var(--bg-elevated));
|
||||
border-color: var(--agent, var(--accent));
|
||||
background: var(--bg-4);
|
||||
border-color: var(--agent, var(--text-2));
|
||||
}
|
||||
.question-option:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.question-option.affirmative {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.question-hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.assistant-bubble.err {
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
border-left-color: rgba(239, 68, 68, 0.6);
|
||||
color: var(--error);
|
||||
border-color: var(--err-line);
|
||||
border-left-color: var(--err);
|
||||
color: var(--err);
|
||||
}
|
||||
.bubble.live {
|
||||
display: flex;
|
||||
@@ -604,7 +605,7 @@
|
||||
width: 7px;
|
||||
height: 14px;
|
||||
margin-left: 2px;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: blink 1s step-end infinite;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
@@ -617,14 +618,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
/* 进行中指示 = 信号青(§1.3),不占金色。 */
|
||||
.pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -711,12 +711,12 @@ let {
|
||||
display: inline;
|
||||
padding: 1px 7px;
|
||||
margin: 0 1px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 22%, var(--bg-elevated));
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
color: var(--accent);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-4);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -724,12 +724,12 @@ let {
|
||||
display: inline;
|
||||
padding: 1px 7px;
|
||||
margin: 0 1px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, #38bdf8 20%, var(--bg-elevated));
|
||||
border: 1px solid color-mix(in srgb, #38bdf8 45%, transparent);
|
||||
color: #7dd3fc;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-4);
|
||||
border: 1px dashed var(--line-2);
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
}
|
||||
.head:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
.head:hover {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.icon-wrap {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -135,9 +135,7 @@
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: var(--shadow-float);
|
||||
padding: 4px;
|
||||
}
|
||||
.empty {
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
/**
|
||||
* Agent identity palette: a stable display name + accent color per agent.
|
||||
*
|
||||
* The main loop runs with `agent_name = null`; we label it "Calliope". Each
|
||||
* swarm sub-agent ("planner", "story-agent", …) gets its own fixed color so
|
||||
* differently-authored bubbles are visually distinct, plus a deterministic
|
||||
* fallback for any future agent name.
|
||||
* Identity colors answer "who is speaking" only (avatar / name / bubble
|
||||
* tint) — they never express task status (spec v2 §3.6). The curated set
|
||||
* reuses the spec §3.6 avatar five-color family plus one neutral slate, so
|
||||
* no tailwind rainbow hues leak into the fusion palette; the default
|
||||
* main-loop agent is neutral `--text-2` slate.
|
||||
*/
|
||||
|
||||
export const DEFAULT_AGENT_NAME = 'Calliope';
|
||||
|
||||
// Fixed identity map (keyed by the raw agent_name the backend emits).
|
||||
const IDENTITIES: Record<string, { label: string; color: string }> = {
|
||||
planner: { label: 'Planner', color: '#22d3ee' }, // cyan
|
||||
'story-agent': { label: 'Story Agent', color: '#22c55e' }, // green
|
||||
'script-agent': { label: 'Script Agent', color: '#f59e0b' }, // amber
|
||||
'assets-agent': { label: 'Assets Agent', color: '#ec4899' }, // pink
|
||||
'video-agent': { label: 'Video Agent', color: '#fb923c' }, // orange
|
||||
'research-agent': { label: 'Research Agent', color: '#a78bfa' }, // violet
|
||||
planner: { label: 'Planner', color: '#5b8def' }, // avatar-2 blue
|
||||
'story-agent': { label: 'Story Agent', color: '#3fb97f' }, // avatar-3 green
|
||||
'script-agent': { label: 'Script Agent', color: '#e8a33d' }, // avatar-1 amber
|
||||
'assets-agent': { label: 'Assets Agent', color: '#b98be0' }, // avatar-4 violet
|
||||
'video-agent': { label: 'Video Agent', color: '#d98e6a' }, // avatar-5 clay
|
||||
'research-agent': { label: 'Research Agent', color: '#8fa3c8' }, // neutral slate
|
||||
// The five named agents (HEL-613) — the runner stamps their zh names on
|
||||
// named-session replies; colors mirror the spec §2.1 avatar five-set.
|
||||
剧本创作: { label: '剧本创作', color: '#e8a33d' },
|
||||
@@ -26,19 +27,9 @@ const IDENTITIES: Record<string, { label: string; color: string }> = {
|
||||
镜头脚本: { label: '镜头脚本', color: '#d98e6a' },
|
||||
};
|
||||
|
||||
// Fallback palette for unknown names (stable hash → deterministic color).
|
||||
const FALLBACK_COLORS = [
|
||||
'#22d3ee',
|
||||
'#22c55e',
|
||||
'#f59e0b',
|
||||
'#ec4899',
|
||||
'#fb923c',
|
||||
'#a78bfa',
|
||||
'#38bdf8',
|
||||
'#4ade80',
|
||||
'#f472b6',
|
||||
'#facc15',
|
||||
];
|
||||
// Fallback palette for unknown names (stable hash → deterministic color),
|
||||
// narrowed to the avatar identity family + neutral slate (spec v2 §十.4).
|
||||
const FALLBACK_COLORS = ['#5b8def', '#3fb97f', '#e8a33d', '#b98be0', '#d98e6a', '#8fa3c8'];
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 0;
|
||||
@@ -48,8 +39,8 @@ function hashString(s: string): number {
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The default "Calliope" accent (matches --accent). */
|
||||
export const DEFAULT_AGENT_COLOR = '#8b5cf6';
|
||||
/** Neutral slate (--text-2) — the default agent borrows no signal color. */
|
||||
export const DEFAULT_AGENT_COLOR = '#aab2c5';
|
||||
|
||||
export function agentDisplayName(name: string | null | undefined): string {
|
||||
const key = (name || '').trim().toLowerCase();
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import ParamChip from './ParamChip.svelte';
|
||||
import SlotTray from './SlotTray.svelte';
|
||||
import WorkflowPicker from './WorkflowPicker.svelte';
|
||||
import { chipValue, type BlockReason, type DeskSchema, type FormValue, type FormValues } from './schema';
|
||||
|
||||
/**
|
||||
* 输入台 (spec-v2 §3.3): slots from the workflow JSON, the prompt, and one
|
||||
* row of chips — workflow, connection, common params, 参数 — ending in
|
||||
* 生成 with its reason line. The whole composer takes dropped files.
|
||||
* ≤760px it collapses to one line and rises as a bottom sheet on focus.
|
||||
*/
|
||||
interface Props {
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
workflows: Workflow[];
|
||||
current: Workflow | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
endpointId: string;
|
||||
conn: { state: 'ok' | 'fail' | 'untested' | 'dry'; ms?: number | null };
|
||||
errors: Record<string, string>;
|
||||
showErrors: boolean;
|
||||
block: BlockReason | null;
|
||||
changedCount: number;
|
||||
submitting: boolean;
|
||||
/** Shows 已提交 on the button for --d-flash. */
|
||||
sent: boolean;
|
||||
slotBusy?: boolean;
|
||||
promptEl?: HTMLTextAreaElement | null;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onPickWorkflow: (wf: Workflow) => void;
|
||||
onPickEndpoint: (id: string) => void;
|
||||
onSubmit: () => void;
|
||||
onReveal: () => void;
|
||||
onOpenParams: () => void;
|
||||
onFromResults: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
ds,
|
||||
values,
|
||||
workflows,
|
||||
current,
|
||||
endpoints,
|
||||
endpointId,
|
||||
conn,
|
||||
errors,
|
||||
showErrors,
|
||||
block,
|
||||
changedCount,
|
||||
submitting,
|
||||
sent,
|
||||
slotBusy = $bindable(false),
|
||||
promptEl = $bindable(null),
|
||||
onSet,
|
||||
onPickWorkflow,
|
||||
onPickEndpoint,
|
||||
onSubmit,
|
||||
onReveal,
|
||||
onOpenParams,
|
||||
onFromResults,
|
||||
}: Props = $props();
|
||||
|
||||
let tray = $state<ReturnType<typeof SlotTray> | null>(null);
|
||||
let root = $state<HTMLElement | null>(null);
|
||||
let dragDepth = $state(0);
|
||||
let expanded = $state(false);
|
||||
let connOpen = $state(false);
|
||||
|
||||
const hasSlots = $derived(ds.slots.length + ds.lists.length > 0);
|
||||
const promptVal = $derived(ds.prompt ? String(values[ds.prompt.nodeId] ?? '') : '');
|
||||
const promptErr = $derived(showErrors && ds.prompt ? errors[ds.prompt.nodeId] : undefined);
|
||||
const endpointName = $derived(endpoints.find((e) => e.id === endpointId)?.name ?? t('desk.noConn'));
|
||||
|
||||
/** Composer chips: ratio, the width × height pair, resolution, duration, seed. */
|
||||
const chips = $derived.by(() => {
|
||||
const out: Array<{ key: string; inputs: ComfyDynamicInput[]; text: string }> = [];
|
||||
if (ds.ratio) out.push({ key: ds.ratio.nodeId, inputs: [ds.ratio], text: chipValue(ds.ratio, values, ds) });
|
||||
if (ds.width && ds.height) {
|
||||
const w = values[ds.width.nodeId] ?? ds.width.defaultValue ?? '?';
|
||||
const h = values[ds.height.nodeId] ?? ds.height.defaultValue ?? '?';
|
||||
out.push({ key: ds.width.nodeId, inputs: [ds.width, ds.height], text: `${w}×${h}` });
|
||||
} else {
|
||||
for (const inp of [ds.width, ds.height]) {
|
||||
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: `${inp.label} ${chipValue(inp, values, ds)}` });
|
||||
}
|
||||
}
|
||||
for (const inp of [ds.resolution, ds.duration, ds.seed]) {
|
||||
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: chipValue(inp, values, ds) });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragDepth = 0;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) tray?.dropFiles(files);
|
||||
}
|
||||
|
||||
function hasFiles(e: DragEvent): boolean {
|
||||
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
||||
}
|
||||
|
||||
function onFocusOut(e: FocusEvent) {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!next || !root?.contains(next)) expanded = false;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && expanded && !e.defaultPrevented) {
|
||||
expanded = false;
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="composer-wrap">
|
||||
<div class="a-sprocket" aria-hidden="true"></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={root}
|
||||
class="composer"
|
||||
class:drag={dragDepth > 0}
|
||||
class:expanded
|
||||
onfocusin={() => (expanded = true)}
|
||||
onfocusout={onFocusOut}
|
||||
onkeydown={onKeydown}
|
||||
ondragenter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
}}
|
||||
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
|
||||
ondragover={(e) => hasFiles(e) && e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if hasSlots}
|
||||
<div class="slots">
|
||||
<SlotTray bind:this={tray} bind:busy={slotBusy} {ds} {values} {showErrors} {errors} {onSet} {onFromResults} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ds.prompt}
|
||||
<textarea
|
||||
bind:this={promptEl}
|
||||
id="desk-prompt"
|
||||
class="prompt"
|
||||
class:invalid={!!promptErr}
|
||||
rows="2"
|
||||
value={promptVal}
|
||||
placeholder={t('desk.promptPh')}
|
||||
aria-label={t('studio.promptLabel')}
|
||||
aria-invalid={!!promptErr || undefined}
|
||||
aria-keyshortcuts="Control+Enter Meta+Enter /"
|
||||
oninput={(e) => onSet(ds.prompt!.nodeId, e.currentTarget.value)}
|
||||
></textarea>
|
||||
{#if promptErr}<p class="a-slot-hint err prompt-err" role="alert">{promptErr}</p>{/if}
|
||||
{:else if current}
|
||||
<p class="no-prompt">{t('desk.noPromptInput')}</p>
|
||||
{/if}
|
||||
|
||||
<div class="foot">
|
||||
<WorkflowPicker {workflows} {current} {endpoints} onPick={onPickWorkflow} />
|
||||
<div class="conn opt">
|
||||
<button
|
||||
type="button"
|
||||
id="desk-conn"
|
||||
class="a-chip"
|
||||
class:err={conn.state === 'fail'}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={connOpen}
|
||||
title={t('studio.connLabel')}
|
||||
onclick={() => (connOpen = !connOpen)}
|
||||
>
|
||||
<span class="status-chip-dot {conn.state === 'ok' ? 'run' : conn.state === 'fail' ? 'fail' : 'idle'}"></span>
|
||||
<span class="lbl">{endpointName}</span>
|
||||
{#if conn.state === 'ok' && conn.ms != null}<span class="mono">· {Math.round(conn.ms)} ms</span>{/if}
|
||||
{#if conn.state === 'dry'}<span>· {t('desk.dryRun')}</span>{/if}
|
||||
</button>
|
||||
<ShellPop bind:open={connOpen} label={t('studio.connLabel')} placement="top" width="280px">
|
||||
<div class="a-menu-group">{t('studio.connLabel')}</div>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item"
|
||||
aria-current={ep.id === endpointId ? 'true' : undefined}
|
||||
onclick={() => {
|
||||
onPickEndpoint(ep.id);
|
||||
connOpen = false;
|
||||
}}
|
||||
>
|
||||
<span class="a-menu-label">{ep.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<a class="a-menu-item" href="/connections">
|
||||
<Icon name="link" size={15} />
|
||||
<span class="a-menu-label">{t('studio.connManage')}</span>
|
||||
</a>
|
||||
</ShellPop>
|
||||
</div>
|
||||
{#each chips as c (c.key)}
|
||||
<div class="opt">
|
||||
<ParamChip
|
||||
inputs={c.inputs}
|
||||
{values}
|
||||
text={c.text}
|
||||
invalid={showErrors && c.inputs.some((i) => !!errors[i.nodeId]?.trim())}
|
||||
{onSet}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if ds.common.length + ds.advanced.length > 0}
|
||||
<button type="button" class="a-chip opt" class:changed={changedCount > 0} onclick={onOpenParams}>
|
||||
<Icon name="settings" size={14} />
|
||||
<span class="lbl">
|
||||
{changedCount > 0 ? t('desk.paramsChanged', { n: changedCount }) : t('desk.paramsAll')}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="submit">
|
||||
{#if block}
|
||||
<button type="button" class="why" class:err={block.tone === 'err'} onclick={onReveal}>
|
||||
{block.text}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn primary gen"
|
||||
class:sent
|
||||
disabled={!!block || submitting || slotBusy}
|
||||
aria-keyshortcuts="Control+Enter Meta+Enter"
|
||||
title={t('desk.genHint')}
|
||||
onclick={onSubmit}
|
||||
>
|
||||
<span class="l-idle"><Icon name="sparkle" size={13} />{t('desk.generate')}</span>
|
||||
<span class="l-sent" aria-hidden={!sent}><Icon name="check" size={13} />{t('desk.sent')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.composer-wrap {
|
||||
padding: var(--sp-2) var(--sp-7) var(--sp-6);
|
||||
}
|
||||
.a-sprocket {
|
||||
margin: 0 var(--sp-2) var(--sp-3);
|
||||
}
|
||||
.composer {
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--composer-bg);
|
||||
transition:
|
||||
border-color var(--d-state) ease,
|
||||
box-shadow var(--d-state) ease;
|
||||
}
|
||||
.composer:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: var(--composer-focus);
|
||||
}
|
||||
.composer.drag {
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.slots {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.prompt {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
max-height: 180px;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-2);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
field-sizing: content;
|
||||
}
|
||||
.prompt:focus-visible {
|
||||
outline: 0;
|
||||
}
|
||||
.prompt::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.prompt-err,
|
||||
.no-prompt {
|
||||
margin: 0;
|
||||
padding: 0 var(--sp-6);
|
||||
}
|
||||
.no-prompt {
|
||||
padding-top: var(--sp-5);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3) var(--sp-3);
|
||||
}
|
||||
.conn {
|
||||
position: relative;
|
||||
}
|
||||
.submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
margin-left: auto;
|
||||
}
|
||||
.why {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--warn);
|
||||
white-space: nowrap;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.why.err {
|
||||
color: var(--err);
|
||||
}
|
||||
/* 生成:按下即缩,提交后文字换成「已提交」约 --d-flash,期间仍可再次提交 */
|
||||
.gen {
|
||||
display: inline-grid;
|
||||
min-width: 84px;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.gen > span {
|
||||
grid-area: 1 / 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-1);
|
||||
transition:
|
||||
opacity var(--d-state) ease,
|
||||
transform var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.gen .l-sent {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.gen.sent .l-idle {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
.gen.sent .l-sent {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
:global(:root[data-reduce='1']) .gen > span {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1179px) {
|
||||
.composer-wrap {
|
||||
padding: var(--sp-2) var(--sp-6) var(--sp-5);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.composer-wrap {
|
||||
padding: var(--sp-1) var(--sp-4) var(--sp-4);
|
||||
}
|
||||
.composer:not(.expanded) .slots,
|
||||
.composer:not(.expanded) .opt {
|
||||
display: none;
|
||||
}
|
||||
.composer:not(.expanded) .prompt {
|
||||
min-height: 40px;
|
||||
max-height: 40px;
|
||||
padding: var(--sp-4) var(--sp-5) var(--sp-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.composer.expanded {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
|
||||
z-index: 45;
|
||||
border-bottom: 0;
|
||||
border-radius: var(--r-lg) var(--r-lg) 0 0;
|
||||
box-shadow: var(--shadow-float);
|
||||
max-height: calc(100dvh - var(--topbar-h) - var(--tabbar-h));
|
||||
overflow-y: auto;
|
||||
}
|
||||
.composer.expanded .slots {
|
||||
max-height: none;
|
||||
}
|
||||
.submit {
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { backHref, type DeskContext } from './context';
|
||||
|
||||
/**
|
||||
* 上下文条 (spec-v2 §3.3): the desk was opened from a creation stage.
|
||||
* Says where from, and plainly that takes stay in the desk feed — the
|
||||
* desk's generate call has no shot/asset link to fill back.
|
||||
*/
|
||||
interface Props {
|
||||
ctx: DeskContext;
|
||||
projectTitle: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
let { ctx, projectTitle, onDismiss }: Props = $props();
|
||||
|
||||
const stageName = $derived(t(`workbench.stage.${ctx.stage}`));
|
||||
</script>
|
||||
|
||||
<div class="context-bar" role="status">
|
||||
<Icon name="link" size={14} />
|
||||
<span class="from">
|
||||
<b>{t('desk.ctxFrom', { project: projectTitle })}</b>{#if ctx.label} · {ctx.label}{/if}
|
||||
</span>
|
||||
<span class="note">{t('desk.ctxNoBackfill', { stage: stageName })}</span>
|
||||
<a class="a-btn ghost xs back" href={backHref(ctx)}>{t('desk.ctxBack', { stage: stageName })}</a>
|
||||
<button type="button" class="a-btn ghost xs" aria-label={t('desk.ctxDismiss')} onclick={onDismiss}>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.context-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2) var(--sp-4);
|
||||
margin: var(--sp-4) var(--sp-7) 0;
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--run-soft);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.context-bar > :global(svg) {
|
||||
color: var(--run);
|
||||
}
|
||||
.from b {
|
||||
color: var(--run);
|
||||
font-weight: 600;
|
||||
}
|
||||
.note {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.back {
|
||||
margin-left: auto;
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.context-bar {
|
||||
margin: var(--sp-4) var(--sp-6) 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.context-bar {
|
||||
margin: var(--sp-3) var(--sp-5) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,565 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { exportLastFrame } from '$lib/media/lastFrame';
|
||||
import { usageLabel } from './schema';
|
||||
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf, specOf } from './takes';
|
||||
|
||||
/**
|
||||
* 检视区 (spec-v2 §3.3): 结果 shows the selected take on the projection
|
||||
* stage with its spec, inputs and workflow; 参数 hosts the full form.
|
||||
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
|
||||
* fullscreen — `overlay` / `open` drive the last two.
|
||||
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
|
||||
* ↑↓/J/K 即时无动画), a finished/just-picked video auto-plays muted,
|
||||
* failed takes get 重试, and a done take whose files no longer load
|
||||
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
|
||||
*/
|
||||
interface Props {
|
||||
tab: 'result' | 'params';
|
||||
job: Job | null;
|
||||
wf: Workflow | undefined;
|
||||
nowMs: number;
|
||||
open: boolean;
|
||||
/** Collapsed or off-screen — nothing inside may take focus. */
|
||||
inactive: boolean;
|
||||
/** Current workflow can take an image output (送入当前图片槽). */
|
||||
canSendToSlot: boolean;
|
||||
/** Animate this result change (pointer pick) — 150ms cross-fade. */
|
||||
crossfade: boolean;
|
||||
/** Bump to auto-play the stage video (pointer pick / completion). */
|
||||
playSignal: number;
|
||||
/** The stage <video> element, for the desk's space-bar toggle. */
|
||||
videoEl?: HTMLVideoElement | null;
|
||||
params: Snippet;
|
||||
onClose: () => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onSendToSlot: (path: string) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tab = $bindable(),
|
||||
job,
|
||||
wf,
|
||||
nowMs,
|
||||
open,
|
||||
inactive,
|
||||
canSendToSlot,
|
||||
crossfade,
|
||||
playSignal,
|
||||
videoEl = $bindable(null),
|
||||
params,
|
||||
onClose,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onSendToSlot,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
let outIndex = $state(0);
|
||||
$effect.pre(() => {
|
||||
void job?.id;
|
||||
outIndex = 0;
|
||||
});
|
||||
|
||||
const st = $derived(job ? jobStatusInfo(job) : null);
|
||||
// §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files
|
||||
// cannot be fetched — the failed case stays red.
|
||||
const gonePaths = $derived(missingOutputsOf(job));
|
||||
const fileGone = $derived(gonePaths.length > 0);
|
||||
const outputs = $derived(job && st?.tone === 'ok' ? job.output_paths.filter((p) => !gonePaths.includes(p)) : []);
|
||||
const output = $derived(outputs[Math.min(outIndex, outputs.length - 1)] ?? null);
|
||||
const allOutputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
|
||||
const video = $derived(!!job && !!output && isVideoOutput(job, output));
|
||||
const wfDeleted = $derived(!!job && job.workflow_id == null);
|
||||
const prompt = $derived(job ? promptOf(job, wf) : '');
|
||||
const inputs = $derived(job ? inputsOf(job, wf) : []);
|
||||
const spec = $derived(job ? specOf(job, wf) : []);
|
||||
const endpoint = $derived(
|
||||
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ?? '',
|
||||
);
|
||||
const progress = $derived.by(() => {
|
||||
if (!job || job.status !== 'running') return 0;
|
||||
const live = progressFor(job.id);
|
||||
const phase = live?.phase ?? job.phase;
|
||||
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
|
||||
return live?.progress ?? 3;
|
||||
});
|
||||
let dims = $state('');
|
||||
$effect.pre(() => {
|
||||
void output;
|
||||
dims = '';
|
||||
});
|
||||
function noteImg(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
dims = `${img.naturalWidth}×${img.naturalHeight}`;
|
||||
}
|
||||
function noteVideo(e: Event) {
|
||||
const v = e.currentTarget as HTMLVideoElement;
|
||||
dims = `${v.videoWidth}×${v.videoHeight}`;
|
||||
}
|
||||
|
||||
// 完成或点选后自动播放(静音);键盘切换不触发。
|
||||
$effect(() => {
|
||||
void playSignal;
|
||||
if (inactive || tab !== 'result' || !videoEl) return;
|
||||
videoEl.muted = true;
|
||||
videoEl.play().catch(() => {});
|
||||
});
|
||||
|
||||
function reduced(): boolean {
|
||||
return (
|
||||
document.documentElement.dataset.reduce === '1' ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
}
|
||||
|
||||
/** 150ms 交叉淡变 on pointer picks; keyboard switches stay instant. */
|
||||
function xfade(_node: Element) {
|
||||
const ms = crossfade ? (reduced() ? 120 : 150) : 0;
|
||||
return { duration: ms, css: (t: number) => `opacity: ${t}` };
|
||||
}
|
||||
|
||||
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
|
||||
|
||||
let frameBusy = $state(false);
|
||||
async function exportTail() {
|
||||
const src = assetUrl(output);
|
||||
if (!src || frameBusy) return;
|
||||
frameBusy = true;
|
||||
try {
|
||||
await exportLastFrame(src);
|
||||
} finally {
|
||||
frameBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="inspector" class:open inert={inactive} aria-label={t('desk.inspector')}>
|
||||
<div class="tabs">
|
||||
<div class="a-seg" role="group" aria-label={t('desk.inspector')}>
|
||||
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}>{t('desk.tabResult')}</button>
|
||||
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}>{t('desk.tabParams')}</button>
|
||||
</div>
|
||||
<span class="keys"><span class="a-kbd">]</span>{t('desk.keyInspector')}</span>
|
||||
<button type="button" class="a-btn ghost sm close" aria-label={t('desk.closeInspector')} onclick={onClose}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
{#if tab === 'params'}
|
||||
{@render params()}
|
||||
{:else if !job || !st}
|
||||
<div class="a-stage stage"><div class="stage-empty">{t('desk.pickTake')}</div></div>
|
||||
{:else}
|
||||
<div class="a-stage stage">
|
||||
{#key mediaKey}
|
||||
<div class="stage-media" in:xfade out:xfade>
|
||||
{#if output && video}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
src={assetUrl(output)}
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
></video>
|
||||
{:else if output}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt={prompt || t('desk.tabResult')}
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImg(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
{:else if fileGone}
|
||||
<div class="stage-empty gone">
|
||||
<span>{t('desk.missingTitle')}</span>
|
||||
<span class="sub">{t('desk.missingSub')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stage-empty">
|
||||
<span>{st.label}</span>
|
||||
<span class="sub">{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span>
|
||||
{#if progress > 0}
|
||||
<div class="a-progress stage-bar" style:--p={progress / 100}><i></i></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if allOutputs.length > 1}
|
||||
<div class="strip" role="group" aria-label={t('desk.outputs')}>
|
||||
{#each allOutputs as p, i (p)}
|
||||
<button
|
||||
type="button"
|
||||
class="strip-item"
|
||||
class:gone={gonePaths.includes(p)}
|
||||
aria-pressed={p === output}
|
||||
aria-label={t('desk.outputN', { n: i + 1 })}
|
||||
onclick={() => (outIndex = i)}
|
||||
>
|
||||
{#if isVideoOutput(job, p)}
|
||||
<Icon name="film" size={14} />
|
||||
{:else}
|
||||
<img src={assetUrl(p)} alt="" loading="lazy" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="meta">
|
||||
{#if dims}<span class="mono"><b>{dims}</b></span>{/if}
|
||||
{#each spec as s (s.label)}
|
||||
{#if !(dims && s.label === t('desk.specSize'))}
|
||||
<span>{s.label} <b class="mono">{s.value}</b></span>
|
||||
{/if}
|
||||
{/each}
|
||||
<span>{t('desk.elapsed')} <b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span>
|
||||
{#if endpoint}<span>{endpoint}</span>{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#if fileGone && !outputs.length}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('desk.redo')}</button>
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('desk.removeRecord')}</button>
|
||||
{:else}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}>{t('desk.reuseToComposer')}</button>
|
||||
{#if output && !video && canSendToSlot}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onSendToSlot(output)}>
|
||||
{t('desk.sendToSlot')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if output}
|
||||
<a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a>
|
||||
{/if}
|
||||
{#if output && video}
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn secondary sm"
|
||||
title={t('lastFrame.hint')}
|
||||
disabled={frameBusy}
|
||||
aria-busy={frameBusy}
|
||||
onclick={exportTail}
|
||||
>
|
||||
{frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if st.tone === 'fail'}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button>
|
||||
{/if}
|
||||
{#if job.status !== 'pending' && job.status !== 'running'}
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if st.tone === 'fail'}
|
||||
<div class="note err" role="alert">{st.detail}</div>
|
||||
{:else if fileGone}
|
||||
<div class="note warn" role="alert">{t('jobst.missingDetail')}</div>
|
||||
{/if}
|
||||
|
||||
{#if inputs.length || prompt}
|
||||
<div class="section-title">{t('desk.inputs')}</div>
|
||||
{#if inputs.length}
|
||||
<div class="inputs">
|
||||
{#each inputs as inp (inp.label + inp.path)}
|
||||
{#if inp.kind === 'image'}
|
||||
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
|
||||
{:else}
|
||||
<span class="mini glyph" title={inp.label}><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<div class="section-title">{t('desk.workflow')}</div>
|
||||
<dl class="kv">
|
||||
<dt>{t('desk.kvName')}</dt>
|
||||
<dd>
|
||||
{wf?.name ?? t('studio.jobName', { id: job.id })}
|
||||
{#if wfDeleted}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
|
||||
</dd>
|
||||
{#if wf?.usage}
|
||||
<dt>{t('desk.kvUsage')}</dt>
|
||||
<dd>{usageLabel(wf.usage)}</dd>
|
||||
{/if}
|
||||
<dt>{t('desk.kvCreated')}</dt>
|
||||
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
|
||||
{#if job.comfy_prompt_id}
|
||||
<dt>{t('desk.kvPromptId')}</dt>
|
||||
<dd class="mono">{job.comfy_prompt_id}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.close {
|
||||
padding: 0 var(--sp-3);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
.stage {
|
||||
aspect-ratio: 16 / 10;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.stage-media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.stage-media > img,
|
||||
.stage-media > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.stage-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-6);
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.stage-empty.gone {
|
||||
color: var(--warn);
|
||||
}
|
||||
.stage-empty .sub {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.stage-bar {
|
||||
width: 160px;
|
||||
}
|
||||
.strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.strip-item {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.strip-item[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.strip-item.gone {
|
||||
border-style: dashed;
|
||||
border-color: var(--warn);
|
||||
color: var(--warn);
|
||||
}
|
||||
.strip-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2) var(--sp-5);
|
||||
margin: var(--sp-5) 0;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.meta b {
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.note {
|
||||
margin-top: var(--sp-5);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--r-md);
|
||||
font-size: var(--fs-aux);
|
||||
word-break: break-word;
|
||||
}
|
||||
.note.err {
|
||||
background: var(--err-soft);
|
||||
color: var(--err);
|
||||
}
|
||||
.note.warn {
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
}
|
||||
.section-title {
|
||||
margin: var(--sp-7) 0 var(--sp-3);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.inputs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
.mini {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
object-fit: cover;
|
||||
}
|
||||
.mini.glyph {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.prompt {
|
||||
margin: 0;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 92px 1fr;
|
||||
gap: var(--sp-2) var(--sp-4);
|
||||
margin: 0;
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.kv dt {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
word-break: break-all;
|
||||
}
|
||||
.kv .wf-gone {
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
|
||||
/* ≥1180: a column of the desk grid; the close button hides it (]) */
|
||||
@media (max-width: 1179px) {
|
||||
.inspector {
|
||||
position: fixed;
|
||||
top: var(--topbar-h);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
width: min(420px, 100vw);
|
||||
box-shadow: var(--shadow-float);
|
||||
transform: translateX(100%);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform var(--d-drawer) var(--ease-drawer),
|
||||
visibility 0s linear var(--d-drawer);
|
||||
}
|
||||
.inspector.open {
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
transition:
|
||||
transform var(--d-drawer) var(--ease-drawer),
|
||||
visibility 0s;
|
||||
}
|
||||
:global(:root[data-reduce='1']) .inspector {
|
||||
transform: none;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--d-drawer) ease,
|
||||
visibility 0s linear var(--d-drawer);
|
||||
}
|
||||
:global(:root[data-reduce='1']) .inspector.open {
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity var(--d-drawer) ease,
|
||||
visibility 0s;
|
||||
}
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
.close {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.inspector {
|
||||
top: 0;
|
||||
z-index: 70;
|
||||
width: 100vw;
|
||||
border-left: 0;
|
||||
}
|
||||
.body {
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import type { ComfyDynamicInput } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { inputRole, isChanged, type FormValue, type FormValues } from './schema';
|
||||
|
||||
/**
|
||||
* One common parameter as a composer chip (spec-v2 §3.3 参数芯片): the
|
||||
* pop edits it in place — options list for selects, a number field for
|
||||
* the rest (seed adds 随机) — and 恢复原值 returns to the workflow value.
|
||||
* `inputs` holds two entries for the width × height pair.
|
||||
*/
|
||||
interface Props {
|
||||
inputs: ComfyDynamicInput[];
|
||||
values: FormValues;
|
||||
text: string;
|
||||
title?: string;
|
||||
invalid?: boolean;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
}
|
||||
|
||||
let { inputs, values, text, title, invalid = false, onSet }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
const changed = $derived(inputs.some((i) => isChanged(i, values)));
|
||||
const single = $derived(inputs.length === 1 ? inputs[0] : null);
|
||||
const uid = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
function current(inp: ComfyDynamicInput): string {
|
||||
const v = values[inp.nodeId] ?? inp.defaultValue ?? '';
|
||||
return Array.isArray(v) ? v.join(',') : String(v);
|
||||
}
|
||||
|
||||
function setNumber(inp: ComfyDynamicInput, raw: string) {
|
||||
if (raw === '') return onSet(inp.nodeId, '');
|
||||
const n = Number(raw);
|
||||
onSet(inp.nodeId, Number.isNaN(n) ? raw : n);
|
||||
}
|
||||
|
||||
function restore() {
|
||||
for (const inp of inputs) onSet(inp.nodeId, inp.defaultValue ?? '');
|
||||
}
|
||||
|
||||
function randomSeed(inp: ComfyDynamicInput) {
|
||||
const max = Math.min(inp.max ?? 2 ** 32 - 1, Number.MAX_SAFE_INTEGER);
|
||||
const min = Math.max(inp.min ?? 0, 0);
|
||||
onSet(inp.nodeId, min + Math.floor(Math.random() * (max - min + 1)));
|
||||
}
|
||||
|
||||
function pickOption(inp: ComfyDynamicInput, opt: string) {
|
||||
onSet(inp.nodeId, opt);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="param-chip">
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip"
|
||||
class:changed={changed && !invalid}
|
||||
class:err={invalid}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={title ?? inputs.map((i) => i.label).join(' × ')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span class="lbl">{text}</span>
|
||||
</button>
|
||||
<ShellPop bind:open label={inputs.map((i) => i.label).join(' × ')} placement="top" width="260px">
|
||||
{#if single?.kind === 'select' && single.options?.length}
|
||||
<div class="a-menu-group">{single.label}</div>
|
||||
{#each single.options as opt (opt)}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item"
|
||||
aria-current={current(single) === opt ? 'true' : undefined}
|
||||
onclick={() => pickOption(single, opt)}
|
||||
>
|
||||
<span class="a-menu-label">{opt}</span>
|
||||
{#if String(single.defaultValue ?? '') === opt}
|
||||
<span class="a-menu-meta">{t('desk.origValue')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="fields">
|
||||
{#each inputs as inp (inp.nodeId)}
|
||||
{@const id = `pc-${uid}-${inp.nodeId.replace(/[^a-zA-Z0-9_-]/g, '')}`}
|
||||
<div class="field-row">
|
||||
<label class="field-label" for={id}>
|
||||
{inp.label}
|
||||
{#if inp.defaultValue !== undefined && inp.defaultValue !== ''}
|
||||
<span class="orig mono">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<div class="inline">
|
||||
{#if inp.kind === 'number'}
|
||||
<input
|
||||
{id}
|
||||
class="a-input mono"
|
||||
class:invalid
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
value={current(inp)}
|
||||
oninput={(e) => setNumber(inp, e.currentTarget.value)}
|
||||
onkeydown={(e) => e.key === 'Enter' && (open = false)}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
{id}
|
||||
class="a-input"
|
||||
class:invalid
|
||||
type="text"
|
||||
value={current(inp)}
|
||||
oninput={(e) => onSet(inp.nodeId, e.currentTarget.value)}
|
||||
onkeydown={(e) => e.key === 'Enter' && (open = false)}
|
||||
/>
|
||||
{/if}
|
||||
{#if inputRole(inp) === 'seed'}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => randomSeed(inp)}>
|
||||
{t('desk.seedRandom')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if inp.min != null || inp.max != null}
|
||||
<span class="field-hint mono">{inp.min ?? '−∞'} – {inp.max ?? '∞'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if changed}
|
||||
<div class="foot">
|
||||
<button type="button" class="a-btn ghost sm" onclick={restore}>{t('desk.restoreOrig')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.param-chip {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: var(--sp-5);
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
.field-row {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.field-row .field-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin: 0;
|
||||
}
|
||||
.orig {
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
.inline {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.field-row .field-hint {
|
||||
margin: 0;
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--sp-2) var(--sp-1) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import type { DeskSchema, FormValue, FormValues } from './schema';
|
||||
|
||||
/**
|
||||
* 参数 (spec-v2 §3.3 检视区 · 参数): every non-media input the workflow
|
||||
* declares, each with its 原值 and a gold edge once changed. Common ones
|
||||
* first; the rest fold under 高级参数 — untouched, they keep the
|
||||
* workflow's own values.
|
||||
*/
|
||||
interface Props {
|
||||
workflow: Workflow | null;
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
errors: Record<string, string>;
|
||||
showErrors: boolean;
|
||||
changedCount: number;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onRestoreAll: () => void;
|
||||
}
|
||||
|
||||
let { workflow, ds, values, errors, showErrors, changedCount, onSet, onRestoreAll }: Props = $props();
|
||||
|
||||
let advancedOpen = $state(false);
|
||||
const brokenReasons = $derived(
|
||||
(workflow?.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message),
|
||||
);
|
||||
const advancedErr = $derived(showErrors && ds.advanced.some((i) => !!errors[i.nodeId]?.trim()));
|
||||
|
||||
$effect(() => {
|
||||
if (advancedErr) advancedOpen = true;
|
||||
});
|
||||
|
||||
function patch(inputs: ComfyDynamicInput[], next: Record<string, string | number>) {
|
||||
for (const inp of inputs) {
|
||||
const v = next[inp.nodeId];
|
||||
if (v !== undefined && v !== values[inp.nodeId]) onSet(inp.nodeId, v);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="params">
|
||||
{#if !workflow}
|
||||
<p class="note">{t('desk.pickWorkflow')}</p>
|
||||
{:else}
|
||||
{#if brokenReasons.length}
|
||||
<div class="note err" role="alert">
|
||||
<strong>{t('studio.wfBrokenTitle')}</strong>
|
||||
<ul>
|
||||
{#each brokenReasons as r (r)}<li>{r}</li>{/each}
|
||||
</ul>
|
||||
<span>{t('studio.wfBrokenHint')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !(workflow.input_schema ?? []).length}
|
||||
<p class="note">{t('studio.wfNoParamsNote')}</p>
|
||||
{:else if (workflow.input_schema ?? []).some((i) => i.auto)}
|
||||
<p class="note">{t('studio.wfAutoParamsNote', { n: workflow.input_schema.length })}</p>
|
||||
{/if}
|
||||
|
||||
{#if ds.common.length}
|
||||
<div class="section-title">{t('desk.paramsCommon')}</div>
|
||||
<ComfyDynamicForm
|
||||
inputs={ds.common}
|
||||
values={values as Record<string, string | number>}
|
||||
quiet
|
||||
showOrig
|
||||
{errors}
|
||||
{showErrors}
|
||||
onChange={(v) => patch(ds.common, v)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if ds.advanced.length}
|
||||
<div class="fold">
|
||||
<button
|
||||
type="button"
|
||||
class="fold-head"
|
||||
class:err={advancedErr}
|
||||
aria-expanded={advancedOpen}
|
||||
aria-controls="desk-advanced"
|
||||
onclick={() => (advancedOpen = !advancedOpen)}
|
||||
>
|
||||
<span class="chev" aria-hidden="true"><Icon name="play" size={10} /></span>
|
||||
{t('desk.advanced', { n: ds.advanced.length })}
|
||||
</button>
|
||||
<div id="desk-advanced" class="collapse" class:open={advancedOpen} inert={!advancedOpen}>
|
||||
<div class="collapse-in">
|
||||
<ComfyDynamicForm
|
||||
inputs={ds.advanced}
|
||||
values={values as Record<string, string | number>}
|
||||
quiet
|
||||
showOrig
|
||||
{errors}
|
||||
{showErrors}
|
||||
onChange={(v) => patch(ds.advanced, v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if changedCount > 0}
|
||||
<div class="restore">
|
||||
<button type="button" class="a-btn ghost sm" onclick={onRestoreAll}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('desk.restoreAll')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.params {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.note {
|
||||
margin: 0;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.note.err {
|
||||
background: var(--err-soft);
|
||||
color: var(--err);
|
||||
}
|
||||
.note ul {
|
||||
margin: var(--sp-2) 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.fold {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.fold-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
padding: var(--sp-4) 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color var(--d-state) ease;
|
||||
}
|
||||
.fold-head.err {
|
||||
color: var(--err);
|
||||
}
|
||||
.chev {
|
||||
display: inline-grid;
|
||||
transition: transform var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.fold-head[aria-expanded='true'] .chev {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.fold-head:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
.collapse {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
transition:
|
||||
grid-template-rows var(--d-enter) var(--ease-out),
|
||||
opacity var(--d-state) ease;
|
||||
}
|
||||
.collapse.open {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
}
|
||||
.collapse-in {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.collapse.open .collapse-in {
|
||||
padding-bottom: var(--sp-2);
|
||||
}
|
||||
.restore {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<script lang="ts">
|
||||
import MediaSlot from '$lib/components/studio/MediaSlot.svelte';
|
||||
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
|
||||
import { mediaFileProblem } from '$lib/comfy/imageFile';
|
||||
import { createUploadManager } from '$lib/comfy/useUpload.svelte';
|
||||
import type { ComfyDynamicInput } from '$lib/comfy/types';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { h3Warnings, listKind, listPaths, type DeskSchema, type FormValue, type FormValues, type SlotDef } from './schema';
|
||||
|
||||
/**
|
||||
* 输入槽 (spec-v2 §3.3): exactly the media inputs the workflow JSON wires —
|
||||
* text-to-image shows none, image-to-image one input, image-to-video a
|
||||
* first frame, ref-to-video the 1–N list. Owns uploads and rejects;
|
||||
* values flow back through `onSet`.
|
||||
*/
|
||||
interface Props {
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
showErrors: boolean;
|
||||
errors: Record<string, string>;
|
||||
/** True while any upload runs (generate waits for it). */
|
||||
busy?: boolean;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onFromResults: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
let { ds, values, showErrors, errors, busy = $bindable(false), onSet, onFromResults }: Props = $props();
|
||||
|
||||
const uploads = createUploadManager();
|
||||
type Reject = { name: string; message: string };
|
||||
let rejects = $state<Record<string, Reject[]>>({});
|
||||
let fileSizes = $state<Record<string, number>>({});
|
||||
let replacingKey = $state('');
|
||||
let replacingIndex = $state(-1);
|
||||
|
||||
$effect(() => {
|
||||
busy = Object.keys(uploads.uploading).length > 0;
|
||||
});
|
||||
|
||||
// A different workflow starts with no stale rejects.
|
||||
$effect(() => {
|
||||
void ds;
|
||||
rejects = {};
|
||||
});
|
||||
|
||||
const warns = $derived(h3Warnings(ds, values));
|
||||
|
||||
function setRejects(nodeId: string, list: Reject[]) {
|
||||
rejects = { ...rejects, [nodeId]: list };
|
||||
}
|
||||
|
||||
async function upload(nodeId: string, file: File): Promise<string | null> {
|
||||
const path = await uploads.uploadSafe(nodeId, file);
|
||||
if (path) fileSizes = { ...fileSizes, [path]: file.size };
|
||||
return path;
|
||||
}
|
||||
|
||||
async function slotFile(s: SlotDef, file: File) {
|
||||
const problem = mediaFileProblem(s.mediaKind, file);
|
||||
if (problem) {
|
||||
setRejects(s.inp.nodeId, [{ name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
setRejects(s.inp.nodeId, []);
|
||||
const path = await upload(s.inp.nodeId, file);
|
||||
if (path) onSet(s.inp.nodeId, path);
|
||||
}
|
||||
|
||||
/** Add picked/dropped files in order; a batch that would pass `max` is refused whole. */
|
||||
async function groupAdd(entry: ComfyDynamicInput, files: File[]) {
|
||||
const key = entry.nodeId;
|
||||
const mk = listKind(entry);
|
||||
const max = entry.max ?? 9;
|
||||
const ok = files.filter((f) => !mediaFileProblem(mk, f));
|
||||
setRejects(
|
||||
key,
|
||||
files.filter((f) => mediaFileProblem(mk, f)).map((f) => ({ name: f.name, message: mediaFileProblem(mk, f)! })),
|
||||
);
|
||||
const start = listPaths(values, entry);
|
||||
if (start.length + ok.length > max) {
|
||||
toast.error(t('studio.errRefMax', { max, n: start.length, k: ok.length }));
|
||||
return;
|
||||
}
|
||||
for (const f of ok) {
|
||||
const path = await upload(key, f);
|
||||
if (!path) break;
|
||||
onSet(key, [...listPaths(values, entry), path]);
|
||||
}
|
||||
}
|
||||
|
||||
async function groupReplace(entry: ComfyDynamicInput, index: number, file: File) {
|
||||
const key = entry.nodeId;
|
||||
const problem = mediaFileProblem(listKind(entry), file);
|
||||
if (problem) {
|
||||
setRejects(key, [{ name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
setRejects(key, []);
|
||||
replacingKey = key;
|
||||
replacingIndex = index;
|
||||
try {
|
||||
const path = await upload(key, file);
|
||||
if (path) onSet(key, listPaths(values, entry).map((p, i) => (i === index ? path : p)));
|
||||
} finally {
|
||||
replacingKey = '';
|
||||
replacingIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function fileKind(f: File): 'image' | 'video' | 'audio' {
|
||||
if (f.type.startsWith('video/')) return 'video';
|
||||
if (f.type.startsWith('audio/')) return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
/** Composer-wide drop: each file goes to the first wired slot of its kind with room. */
|
||||
export function dropFiles(files: File[]) {
|
||||
const pending = new Map<string, File[]>();
|
||||
const taken = new Set<string>();
|
||||
let unplaced = 0;
|
||||
for (const f of files) {
|
||||
const mk = fileKind(f);
|
||||
const slot = ds.slots.find(
|
||||
(s) =>
|
||||
s.mediaKind === mk &&
|
||||
!s.mask &&
|
||||
!taken.has(s.inp.nodeId) &&
|
||||
!String(values[s.inp.nodeId] ?? ''),
|
||||
);
|
||||
if (slot) {
|
||||
taken.add(slot.inp.nodeId);
|
||||
void slotFile(slot, f);
|
||||
continue;
|
||||
}
|
||||
const list = ds.lists.find((g) => listKind(g) === mk);
|
||||
if (list) {
|
||||
pending.set(list.nodeId, [...(pending.get(list.nodeId) ?? []), f]);
|
||||
continue;
|
||||
}
|
||||
unplaced += 1;
|
||||
}
|
||||
for (const [nodeId, batch] of pending) {
|
||||
const entry = ds.lists.find((g) => g.nodeId === nodeId);
|
||||
if (entry) void groupAdd(entry, batch);
|
||||
}
|
||||
if (unplaced) toast.error(t('desk.dropNoSlot', { n: unplaced }));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if ds.slots.length || ds.lists.length}
|
||||
<div class="tray">
|
||||
{#each ds.slots as s (s.inp.nodeId)}
|
||||
{@const reject = rejects[s.inp.nodeId]?.[0]}
|
||||
<MediaSlot
|
||||
label={s.label}
|
||||
required={s.inp.required}
|
||||
value={String(values[s.inp.nodeId] ?? '')}
|
||||
kind={s.mediaKind}
|
||||
size={fileSizes[String(values[s.inp.nodeId] ?? '')]}
|
||||
uploading={uploads.isUploading(s.inp.nodeId)}
|
||||
uploadName={uploads.uploading[s.inp.nodeId]}
|
||||
need={showErrors && !!errors[s.inp.nodeId]}
|
||||
error={reject?.message ?? (showErrors ? errors[s.inp.nodeId] : undefined)}
|
||||
allowFromResults={s.fromResults}
|
||||
anchorId={`slot-${s.inp.nodeId}`}
|
||||
onFile={(f) => slotFile(s, f)}
|
||||
onFromResults={() => onFromResults(s.inp.nodeId)}
|
||||
onClear={() => {
|
||||
setRejects(s.inp.nodeId, []);
|
||||
onSet(s.inp.nodeId, '');
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{#each ds.lists as entry (entry.nodeId)}
|
||||
<RefImageGrid
|
||||
label={entry.label}
|
||||
mediaKind={listKind(entry)}
|
||||
paths={listPaths(values, entry)}
|
||||
max={entry.max ?? 9}
|
||||
sizes={fileSizes}
|
||||
uploadingName={uploads.uploading[entry.nodeId]}
|
||||
uploadingIndex={replacingKey === entry.nodeId ? replacingIndex : -1}
|
||||
rejects={rejects[entry.nodeId] ?? []}
|
||||
need={showErrors && !!errors[entry.nodeId]}
|
||||
error={showErrors ? errors[entry.nodeId] : undefined}
|
||||
allowFromResults={entry.kind === 'image_list'}
|
||||
anchorId={`slot-${entry.nodeId}`}
|
||||
onAdd={(files) => groupAdd(entry, files)}
|
||||
onReplace={(i, file) => groupReplace(entry, i, file)}
|
||||
onRemove={(i) => onSet(entry.nodeId, listPaths(values, entry).filter((_, j) => j !== i))}
|
||||
onDismissReject={(i) => setRejects(entry.nodeId, (rejects[entry.nodeId] ?? []).filter((_, j) => j !== i))}
|
||||
onFromResults={entry.kind === 'image_list' ? () => onFromResults(entry.nodeId) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if showErrors && errors.firstLast}
|
||||
<p class="a-slot-hint err note">{errors.firstLast}</p>
|
||||
{/if}
|
||||
{#if ds.hasH3Hint}
|
||||
<div class="note" role="note">
|
||||
<p class="a-slot-hint">{t('wfMedia.h3Limits')}</p>
|
||||
{#each warns as w (w)}
|
||||
<p class="a-slot-hint warn">{w}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tray {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3) var(--sp-6);
|
||||
padding: var(--sp-4) var(--sp-5) 0;
|
||||
}
|
||||
.note {
|
||||
margin: 0;
|
||||
padding: var(--sp-2) var(--sp-5) 0;
|
||||
}
|
||||
.note p {
|
||||
margin: 0;
|
||||
}
|
||||
.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,568 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { progressFor } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { usageLabel } from './schema';
|
||||
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf } from './takes';
|
||||
|
||||
/**
|
||||
* 一条记录 (spec-v2 §3.3 记录卡): workflow · usage · time, the prompt, the
|
||||
* inputs it used, its real state (phase / progress / time / error) and
|
||||
* the first output. The whole card selects; actions sit above it.
|
||||
* D 批 (HEL-654): the gold sel-mark frame slides between cards on
|
||||
* pointer selection (driven by the feed), new takes enter with a
|
||||
* 220ms rise, completion flashes once, failure shakes once, a done
|
||||
* take whose files no longer load turns amber (文件缺失) with
|
||||
* 用相同参数重做 / 移除记录, and takes on a deleted workflow are
|
||||
* marked as such instead of pretending nothing changed.
|
||||
*/
|
||||
interface Props {
|
||||
job: Job;
|
||||
wf: Workflow | undefined;
|
||||
selected: boolean;
|
||||
nowMs: number;
|
||||
busy?: boolean;
|
||||
/** Play the 220ms entrance rise (new take / undo restore). */
|
||||
enter?: boolean;
|
||||
/** One-shot 900ms gold flash on completion. */
|
||||
flash?: boolean;
|
||||
/** One-shot failure shake. */
|
||||
shake?: boolean;
|
||||
onSelect: () => void;
|
||||
onReuse: () => void;
|
||||
onRetry: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
job,
|
||||
wf,
|
||||
selected,
|
||||
nowMs,
|
||||
busy = false,
|
||||
enter = false,
|
||||
flash = false,
|
||||
shake = false,
|
||||
onSelect,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
const st = $derived(jobStatusInfo(job));
|
||||
// §3.4 红/琥珀界线: red follows the backend task status only. Amber is
|
||||
// reserved for a SUCCEEDED take whose product files no longer load.
|
||||
const gonePaths = $derived(missingOutputsOf(job));
|
||||
const fileGone = $derived(gonePaths.length > 0);
|
||||
const tone = $derived(fileGone ? 'warn' : st.tone === 'queue' ? '' : st.tone === 'fail' ? 'err' : st.tone);
|
||||
const label = $derived(fileGone ? t('jobst.missing') : st.label);
|
||||
const wfDeleted = $derived(job.workflow_id == null);
|
||||
const prompt = $derived(promptOf(job, wf));
|
||||
const inputs = $derived(inputsOf(job, wf).filter((i) => i.kind === 'image'));
|
||||
const output = $derived(
|
||||
fileGone
|
||||
? (job.output_paths.find((p) => !gonePaths.includes(p)) ?? null)
|
||||
: (job.output_paths[0] ?? null),
|
||||
);
|
||||
const progress = $derived.by(() => {
|
||||
if (job.status !== 'running') return 0;
|
||||
const live = progressFor(job.id);
|
||||
const phase = live?.phase ?? job.phase;
|
||||
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
|
||||
return live?.progress ?? 3;
|
||||
});
|
||||
const active = $derived(job.status === 'pending' || job.status === 'running');
|
||||
const name = $derived(wf?.name ?? t('studio.jobName', { id: job.id }));
|
||||
|
||||
let portrait = $state(false);
|
||||
let clipSec = $state<number | null>(null);
|
||||
let errOpen = $state(false);
|
||||
|
||||
function noteImage(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
portrait = img.naturalHeight > img.naturalWidth;
|
||||
}
|
||||
|
||||
function noteVideo(e: Event) {
|
||||
const v = e.currentTarget as HTMLVideoElement;
|
||||
portrait = v.videoHeight > v.videoWidth;
|
||||
if (Number.isFinite(v.duration)) clipSec = v.duration;
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="take"
|
||||
class:selected
|
||||
class:enter
|
||||
class:done-flash={flash}
|
||||
class:shake
|
||||
data-job={job.id}
|
||||
>
|
||||
<span class="sel-mark" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="hit"
|
||||
aria-pressed={selected}
|
||||
aria-label={`${name} · ${label}`}
|
||||
onclick={onSelect}
|
||||
></button>
|
||||
|
||||
<div class="head">
|
||||
<span class="name">{name}</span>
|
||||
{#if wfDeleted}<span class="a-tag">{t('studio.wfDeletedTag')}</span>{/if}
|
||||
{#if wf?.usage}<span class="a-tag">{usageLabel(wf.usage)}</span>{/if}
|
||||
<span class="meta mono">{fmtClock(parseDbTs(job.created_at))}</span>
|
||||
</div>
|
||||
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
|
||||
{#if inputs.length}
|
||||
<div class="inputs" aria-label={t('desk.inputs')}>
|
||||
{#each inputs.slice(0, 5) as inp (inp.path + inp.label)}
|
||||
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
|
||||
{/each}
|
||||
{#if inputs.length > 5}<span class="more">+{inputs.length - 5}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="state">
|
||||
<span class="a-tag {tone}">{label}</span>
|
||||
{#if st.tone === 'run'}
|
||||
{#if progress > 0}
|
||||
<div class="a-progress bar" style:--p={progress / 100}><i></i></div>
|
||||
{/if}
|
||||
<span class="txt">{fmtDur(elapsedSec(job, nowMs))}</span>
|
||||
{:else if st.tone === 'ok' && !fileGone}
|
||||
<span class="txt">{t('desk.took', { d: fmtDur(elapsedSec(job, nowMs)) })}</span>
|
||||
{:else if st.tone === 'fail'}
|
||||
<button
|
||||
type="button"
|
||||
class="err-txt"
|
||||
class:open={errOpen}
|
||||
title={st.detail}
|
||||
aria-expanded={errOpen}
|
||||
onclick={() => (errOpen = !errOpen)}
|
||||
>
|
||||
{st.detail}
|
||||
</button>
|
||||
{:else if fileGone}
|
||||
<span class="gone-txt">{t('jobst.missingDetail')}</span>
|
||||
{:else}
|
||||
<span class="txt">{st.detail}</span>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
{#if fileGone}
|
||||
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>
|
||||
{t('desk.redo')}
|
||||
</button>
|
||||
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('desk.removeRecord')}</button>
|
||||
{:else}
|
||||
<button type="button" class="a-btn ghost xs" onclick={onReuse}>{t('desk.reuse')}</button>
|
||||
{#if st.tone === 'fail' && job.error !== 'cancelled'}
|
||||
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>{t('common.retry')}</button>
|
||||
{/if}
|
||||
{#if active}
|
||||
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onCancel}>{t('common.cancel')}</button>
|
||||
{/if}
|
||||
{#if output}
|
||||
<a class="a-btn ghost xs" href={assetUrl(output)} download>{t('desk.download')}</a>
|
||||
{/if}
|
||||
{#if !active}
|
||||
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('studio.deleteBtn')}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="out">
|
||||
{#if output}
|
||||
<div class="thumb" class:portrait>
|
||||
{#if isVideoOutput(job, output)}
|
||||
<video
|
||||
src={assetUrl(output)}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
onloadedmetadata={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteVideo(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
></video>
|
||||
<span class="play" aria-hidden="true"><Icon name="play" size={10} /></span>
|
||||
{#if clipSec != null}<span class="badge mono">{fmtDur(clipSec)}</span>{/if}
|
||||
{:else}
|
||||
<img
|
||||
src={assetUrl(output)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onload={(e) => {
|
||||
clearOutputMissing(output);
|
||||
noteImage(e);
|
||||
}}
|
||||
onerror={() => output && markOutputMissing(output)}
|
||||
/>
|
||||
{/if}
|
||||
{#if job.output_paths.length > 1}<span class="count mono">×{job.output_paths.length}</span>{/if}
|
||||
</div>
|
||||
{:else if active}
|
||||
<div class="thumb pending">{st.label}</div>
|
||||
{:else if fileGone}
|
||||
<div class="thumb gone">{t('jobst.missing')}</div>
|
||||
{:else}
|
||||
<div class="thumb missing">{t('desk.notGenerated')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.take {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--sp-4) var(--sp-6);
|
||||
margin-bottom: var(--sp-3);
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--bg-2);
|
||||
transition:
|
||||
border-color var(--d-state) ease,
|
||||
background-color var(--d-state) ease,
|
||||
box-shadow var(--d-state) ease;
|
||||
}
|
||||
/* 选中框:金色描边从上一条记录滑到新选中的记录(feed 驱动 WAAPI) */
|
||||
.sel-mark {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
z-index: 3;
|
||||
display: none;
|
||||
border: 1px solid var(--accent-line);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.take.selected {
|
||||
border-color: transparent;
|
||||
background: var(--accent-wash), var(--bg-3);
|
||||
box-shadow: var(--sel-shadow);
|
||||
}
|
||||
.take.selected > .sel-mark {
|
||||
display: block;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.take:hover:not(.selected) {
|
||||
border-color: var(--line-2);
|
||||
background: var(--bg-3);
|
||||
}
|
||||
}
|
||||
/* 新记录 / 撤销恢复:220ms 上移 8px 入场(减动效只余淡变) */
|
||||
.take.enter {
|
||||
animation: take-in var(--d-enter) var(--ease-out);
|
||||
}
|
||||
@keyframes take-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
/* 完成一次性金色光晕 900ms;失败原地轻抖一次 */
|
||||
.take.done-flash {
|
||||
animation: done-flash var(--d-flash) var(--ease-out);
|
||||
}
|
||||
@keyframes done-flash {
|
||||
from {
|
||||
box-shadow: var(--accent-glow), 0 0 0 1px var(--accent-line);
|
||||
}
|
||||
}
|
||||
.take.shake {
|
||||
animation: take-shake 320ms var(--ease-out);
|
||||
}
|
||||
@keyframes take-shake {
|
||||
20% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
45% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
70% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .take.enter {
|
||||
animation: take-fade var(--d-state) ease;
|
||||
}
|
||||
:global(:root[data-reduce='1']) .take.done-flash,
|
||||
:global(:root[data-reduce='1']) .take.shake {
|
||||
animation: none;
|
||||
}
|
||||
@keyframes take-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.hit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hit:focus-visible {
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
.head,
|
||||
.prompt,
|
||||
.inputs,
|
||||
.state {
|
||||
grid-column: 1;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.meta {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.prompt {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-body-sm);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.mini {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 5px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line-2);
|
||||
}
|
||||
.more {
|
||||
padding-left: 2px;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-height: 22px;
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.bar {
|
||||
flex: 0 0 140px;
|
||||
}
|
||||
.txt {
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gone-txt {
|
||||
min-width: 0;
|
||||
color: var(--warn);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* 失败:一行中文结论自动展开,点开看完整详情 */
|
||||
.err-txt {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--err);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.err-txt.open {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
.err-txt:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transition: opacity var(--d-state) ease;
|
||||
}
|
||||
.take:focus-within .actions,
|
||||
.take.selected .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.take:hover .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (hover: none) {
|
||||
.actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.out {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 4;
|
||||
align-self: start;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
.thumb {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-4);
|
||||
box-shadow: inset 0 0 0 1px var(--on-media-line);
|
||||
}
|
||||
.thumb.portrait {
|
||||
width: 96px;
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
.thumb img,
|
||||
.thumb video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.badge,
|
||||
.count {
|
||||
position: absolute;
|
||||
bottom: var(--sp-2);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.badge {
|
||||
right: var(--sp-2);
|
||||
}
|
||||
.count {
|
||||
right: var(--sp-2);
|
||||
top: var(--sp-2);
|
||||
bottom: auto;
|
||||
}
|
||||
.play {
|
||||
position: absolute;
|
||||
left: var(--sp-2);
|
||||
bottom: var(--sp-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
}
|
||||
.thumb.pending,
|
||||
.thumb.missing,
|
||||
.thumb.gone {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-2);
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
border: 1px dashed var(--line-2);
|
||||
background: var(--bg-2);
|
||||
box-shadow: none;
|
||||
}
|
||||
.thumb.pending {
|
||||
border-style: solid;
|
||||
color: var(--run);
|
||||
}
|
||||
.thumb.gone {
|
||||
color: var(--warn);
|
||||
border-color: var(--warn);
|
||||
}
|
||||
.thumb.pending::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(100deg, transparent 30%, var(--run-soft) 50%, transparent 70%);
|
||||
animation: take-sheen 1.4s linear infinite;
|
||||
}
|
||||
@keyframes take-sheen {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .thumb.pending::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.take {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
.head {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 2px;
|
||||
}
|
||||
.out {
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
.state {
|
||||
grid-column: 1 / -1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.thumb {
|
||||
width: 108px;
|
||||
}
|
||||
.thumb.portrait {
|
||||
width: 72px;
|
||||
}
|
||||
.bar {
|
||||
flex-basis: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,344 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import TakeCard from './TakeCard.svelte';
|
||||
import { dayLabel, matchesFilter, type FeedFilter } from './takes';
|
||||
|
||||
/**
|
||||
* 记录流 (spec-v2 §3.3): every desk take, newest last above the composer,
|
||||
* filtered 全部 / 图片 / 视频 / 失败 and grouped by day.
|
||||
* D 批 (HEL-654): rows pending their 5-second undo collapse out
|
||||
* (200ms) instead of vanishing, new/restored takes rise in (220ms),
|
||||
* the gold selection frame slides between cards on pointer picks
|
||||
* (键盘 ↑↓/J/K 即时切换无动画 — driven by the desk through the
|
||||
* exported helpers), and long histories page in from the top
|
||||
* (spec §五: 记录流超 200 条分页).
|
||||
*/
|
||||
interface Props {
|
||||
jobs: Job[];
|
||||
workflowOf: (job: Job) => Workflow | undefined;
|
||||
filter: FeedFilter;
|
||||
selectedId: number | null;
|
||||
nowMs: number;
|
||||
busyId: number | null;
|
||||
loading: boolean;
|
||||
/** Takes whose delete is inside the 5s undo window — hidden. */
|
||||
pendingIds: SvelteSet<number>;
|
||||
/** One-shot completion flashes / failure shakes (driven by SSE). */
|
||||
flashIds: SvelteSet<number>;
|
||||
shakeIds: SvelteSet<number>;
|
||||
/** Ordered visible ids (oldest first) for ↑↓/J/K. */
|
||||
orderedIds?: number[];
|
||||
/** Shown under the filters (e.g. the workbench context bar). */
|
||||
banner?: Snippet;
|
||||
/** Inspector toggle when it is hidden (desktop) or an overlay (mid). */
|
||||
inspectorToggle?: Snippet;
|
||||
onSelect: (job: Job) => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onCancel: (job: Job) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
jobs,
|
||||
workflowOf,
|
||||
filter = $bindable(),
|
||||
selectedId,
|
||||
nowMs,
|
||||
busyId,
|
||||
loading,
|
||||
pendingIds,
|
||||
flashIds,
|
||||
shakeIds,
|
||||
orderedIds = $bindable(),
|
||||
banner,
|
||||
inspectorToggle,
|
||||
onSelect,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
const FILTERS: Array<{ id: FeedFilter; key: string }> = [
|
||||
{ id: 'all', key: 'desk.filterAll' },
|
||||
{ id: 'image', key: 'desk.filterImage' },
|
||||
{ id: 'video', key: 'desk.filterVideo' },
|
||||
{ id: 'failed', key: 'desk.filterFailed' },
|
||||
];
|
||||
|
||||
/** §五 分页: render the newest PAGE_SIZE takes; older ones load on demand. */
|
||||
const PAGE_SIZE = 80;
|
||||
let limit = $state(PAGE_SIZE);
|
||||
|
||||
let scroller = $state<HTMLElement | null>(null);
|
||||
|
||||
/** Oldest first so the newest take sits right above the composer. */
|
||||
const shown = $derived(
|
||||
jobs
|
||||
.filter((j) => matchesFilter(j, filter) && !pendingIds.has(j.id))
|
||||
.sort((a, b) => parseDbTs(a.created_at) - parseDbTs(b.created_at)),
|
||||
);
|
||||
const hiddenEarlier = $derived(Math.max(0, shown.length - limit));
|
||||
const visible = $derived(hiddenEarlier > 0 ? shown.slice(-limit) : shown);
|
||||
$effect(() => {
|
||||
orderedIds = visible.map((j) => j.id);
|
||||
});
|
||||
const activeCount = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running').length);
|
||||
const days = $derived.by(() => {
|
||||
const out: Array<{ label: string; items: Job[] }> = [];
|
||||
for (const job of visible) {
|
||||
const label = dayLabel(parseDbTs(job.created_at), nowMs);
|
||||
const last = out[out.length - 1];
|
||||
if (last?.label === label) last.items.push(job);
|
||||
else out.push({ label, items: [job] });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Stick to the bottom as takes arrive, unless the user scrolled up to read.
|
||||
let pinned = true;
|
||||
function onScroll() {
|
||||
if (!scroller) return;
|
||||
pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80;
|
||||
}
|
||||
$effect(() => {
|
||||
void visible.length;
|
||||
void filter;
|
||||
if (!scroller || !pinned) return;
|
||||
const el = scroller;
|
||||
requestAnimationFrame(() => (el.scrollTop = el.scrollHeight));
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Entrance bookkeeping: only takes that appear AFTER the first fill
|
||||
// (or come back from an undo) play the 220ms rise.
|
||||
// ------------------------------------------------------------------
|
||||
const seenIds = new Set<number>();
|
||||
const animateIds = new SvelteSet<number>();
|
||||
let hydrated = false;
|
||||
$effect(() => {
|
||||
if (!hydrated) {
|
||||
if (jobs.length) {
|
||||
for (const j of jobs) seenIds.add(j.id);
|
||||
hydrated = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const j of jobs) {
|
||||
if (seenIds.has(j.id)) continue;
|
||||
seenIds.add(j.id);
|
||||
animateIds.add(j.id);
|
||||
}
|
||||
});
|
||||
|
||||
export function markEnter(jobId: number) {
|
||||
seenIds.add(jobId);
|
||||
animateIds.add(jobId);
|
||||
}
|
||||
|
||||
function reduced(): boolean {
|
||||
return (
|
||||
document.documentElement.dataset.reduce === '1' ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Gold selection frame (HEL-648 slideMark): pointer picks only.
|
||||
// ------------------------------------------------------------------
|
||||
export function markRect(): DOMRect | null {
|
||||
const el = scroller?.querySelector(`.take[data-job="${selectedId}"]`);
|
||||
return el instanceof HTMLElement ? el.getBoundingClientRect() : null;
|
||||
}
|
||||
|
||||
export function slideFrom(fromRect: DOMRect | null) {
|
||||
if (!fromRect || reduced()) return;
|
||||
const el = scroller?.querySelector(`.take[data-job="${selectedId}"]`);
|
||||
const mark = el?.querySelector('.sel-mark');
|
||||
if (!(el instanceof HTMLElement) || !(mark instanceof HTMLElement)) return;
|
||||
const to = el.getBoundingClientRect();
|
||||
if (Math.abs(fromRect.top - to.top) < 1 && Math.abs(fromRect.height - to.height) < 1) return;
|
||||
mark.animate(
|
||||
[
|
||||
{ top: `${fromRect.top - to.top - 1}px`, bottom: `${to.bottom - fromRect.bottom - 1}px` },
|
||||
{ top: '-1px', bottom: '-1px' },
|
||||
],
|
||||
{ duration: 220, easing: 'cubic-bezier(0.23, 1, 0.32, 1)' },
|
||||
);
|
||||
}
|
||||
|
||||
/** Bring a keyboard-selected take into view without animation. */
|
||||
export function scrollToJob(jobId: number) {
|
||||
const el = scroller?.querySelector(`.take[data-job="${jobId}"]`);
|
||||
el?.scrollIntoView({ block: 'nearest', behavior: 'auto' });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Row transitions: undo-pending rows collapse out (200ms 收起 + 补位),
|
||||
// new/restored takes rise in (220ms). Everything else is instant.
|
||||
// ------------------------------------------------------------------
|
||||
function takeEnter(_node: HTMLElement, should: () => boolean) {
|
||||
if (!should()) return { duration: 0 };
|
||||
if (reduced()) return { duration: 120, css: (t: number) => `opacity: ${t}` };
|
||||
return {
|
||||
duration: 220,
|
||||
easing: cubicOut,
|
||||
css: (t: number) => `opacity: ${t}; transform: translateY(${8 * (1 - t)}px)`,
|
||||
};
|
||||
}
|
||||
|
||||
function takeLeave(node: HTMLElement, isPending: () => boolean) {
|
||||
if (!isPending()) return { duration: 0 };
|
||||
if (reduced()) return { duration: 120, css: (t: number) => `opacity: ${t}` };
|
||||
const h = node.offsetHeight;
|
||||
const cs = getComputedStyle(node);
|
||||
const num = (v: string) => parseFloat(v) || 0;
|
||||
const padT = num(cs.paddingTop);
|
||||
const padB = num(cs.paddingBottom);
|
||||
const mb = num(cs.marginBottom);
|
||||
return {
|
||||
duration: 200,
|
||||
easing: cubicOut,
|
||||
css: (t: number, u: number) =>
|
||||
`height:${h * t}px;padding-top:${padT * t}px;padding-bottom:${padB * t}px;` +
|
||||
`margin-bottom:${mb * t}px;opacity:${t};transform:translateX(${-10 * u}px);overflow:hidden;`,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="filters">
|
||||
<div class="a-seg" role="group" aria-label={t('desk.filterAria')}>
|
||||
{#each FILTERS as f (f.id)}
|
||||
<button type="button" aria-pressed={filter === f.id} onclick={() => (filter = f.id)}>{t(f.key)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="count">
|
||||
{t('desk.countTakes', { n: shown.length })}{#if activeCount} · {t('desk.countActive', { n: activeCount })}{/if}
|
||||
</span>
|
||||
{@render inspectorToggle?.()}
|
||||
</div>
|
||||
{@render banner?.()}
|
||||
|
||||
<div class="feed" bind:this={scroller} onscroll={onScroll}>
|
||||
{#if loading && !jobs.length}
|
||||
<p class="empty">{t('common.loading')}</p>
|
||||
{:else if !shown.length}
|
||||
<div class="empty">
|
||||
<Icon name={filter === 'video' ? 'film' : filter === 'failed' ? 'check' : 'image'} size={20} />
|
||||
<p>{filter === 'failed' ? t('desk.emptyFailed') : t('desk.emptyFeed')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if hiddenEarlier > 0}
|
||||
<button type="button" class="a-btn ghost sm earlier" onclick={() => (limit += PAGE_SIZE)}>
|
||||
<Icon name="chevron-up" size={13} />
|
||||
{t('desk.loadEarlier', { n: hiddenEarlier })}
|
||||
</button>
|
||||
{/if}
|
||||
{#each days as day (day.label)}
|
||||
<div class="day">{day.label}</div>
|
||||
{#each day.items as job (job.id)}
|
||||
<div
|
||||
class="take-slot"
|
||||
in:takeEnter={() => animateIds.has(job.id)}
|
||||
out:takeLeave={() => pendingIds.has(job.id)}
|
||||
>
|
||||
<TakeCard
|
||||
{job}
|
||||
wf={workflowOf(job)}
|
||||
selected={job.id === selectedId}
|
||||
{nowMs}
|
||||
busy={busyId === job.id}
|
||||
enter={animateIds.has(job.id)}
|
||||
flash={flashIds.has(job.id)}
|
||||
shake={shakeIds.has(job.id)}
|
||||
onSelect={() => onSelect(job)}
|
||||
onReuse={() => onReuse(job)}
|
||||
onRetry={() => onRetry(job)}
|
||||
onCancel={() => onCancel(job)}
|
||||
onDelete={() => onDelete(job)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4) var(--sp-7);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.count {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.feed {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-6) var(--sp-7) var(--sp-3);
|
||||
}
|
||||
.earlier {
|
||||
display: flex;
|
||||
margin: 0 auto var(--sp-4);
|
||||
}
|
||||
.day {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin: var(--sp-4) 0 var(--sp-3);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.day::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
.empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin: 0;
|
||||
padding: var(--sp-8) var(--sp-6);
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.empty p {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.filters {
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
}
|
||||
.feed {
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-3);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.filters {
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.count {
|
||||
display: none;
|
||||
}
|
||||
.feed {
|
||||
padding: var(--sp-5) var(--sp-5) var(--sp-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import { WORKFLOW_USAGES, type Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { slotSummary, usageLabel } from './schema';
|
||||
|
||||
/**
|
||||
* 工作流选择 (spec-v2 §3.3 工作流面板): the composer's first chip opens a
|
||||
* pop grouped by usage with search; each row shows what the workflow
|
||||
* really takes (slot summary from its JSON), its connection and whether
|
||||
* it last ran. Picking keeps the prompt and carried-over images.
|
||||
* D 批 (HEL-654): the footer's「↑↓ 选择」is now literally true — an
|
||||
* active row follows ↑↓ (and the pointer), Enter uses it, and the
|
||||
* search's Enter picks the active row instead of always the first.
|
||||
*/
|
||||
interface Props {
|
||||
workflows: Workflow[];
|
||||
current: Workflow | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onPick: (wf: Workflow) => void;
|
||||
}
|
||||
|
||||
let { workflows, current, endpoints, onPick }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let activeId = $state<number | null>(null);
|
||||
|
||||
type Status = { tone: '' | 'ok' | 'warn' | 'err'; label: string };
|
||||
|
||||
function statusOf(wf: Workflow): Status {
|
||||
if ((wf.warnings ?? []).some((w) => w.level === 'error')) return { tone: 'err', label: t('desk.wfBroken') };
|
||||
const lt = wf.last_test;
|
||||
if (!lt) return { tone: '', label: t('desk.wfUntested') };
|
||||
if (lt.status === 'done' && lt.output_count > 0) return { tone: 'ok', label: t('desk.wfOk') };
|
||||
if (lt.status === 'failed' || lt.status === 'done') return { tone: 'warn', label: t('desk.wfLastFailed') };
|
||||
return { tone: '', label: t('desk.wfTesting') };
|
||||
}
|
||||
|
||||
function endpointLabel(wf: Workflow): string {
|
||||
const ep = endpoints.find((e) => e.id === wf.endpoint_id);
|
||||
return ep ? ep.name : t('desk.wfDefaultConn');
|
||||
}
|
||||
|
||||
const rows = $derived(
|
||||
workflows.map((wf) => ({
|
||||
wf,
|
||||
summary: slotSummary(wf),
|
||||
conn: endpointLabel(wf),
|
||||
status: statusOf(wf),
|
||||
usage: wf.usage ? usageLabel(wf.usage) : '',
|
||||
})),
|
||||
);
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const hit = (r: (typeof rows)[number]) =>
|
||||
!q || `${r.wf.name} ${r.usage} ${r.summary}`.toLowerCase().includes(q);
|
||||
return WORKFLOW_USAGES.map((u) => ({
|
||||
usage: u,
|
||||
items: rows.filter((r) => r.wf.usage === u && hit(r)),
|
||||
})).filter((g) => g.items.length);
|
||||
});
|
||||
|
||||
/** Flattened rows in display order — the roving selection walks these. */
|
||||
const flat = $derived(groups.flatMap((g) => g.items));
|
||||
|
||||
// Keep the active row real: default to the current workflow, clamp on
|
||||
// filter changes, never point at a row that is no longer listed.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
if (activeId != null && flat.some((r) => r.wf.id === activeId)) return;
|
||||
activeId = flat.find((r) => r.wf.id === current?.id)?.wf.id ?? flat[0]?.wf.id ?? null;
|
||||
});
|
||||
|
||||
function moveActive(delta: number) {
|
||||
if (!flat.length) return;
|
||||
let i = flat.findIndex((r) => r.wf.id === activeId);
|
||||
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
|
||||
activeId = flat[Math.max(0, Math.min(flat.length - 1, i))].wf.id;
|
||||
}
|
||||
|
||||
function pickActive() {
|
||||
const row = flat.find((r) => r.wf.id === activeId) ?? flat[0];
|
||||
if (row) pick(row.wf);
|
||||
}
|
||||
|
||||
function pick(wf: Workflow) {
|
||||
open = false;
|
||||
query = '';
|
||||
if (wf.id !== current?.id) onPick(wf);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
pickActive();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="picker">
|
||||
<button
|
||||
type="button"
|
||||
id="desk-wf"
|
||||
class="a-chip wf"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={current?.name ?? t('desk.pickWorkflow')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<Icon name="playground" size={14} />
|
||||
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
|
||||
</button>
|
||||
<ShellPop
|
||||
bind:open
|
||||
label={t('desk.pickWorkflow')}
|
||||
placement="top"
|
||||
width="520px"
|
||||
arrowRoving={false}
|
||||
onclose={() => (query = '')}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="pop-body" onkeydown={onKeydown}>
|
||||
<div class="search">
|
||||
<Icon name="search" size={14} />
|
||||
<input
|
||||
class="search-input"
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder={t('desk.wfSearchPh')}
|
||||
aria-label={t('desk.wfSearchPh')}
|
||||
/>
|
||||
</div>
|
||||
<div class="list" role="listbox" aria-label={t('desk.pickWorkflow')}>
|
||||
{#each groups as g (g.usage)}
|
||||
<div class="a-menu-group">{usageLabel(g.usage)} · {g.items.length}</div>
|
||||
{#each g.items as r (r.wf.id)}
|
||||
{@const cur = r.wf.id === current?.id}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item row"
|
||||
class:off={r.status.tone === 'err'}
|
||||
class:active={r.wf.id === activeId}
|
||||
role="option"
|
||||
aria-current={cur ? 'true' : undefined}
|
||||
aria-selected={r.wf.id === activeId ? 'true' : undefined}
|
||||
onclick={() => pick(r.wf)}
|
||||
onpointermove={() => (activeId = r.wf.id)}
|
||||
>
|
||||
<span class="main">
|
||||
<span class="a-menu-label">{r.wf.name}{cur ? ` · ${t('desk.wfCurrent')}` : ''}</span>
|
||||
<span class="sub">
|
||||
<span class="a-tag">{r.summary}</span>
|
||||
<span class="a-tag">{r.conn}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="a-tag {r.status.tone}">{r.status.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="empty">{t('desk.wfNoMatch')}</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="foot">
|
||||
<a class="a-btn ghost sm" href="/connections">
|
||||
<Icon name="upload" size={13} />
|
||||
{t('desk.wfImport')}
|
||||
</a>
|
||||
<span class="keys">
|
||||
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
|
||||
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
|
||||
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.pop-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
/* Search and foot stay put; only the list scrolls inside the pop's max-height. */
|
||||
.list {
|
||||
max-height: min(380px, 50vh);
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
.row {
|
||||
align-items: center;
|
||||
}
|
||||
/* 键盘游走选中态(↑↓ 选择 · Enter 使用) */
|
||||
.row.active {
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.main {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.main .a-menu-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.row.off .a-menu-label {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3) var(--sp-1) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keys .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { WorkbenchStageKey } from '$lib/api';
|
||||
import type { DeskKind } from './schema';
|
||||
|
||||
/**
|
||||
* 五段创作 → 生成台 handoff, carried in the URL so a reload keeps it.
|
||||
* The desk only prefills from it; the playground generate API has no
|
||||
* project/shot link, so results stay in the desk feed (no backfill).
|
||||
*/
|
||||
export interface DeskContext {
|
||||
kind: DeskKind;
|
||||
projectId: number;
|
||||
stage: WorkbenchStageKey;
|
||||
/** Human label of the source row, e.g. 镜头 S1 / 角色「林夏」. */
|
||||
label: string;
|
||||
prompt?: string;
|
||||
negative?: string;
|
||||
durationSec?: number;
|
||||
}
|
||||
|
||||
const STAGES: WorkbenchStageKey[] = ['script', 'style', 'storyboard', 'assets', 'shot'];
|
||||
|
||||
export function deskHref(ctx: DeskContext): string {
|
||||
const q = new URLSearchParams({
|
||||
from: 'workbench',
|
||||
project: String(ctx.projectId),
|
||||
stage: ctx.stage,
|
||||
label: ctx.label,
|
||||
});
|
||||
if (ctx.prompt) q.set('prompt', ctx.prompt);
|
||||
if (ctx.negative) q.set('negative', ctx.negative);
|
||||
if (ctx.durationSec != null) q.set('duration', String(ctx.durationSec));
|
||||
return `/${ctx.kind}?${q.toString()}`;
|
||||
}
|
||||
|
||||
export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
|
||||
const q = url.searchParams;
|
||||
if (q.get('from') !== 'workbench') return null;
|
||||
const projectId = Number(q.get('project'));
|
||||
const stage = q.get('stage') as WorkbenchStageKey | null;
|
||||
if (!Number.isInteger(projectId) || projectId <= 0 || !stage || !STAGES.includes(stage)) return null;
|
||||
const duration = Number(q.get('duration'));
|
||||
return {
|
||||
kind,
|
||||
projectId,
|
||||
stage,
|
||||
label: q.get('label') ?? '',
|
||||
prompt: q.get('prompt') ?? undefined,
|
||||
negative: q.get('negative') ?? undefined,
|
||||
durationSec: Number.isFinite(duration) && duration > 0 ? duration : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function backHref(ctx: DeskContext): string {
|
||||
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import type { ComfyDynamicInput, Workflow, WorkflowUsage } from '$lib/comfy/types';
|
||||
import { normalizeInputRole } from '$lib/comfy/parser';
|
||||
import { ratioShort, resolutionSelectorSize } from '$lib/comfy/resolution';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 生成台表单识别 (HEL-653, moved verbatim from StudioGenerate): slots and
|
||||
* structured params come from what the workflow JSON actually declares —
|
||||
* never from model names. No wiring → no slot.
|
||||
*/
|
||||
|
||||
export type DeskKind = 'image' | 'video';
|
||||
export type FormValue = string | number | string[];
|
||||
export type FormValues = Record<string, FormValue>;
|
||||
|
||||
export interface SlotDef {
|
||||
inp: ComfyDynamicInput;
|
||||
label: string;
|
||||
mediaKind: 'image' | 'video' | 'audio';
|
||||
fromResults: boolean;
|
||||
/** Required-slot message when left empty (ref-upload-spec §3). */
|
||||
missing: string;
|
||||
firstFrame: boolean;
|
||||
lastFrame: boolean;
|
||||
mask: boolean;
|
||||
}
|
||||
|
||||
/** Why 生成 is disabled — the line next to the button (spec-v2 §1.2). */
|
||||
export interface BlockReason {
|
||||
text: string;
|
||||
tone: 'warn' | 'err';
|
||||
/** Element id to focus when the reason is clicked. */
|
||||
target?: string;
|
||||
/** Open the params tab instead of focusing. */
|
||||
params?: boolean;
|
||||
}
|
||||
|
||||
export interface DeskSchema {
|
||||
prompt: ComfyDynamicInput | null;
|
||||
negative: ComfyDynamicInput | null;
|
||||
seed: ComfyDynamicInput | null;
|
||||
width: ComfyDynamicInput | null;
|
||||
height: ComfyDynamicInput | null;
|
||||
resolution: ComfyDynamicInput | null;
|
||||
duration: ComfyDynamicInput | null;
|
||||
batch: ComfyDynamicInput | null;
|
||||
/** ComfyUI ResolutionSelector: the ratio + megapixels pair decides width/height. */
|
||||
ratio: ComfyDynamicInput | null;
|
||||
megapixels: ComfyDynamicInput | null;
|
||||
/** Single media inputs (input image, first/last frame, mask, ref video/audio). */
|
||||
slots: SlotDef[];
|
||||
/** Autogrow media lists actually wired (image / video / audio). */
|
||||
lists: ComfyDynamicInput[];
|
||||
/** Parameters shown as composer chips (比例 / 尺寸 / 分辨率 / 时长 / 种子). */
|
||||
common: ComfyDynamicInput[];
|
||||
/** Everything else that is not media and not the prompt — folded. */
|
||||
advanced: ComfyDynamicInput[];
|
||||
hasH3Hint: boolean;
|
||||
}
|
||||
|
||||
export function inputRole(inp: ComfyDynamicInput): string | null {
|
||||
return normalizeInputRole(inp.role ?? null);
|
||||
}
|
||||
|
||||
function hay(inp: ComfyDynamicInput): string {
|
||||
return `${inp.role ?? ''} ${inp.label}`.toLowerCase();
|
||||
}
|
||||
|
||||
export function isMedia(inp: ComfyDynamicInput): boolean {
|
||||
return (
|
||||
inp.kind === 'image' ||
|
||||
inp.kind === 'image_url' ||
|
||||
inp.kind === 'video' ||
|
||||
inp.kind === 'audio' ||
|
||||
inp.kind === 'image_list' ||
|
||||
inp.kind === 'video_list' ||
|
||||
inp.kind === 'audio_list'
|
||||
);
|
||||
}
|
||||
|
||||
function isList(inp: ComfyDynamicInput): boolean {
|
||||
return inp.kind === 'image_list' || inp.kind === 'video_list' || inp.kind === 'audio_list';
|
||||
}
|
||||
|
||||
function isMask(inp: ComfyDynamicInput): boolean {
|
||||
return inputRole(inp) === 'mask' || /mask|遮罩/.test(hay(inp));
|
||||
}
|
||||
|
||||
// Auto-detected slots carry their role from the wiring; tagged (Input:…)
|
||||
// workflows still name frames only in the title.
|
||||
export function isFirstFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
|
||||
return inputRole(inp) === 'first_frame' || (!inp.auto && kind === 'video' && /first|start|首帧/.test(hay(inp)));
|
||||
}
|
||||
export function isLastFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
|
||||
return inputRole(inp) === 'last_frame' || (!inp.auto && kind === 'video' && /last|end|尾帧/.test(hay(inp)));
|
||||
}
|
||||
|
||||
export function listKind(entry: ComfyDynamicInput): 'image' | 'video' | 'audio' {
|
||||
if (entry.kind === 'video_list') return 'video';
|
||||
if (entry.kind === 'audio_list') return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
export function listPaths(values: FormValues, entry: ComfyDynamicInput): string[] {
|
||||
const v = values[entry.nodeId];
|
||||
if (Array.isArray(v)) return v;
|
||||
return typeof v === 'string' && v ? [v] : [];
|
||||
}
|
||||
|
||||
export function classifySchema(schema: ComfyDynamicInput[], kind: DeskKind): DeskSchema {
|
||||
const plain = (pred: (i: ComfyDynamicInput) => boolean) =>
|
||||
schema.find((i) => !isMedia(i) && pred(i)) ?? null;
|
||||
const byRole = (r: string) => plain((i) => inputRole(i) === r);
|
||||
|
||||
const prompt = byRole('prompt');
|
||||
const negative = byRole('negative');
|
||||
const seed = byRole('seed');
|
||||
const width = byRole('width');
|
||||
const height = byRole('height');
|
||||
const resolution = byRole('resolution');
|
||||
const duration = byRole('duration');
|
||||
const batch = plain((i) => /批次|batch/.test(hay(i)));
|
||||
const ratio =
|
||||
schema.find((i) => i.kind === 'select' && inputRole(i) === 'aspect_ratio' && i.resolution) ?? null;
|
||||
const megapixels = ratio ? (schema.find((i) => i.nodeId === ratio.resolution!.megapixels) ?? null) : null;
|
||||
|
||||
const media = schema.filter((i) => isMedia(i) && !isList(i));
|
||||
const plainImages = media.filter(
|
||||
(i) => i.kind === 'image' && !isMask(i) && !isFirstFrame(i, kind) && !isLastFrame(i, kind),
|
||||
);
|
||||
const editInput = kind === 'image' && plainImages.length === 1 ? plainImages[0] : null;
|
||||
const slots: SlotDef[] = [];
|
||||
let refIdx = 0;
|
||||
for (const inp of media) {
|
||||
const base = { inp, missing: '', firstFrame: false, lastFrame: false, mask: false };
|
||||
if (inp.kind === 'video') {
|
||||
slots.push({ ...base, label: t('studio.slotRefVideo'), mediaKind: 'video', fromResults: false });
|
||||
} else if (inp.kind === 'audio') {
|
||||
slots.push({ ...base, label: t('studio.slotAudio'), mediaKind: 'audio', fromResults: false });
|
||||
} else if (isMask(inp)) {
|
||||
slots.push({ ...base, mask: true, label: t('studio.slotMask'), mediaKind: 'image', fromResults: false });
|
||||
} else if (isFirstFrame(inp, kind)) {
|
||||
slots.push({
|
||||
...base,
|
||||
firstFrame: true,
|
||||
label: t('studio.slotFirst'),
|
||||
mediaKind: 'image',
|
||||
fromResults: true,
|
||||
missing: t('studio.errNeedFirstFrame'),
|
||||
});
|
||||
} else if (isLastFrame(inp, kind)) {
|
||||
slots.push({ ...base, lastFrame: true, label: t('studio.slotLast'), mediaKind: 'image', fromResults: true });
|
||||
} else if (inp === editInput) {
|
||||
slots.push({
|
||||
...base,
|
||||
label: t('studio.slotInput'),
|
||||
mediaKind: 'image',
|
||||
fromResults: true,
|
||||
missing: t('studio.errNeedInputImage'),
|
||||
});
|
||||
} else {
|
||||
refIdx += 1;
|
||||
const label = inp.auto
|
||||
? inp.label
|
||||
: refIdx === 1 && media.length === 1
|
||||
? t('studio.slotRef')
|
||||
: t('studio.slotRefN', { n: refIdx });
|
||||
slots.push({
|
||||
...base,
|
||||
label,
|
||||
mediaKind: 'image',
|
||||
fromResults: true,
|
||||
missing: t('studio.errUpload', { label }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const lists = schema.filter(isList);
|
||||
const common = [ratio, width, height, resolution, duration, seed].filter(
|
||||
(i): i is ComfyDynamicInput => !!i,
|
||||
);
|
||||
const commonIds = new Set(common.map((i) => i.nodeId));
|
||||
const advanced = schema.filter((i) => !isMedia(i) && i !== prompt && !commonIds.has(i.nodeId));
|
||||
|
||||
return {
|
||||
prompt,
|
||||
negative,
|
||||
seed,
|
||||
width,
|
||||
height,
|
||||
resolution,
|
||||
duration,
|
||||
batch,
|
||||
ratio,
|
||||
megapixels,
|
||||
slots,
|
||||
lists,
|
||||
common,
|
||||
advanced,
|
||||
hasH3Hint: lists.some((e) => e.media_hint === 'minimax_h3_ref2va'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Workflow JSON defaults — the 原值 every parameter falls back to. */
|
||||
export function schemaDefaults(schema: ComfyDynamicInput[]): FormValues {
|
||||
const out: FormValues = {};
|
||||
for (const inp of schema) {
|
||||
if (inp.defaultValue !== undefined && inp.defaultValue !== '') out[inp.nodeId] = inp.defaultValue;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isChanged(inp: ComfyDynamicInput, values: FormValues): boolean {
|
||||
const v = values[inp.nodeId];
|
||||
const orig = inp.defaultValue;
|
||||
if (v === undefined || v === '') return orig !== undefined && orig !== '';
|
||||
if (orig === undefined || orig === '') return true;
|
||||
return String(v) !== String(orig);
|
||||
}
|
||||
|
||||
export function changedParams(ds: DeskSchema, values: FormValues): ComfyDynamicInput[] {
|
||||
return [...ds.common, ...ds.advanced].filter((i) => isChanged(i, values));
|
||||
}
|
||||
|
||||
export function outputSize(ds: DeskSchema, values: FormValues): { width: number; height: number } | null {
|
||||
if (!ds.ratio?.resolution) return null;
|
||||
const mp = Number(values[ds.megapixels?.nodeId ?? ''] ?? ds.megapixels?.defaultValue);
|
||||
return resolutionSelectorSize(
|
||||
String(values[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
|
||||
mp,
|
||||
ds.ratio.resolution.multiple,
|
||||
);
|
||||
}
|
||||
|
||||
export function batchCount(ds: DeskSchema, values: FormValues): number {
|
||||
if (!ds.batch) return 1;
|
||||
const n = Number(values[ds.batch.nodeId]);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
|
||||
}
|
||||
|
||||
/** H3 Ref2VA official limits (soft hint, keyed off the node class only). */
|
||||
export function h3Warnings(ds: DeskSchema, values: FormValues): string[] {
|
||||
if (!ds.hasH3Hint) return [];
|
||||
const warns: string[] = [];
|
||||
const counts = { image: 0, video: 0, audio: 0 };
|
||||
for (const g of ds.lists) counts[listKind(g)] += listPaths(values, g).length;
|
||||
const total = counts.image + counts.video + counts.audio;
|
||||
if (total > 12) warns.push(t('wfMedia.h3TotalOver', { n: total }));
|
||||
if (counts.audio > 0 && counts.image === 0 && counts.video === 0) warns.push(t('wfMedia.h3AudioAlone'));
|
||||
return warns;
|
||||
}
|
||||
|
||||
/** Same rules the backend enforces before queueing (comfyui/validate.py). */
|
||||
export function valueError(inp: ComfyDynamicInput, raw: string | number): string | null {
|
||||
const label = inp.label;
|
||||
if (inp.kind === 'select') {
|
||||
return inp.options?.length && !inp.options.includes(String(raw)) ? t('studio.errOption', { label }) : null;
|
||||
}
|
||||
if (inp.kind !== 'number') return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return t('studio.errNumber', { label });
|
||||
if (inp.integer && !Number.isInteger(n)) return t('studio.errInteger', { label });
|
||||
if (inp.min != null && n < inp.min) return t('studio.errMin', { label, v: inp.min });
|
||||
if (inp.max != null && n > inp.max) return t('studio.errMax', { label, v: inp.max });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Field errors keyed by nodeId (spec §6.2 wording); '' keys never block. */
|
||||
export function validateForm(
|
||||
schema: ComfyDynamicInput[],
|
||||
ds: DeskSchema,
|
||||
values: FormValues,
|
||||
): Record<string, string> {
|
||||
const errs: Record<string, string> = {};
|
||||
if (ds.prompt?.required) {
|
||||
const v = String(values[ds.prompt.nodeId] ?? '');
|
||||
if (!v.trim()) errs[ds.prompt.nodeId] = t('studio.errPrompt');
|
||||
else if (v.length > 2000) errs[ds.prompt.nodeId] = t('studio.errPromptLong');
|
||||
}
|
||||
const first = ds.slots.find((s) => s.firstFrame);
|
||||
const last = ds.slots.find((s) => s.lastFrame);
|
||||
if (first && last) {
|
||||
const a = String(values[first.inp.nodeId] ?? '');
|
||||
const b = String(values[last.inp.nodeId] ?? '');
|
||||
if ((!a || !b) && (a || b)) {
|
||||
errs.firstLast = t('studio.errFirstLast');
|
||||
if (!a) errs[first.inp.nodeId] = ' ';
|
||||
if (!b) errs[last.inp.nodeId] = ' ';
|
||||
}
|
||||
}
|
||||
for (const s of ds.slots) {
|
||||
if (!s.inp.required) continue;
|
||||
if (!String(values[s.inp.nodeId] ?? '').trim()) {
|
||||
errs[s.inp.nodeId] = s.missing || t('studio.errUpload', { label: s.label });
|
||||
}
|
||||
}
|
||||
for (const g of ds.lists) {
|
||||
const n = listPaths(values, g).length;
|
||||
const max = g.max ?? 9;
|
||||
if (n < (g.min ?? 1)) {
|
||||
errs[g.nodeId] =
|
||||
g.kind === 'image_list'
|
||||
? t('studio.errNeedRef')
|
||||
: t('studio.errNeedRefMedia', { n: g.min ?? 1, label: g.label });
|
||||
} else if (n > max) {
|
||||
errs[g.nodeId] = t('studio.errRefMax', { max, n, k: n - max });
|
||||
}
|
||||
}
|
||||
for (const inp of [...ds.common, ...ds.advanced]) {
|
||||
if (inp.required && !String(values[inp.nodeId] ?? '').trim()) {
|
||||
errs[inp.nodeId] = t('studio.errFill', { label: inp.label });
|
||||
}
|
||||
}
|
||||
for (const inp of schema) {
|
||||
const raw = values[inp.nodeId];
|
||||
if (raw === undefined || Array.isArray(raw) || String(raw).trim() === '' || errs[inp.nodeId]) continue;
|
||||
const msg = valueError(inp, raw);
|
||||
if (msg) errs[inp.nodeId] = msg;
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
/** Media a slot still waits for — the 「还缺:…」 hint next to 生成. */
|
||||
export function missingMedia(ds: DeskSchema, values: FormValues): Array<{ nodeId: string; label: string }> {
|
||||
const out: Array<{ nodeId: string; label: string }> = [];
|
||||
for (const s of ds.slots) {
|
||||
if (s.inp.required && !String(values[s.inp.nodeId] ?? '').trim()) out.push({ nodeId: s.inp.nodeId, label: s.label });
|
||||
}
|
||||
for (const g of ds.lists) {
|
||||
if (listPaths(values, g).length < (g.min ?? 1)) out.push({ nodeId: g.nodeId, label: g.label });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function usageLabel(u: WorkflowUsage): string {
|
||||
return t(`usage.${u}`);
|
||||
}
|
||||
|
||||
/** Mode badge: the stored usage when we have one, else the wiring shape. */
|
||||
export function modeLabel(wf: Workflow | null, ds: DeskSchema, kind: DeskKind): string {
|
||||
if (wf?.usage) return usageLabel(wf.usage);
|
||||
if (ds.lists.some((g) => g.kind === 'image_list')) return t('studio.modeRefToVideo');
|
||||
if (ds.slots.some((s) => s.firstFrame)) return t('studio.modeImageToVideo');
|
||||
if (kind === 'image' && ds.slots.some((s) => s.mediaKind === 'image' && !s.mask))
|
||||
return t('studio.modeImageEdit');
|
||||
if (ds.slots.length) return ds.slots.map((s) => s.label).join(' · ');
|
||||
return kind === 'image' ? t('studio.modeTextToImage') : t('studio.modeTextToVideo');
|
||||
}
|
||||
|
||||
/** Picker row summary of a workflow's real media inputs, e.g. 参考图 1–9 张. */
|
||||
export function slotSummary(wf: Workflow): string {
|
||||
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
|
||||
const parts = [
|
||||
...ds.slots.map((s) => t('desk.slotOne', { label: s.label })),
|
||||
...ds.lists.map((g) => t('desk.slotRange', { label: g.label, min: g.min ?? 1, max: g.max ?? 9 })),
|
||||
];
|
||||
return parts.length ? parts.join(' · ') : t('desk.promptOnly');
|
||||
}
|
||||
|
||||
/** Short chip text for one common parameter value. */
|
||||
export function chipValue(inp: ComfyDynamicInput, values: FormValues, ds: DeskSchema): string {
|
||||
const raw = values[inp.nodeId] ?? inp.defaultValue ?? '';
|
||||
const v = Array.isArray(raw) ? raw.join(',') : String(raw);
|
||||
if (inp === ds.ratio) {
|
||||
const size = outputSize(ds, values);
|
||||
return size ? `${ratioShort(v)} · ${size.width}×${size.height}` : ratioShort(v);
|
||||
}
|
||||
if (inp === ds.duration) return v ? t('desk.chipSeconds', { v }) : t('studio.durationLabel');
|
||||
if (inp === ds.seed) return t('desk.chipSeed', { v: v.length > 8 ? `${v.slice(0, 8)}…` : v || '—' });
|
||||
if (inp === ds.resolution) return t('desk.chipResolution', { v: v || '—' });
|
||||
return v || inp.label;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { QueryClient } from '@tanstack/svelte-query';
|
||||
import { apiErrorMessage, playgroundApi } from '$lib/api';
|
||||
import type { Job } from '$lib/comfy/types';
|
||||
import { showToast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 撤销删除 (spec-v2 §四.4 删除两级制 · HEL-654 D 批): deleting a single
|
||||
* take/task never opens a dialog. The row leaves the view right away, a
|
||||
* 5-second Toast offers 撤销, and the backend DELETE is only sent when
|
||||
* that window closes without an undo — the backend removes result files,
|
||||
* so an undo after the call would be impossible. Leaving the page (SPA
|
||||
* route change or hard unload) flushes pending deletes instead of
|
||||
* dropping them.
|
||||
*/
|
||||
|
||||
export interface TakeDeleteHandlers {
|
||||
/** Localized toast message for this take. */
|
||||
message: (job: Job) => string;
|
||||
/** Undo: the row comes back — reselect / replay its entrance here. */
|
||||
onUndo?: (job: Job) => void;
|
||||
/** Called after the delete really hit the backend. */
|
||||
onCommitted?: (job: Job) => void;
|
||||
}
|
||||
|
||||
export interface TakeDeleteQueue {
|
||||
/** Ids whose rows should be hidden (commit pending). */
|
||||
readonly pending: SvelteSet<number>;
|
||||
request: (job: Job, handlers: TakeDeleteHandlers) => void;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
const UNDO_MS = 5000;
|
||||
|
||||
export function createTakeDeleteQueue(client: QueryClient): TakeDeleteQueue {
|
||||
const pending = new SvelteSet<number>();
|
||||
const entries = new Map<
|
||||
number,
|
||||
{
|
||||
job: Job;
|
||||
handlers: TakeDeleteHandlers;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
>();
|
||||
let destroyed = false;
|
||||
|
||||
async function invalidate() {
|
||||
await Promise.allSettled([
|
||||
client.invalidateQueries({ queryKey: ['playground-jobs'] }),
|
||||
client.invalidateQueries({ queryKey: ['jobs'] }),
|
||||
]);
|
||||
}
|
||||
|
||||
async function commit(id: number) {
|
||||
const entry = entries.get(id);
|
||||
if (!entry) return;
|
||||
entries.delete(id);
|
||||
try {
|
||||
await playgroundApi.deleteJob(id);
|
||||
pending.delete(id);
|
||||
entry.handlers.onCommitted?.(entry.job);
|
||||
} catch (err) {
|
||||
// Delete failed: the take stays — bring the row back honestly.
|
||||
pending.delete(id);
|
||||
showToast(
|
||||
t('studio.deleteFailedToast', { detail: apiErrorMessage(err) }),
|
||||
'error',
|
||||
);
|
||||
}
|
||||
await invalidate();
|
||||
}
|
||||
|
||||
/** Hard unload (tab close / external nav): fire-and-forget with keepalive. */
|
||||
function onPageHide() {
|
||||
for (const [id] of entries) {
|
||||
void fetch(`/api/playground/jobs/${id}`, {
|
||||
method: 'DELETE',
|
||||
keepalive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
if (inBrowser) window.removeEventListener('pagehide', onPageHide);
|
||||
// SPA route change: the JS context survives, normal commits are fine.
|
||||
for (const [id] of entries) {
|
||||
const entry = entries.get(id)!;
|
||||
clearTimeout(entry.timer);
|
||||
void commit(id);
|
||||
}
|
||||
}
|
||||
|
||||
const inBrowser = typeof window !== 'undefined';
|
||||
if (inBrowser) window.addEventListener('pagehide', onPageHide);
|
||||
|
||||
return {
|
||||
pending,
|
||||
request(job, handlers) {
|
||||
if (destroyed || pending.has(job.id)) return;
|
||||
pending.add(job.id);
|
||||
const timer = setTimeout(() => void commit(job.id), UNDO_MS);
|
||||
entries.set(job.id, { job, handlers, timer });
|
||||
showToast(handlers.message(job), 'info', UNDO_MS, {
|
||||
label: t('toast.undo'),
|
||||
run: () => {
|
||||
const e = entries.get(job.id);
|
||||
if (!e) return;
|
||||
clearTimeout(e.timer);
|
||||
entries.delete(job.id);
|
||||
pending.delete(job.id);
|
||||
handlers.onUndo?.(job);
|
||||
},
|
||||
});
|
||||
},
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { resolutionSelectorSize } from '$lib/comfy/resolution';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { classifySchema, inputRole, isFirstFrame, isLastFrame, isMedia } from './schema';
|
||||
|
||||
/** Read-only views of a finished / running take (one playground job). */
|
||||
|
||||
export type FeedFilter = 'all' | 'image' | 'video' | 'failed';
|
||||
|
||||
export function jobValues(job: Job): Record<string, unknown> {
|
||||
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function jobValueByRole(job: Job, wf: Workflow | undefined, ...roles: string[]): string | null {
|
||||
if (!wf) return null;
|
||||
const vals = jobValues(job);
|
||||
for (const inp of wf.input_schema ?? []) {
|
||||
const r = inputRole(inp);
|
||||
if (r && roles.includes(r)) {
|
||||
const v = vals[inp.nodeId];
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function promptOf(job: Job, wf: Workflow | undefined): string {
|
||||
return jobValueByRole(job, wf, 'prompt') ?? '';
|
||||
}
|
||||
|
||||
export interface TakeInput {
|
||||
label: string;
|
||||
path: string;
|
||||
kind: 'image' | 'video' | 'audio';
|
||||
}
|
||||
|
||||
/** Media the take was generated from, in workflow order. */
|
||||
export function inputsOf(job: Job, wf: Workflow | undefined): TakeInput[] {
|
||||
if (!wf) return [];
|
||||
const kind = wf.kind;
|
||||
const vals = jobValues(job);
|
||||
const out: TakeInput[] = [];
|
||||
let refIdx = 0;
|
||||
for (const inp of wf.input_schema ?? []) {
|
||||
if (!isMedia(inp)) continue;
|
||||
const v = vals[inp.nodeId];
|
||||
const list = Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : [];
|
||||
const mk = inp.kind.startsWith('video') ? 'video' : inp.kind.startsWith('audio') ? 'audio' : 'image';
|
||||
list.forEach((p, i) => {
|
||||
if (typeof p !== 'string' || !p) return;
|
||||
let label = inp.label;
|
||||
if (inp.kind === 'image_list') label = t('studio.slotRefN', { n: i + 1 });
|
||||
else if (isFirstFrame(inp, kind)) label = t('studio.slotFirst');
|
||||
else if (isLastFrame(inp, kind)) label = t('studio.slotLast');
|
||||
else if (inp.kind === 'image' && inputRole(inp) !== 'mask') label = t('studio.slotRefN', { n: ++refIdx });
|
||||
out.push({ label, path: p, kind: mk });
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 888×1184 · 种子 42 · 5 秒 — values that differ per take. */
|
||||
export function specOf(job: Job, wf: Workflow | undefined): Array<{ label: string; value: string }> {
|
||||
const out: Array<{ label: string; value: string }> = [];
|
||||
if (!wf) return out;
|
||||
const w = jobValueByRole(job, wf, 'width');
|
||||
const h = jobValueByRole(job, wf, 'height');
|
||||
if (w && h) out.push({ label: t('desk.specSize'), value: `${w}×${h}` });
|
||||
else {
|
||||
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
|
||||
if (ds.ratio?.resolution) {
|
||||
const vals = jobValues(job);
|
||||
const size = resolutionSelectorSize(
|
||||
String(vals[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
|
||||
Number(vals[ds.ratio.resolution.megapixels] ?? ds.megapixels?.defaultValue),
|
||||
ds.ratio.resolution.multiple,
|
||||
);
|
||||
if (size) out.push({ label: t('desk.specSize'), value: `${size.width}×${size.height}` });
|
||||
}
|
||||
}
|
||||
const seed = jobValueByRole(job, wf, 'seed');
|
||||
if (seed) out.push({ label: t('studio.seedLabel'), value: seed });
|
||||
const dur = jobValueByRole(job, wf, 'duration');
|
||||
if (dur) out.push({ label: t('studio.durationLabel'), value: t('desk.chipSeconds', { v: dur }) });
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isVideoOutput(job: Job, path: string): boolean {
|
||||
return job.kind === 'video' && !/\.(png|jpe?g|webp)$/i.test(path);
|
||||
}
|
||||
|
||||
export function elapsedSec(job: Job, nowMs: number): number | null {
|
||||
const from = job.started_at ? parseDbTs(job.started_at) : parseDbTs(job.created_at);
|
||||
if (!Number.isFinite(from)) return null;
|
||||
const to = job.completed_at ? parseDbTs(job.completed_at) : nowMs;
|
||||
return Math.max(0, (to - from) / 1000);
|
||||
}
|
||||
|
||||
export function fmtDur(sec: number | null): string {
|
||||
if (sec == null) return '—';
|
||||
if (sec < 60) return t('desk.durSec', { s: Math.floor(sec) });
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return t('desk.durMin', { m, s: String(s).padStart(2, '0') });
|
||||
}
|
||||
|
||||
export function fmtClock(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 今天 / 昨天 / 9月28日 heading for the feed. */
|
||||
export function dayLabel(ms: number, nowMs: number): string {
|
||||
const d = new Date(ms);
|
||||
const today = new Date(nowMs);
|
||||
const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
||||
const diff = Math.round((startOf(today) - startOf(d)) / 86_400_000);
|
||||
if (diff === 0) return t('desk.today');
|
||||
if (diff === 1) return t('desk.yesterday');
|
||||
return t('desk.dayDate', { m: d.getMonth() + 1, d: d.getDate() });
|
||||
}
|
||||
|
||||
export function matchesFilter(job: Job, filter: FeedFilter): boolean {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'failed') return jobStatusInfo(job).tone === 'fail';
|
||||
return job.kind === filter;
|
||||
}
|
||||
@@ -39,7 +39,8 @@
|
||||
function segClass(key: string): string {
|
||||
const state = stats.stage_status?.[key];
|
||||
if (state === 'confirmed') return 'seg-confirmed';
|
||||
if (state === 'running' || state === 'awaiting_confirm') return 'seg-draft';
|
||||
if (state === 'running') return 'seg-running';
|
||||
if (state === 'awaiting_confirm') return 'seg-draft';
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -70,7 +71,7 @@
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.running')}` };
|
||||
}
|
||||
if (state === 'awaiting_confirm') {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.draft')}` };
|
||||
return { cls: 'st-pending', label: `${stageName} · ${t('workbench.state.draft')}` };
|
||||
}
|
||||
if (stats.stage_confirmed > 0) {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('home.statusProgress')}` };
|
||||
@@ -329,7 +330,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(22, 19, 10, 0.82);
|
||||
color: color-mix(in srgb, var(--accent-ink) 82%, transparent);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.status-tag {
|
||||
@@ -340,7 +341,7 @@
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
background: rgba(13, 14, 17, 0.78);
|
||||
background: var(--media-scrim);
|
||||
border: 1px solid var(--border-strong);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
@@ -350,7 +351,10 @@
|
||||
color: var(--text-2);
|
||||
}
|
||||
.st-progress {
|
||||
color: var(--accent);
|
||||
color: var(--run);
|
||||
}
|
||||
.st-pending {
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-done {
|
||||
color: var(--success);
|
||||
@@ -377,7 +381,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.arrow {
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
@@ -409,8 +413,11 @@
|
||||
border-radius: 2px;
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.seg-running {
|
||||
background: var(--run);
|
||||
}
|
||||
.seg-draft {
|
||||
background: var(--accent);
|
||||
background: var(--warn);
|
||||
}
|
||||
.seg-confirmed {
|
||||
background: var(--success);
|
||||
@@ -425,8 +432,8 @@
|
||||
margin-left: auto;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -462,7 +469,7 @@
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: rgba(13, 14, 17, 0.6);
|
||||
background: var(--media-scrim);
|
||||
color: var(--text-2);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { projects } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
import { NAV } from './nav';
|
||||
|
||||
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
interface Entry {
|
||||
key: string;
|
||||
group: 'pages' | 'projects';
|
||||
label: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
|
||||
let query = $state('');
|
||||
let activeIndex = $state(0);
|
||||
let list = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const projectsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['projects'],
|
||||
queryFn: projects.list,
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
);
|
||||
|
||||
const entries = $derived.by<Entry[]>(() => {
|
||||
const pages: Entry[] = NAV.map((item) => ({
|
||||
key: `page-${item.id}`,
|
||||
group: 'pages',
|
||||
label: t(item.labelKey),
|
||||
icon: item.icon,
|
||||
href: item.href,
|
||||
}));
|
||||
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
|
||||
key: `project-${p.id}`,
|
||||
group: 'projects',
|
||||
label: p.title,
|
||||
icon: 'story',
|
||||
href: `/workbench/${p.id}`,
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = [...pages, ...projectEntries];
|
||||
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void query;
|
||||
activeIndex = 0;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) query = '';
|
||||
});
|
||||
|
||||
function optionId(index: number) {
|
||||
return `${listId}-${index}`;
|
||||
}
|
||||
|
||||
function go(entry: Entry) {
|
||||
open = false;
|
||||
void goto(entry.href);
|
||||
}
|
||||
|
||||
function move(step: number) {
|
||||
if (entries.length === 0) return;
|
||||
activeIndex = (activeIndex + step + entries.length) % entries.length;
|
||||
list?.querySelector(`#${optionId(activeIndex)}`)?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
} else if (event.key === 'Enter') {
|
||||
const entry = entries[activeIndex];
|
||||
if (entry) {
|
||||
event.preventDefault();
|
||||
go(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function focusOnMount(node: HTMLInputElement) {
|
||||
requestAnimationFrame(() => node.focus());
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title={t('shell.searchTitle')}>
|
||||
<div class="palette">
|
||||
<div class="palette-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input
|
||||
use:focusOnMount
|
||||
class="palette-input"
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder={t('shell.searchPlaceholder')}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={entries.length ? optionId(activeIndex) : undefined}
|
||||
aria-label={t('shell.searchTitle')}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
<div class="palette-list" id={listId} role="listbox" bind:this={list} aria-label={t('shell.searchTitle')}>
|
||||
{#if entries.length === 0}
|
||||
<p class="palette-empty">{t('shell.searchEmpty', { q: query.trim() })}</p>
|
||||
{/if}
|
||||
{#each entries as entry, i (entry.key)}
|
||||
{#if i === 0 || entries[i - 1].group !== entry.group}
|
||||
<div class="a-menu-group" role="presentation">
|
||||
{entry.group === 'pages' ? t('shell.searchGroupPages') : t('shell.searchGroupProjects')}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
id={optionId(i)}
|
||||
class="a-menu-item"
|
||||
class:active={i === activeIndex}
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
aria-selected={i === activeIndex}
|
||||
onpointermove={() => (activeIndex = i)}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => go(entry)}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<Icon name={entry.icon} size={15} />
|
||||
<span class="a-menu-label">{entry.label}</span>
|
||||
<span class="a-menu-meta mono">{entry.href}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="palette-hint">
|
||||
<span class="a-kbd">↑↓</span>{t('shell.searchHintMove')}
|
||||
<span class="a-kbd">Enter</span>{t('shell.searchHintOpen')}
|
||||
<span class="a-kbd">Esc</span>{t('shell.searchHintClose')}
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.palette {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.palette-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-5);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.palette-search:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.palette-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.palette-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-list {
|
||||
max-height: min(360px, 50vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.palette-empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.palette-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-hint .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-3);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.palette-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { projects } from '$lib/api';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from './ShellPop.svelte';
|
||||
|
||||
/**
|
||||
* 顶栏范围胶囊 — the project picker that replaced the old sidebar project
|
||||
* entry: shows the project the page belongs to (or 全部项目) and lists
|
||||
* recent projects, each opening its five-stage workbench.
|
||||
*/
|
||||
interface Props {
|
||||
projectId: number | null;
|
||||
onProjectsList: boolean;
|
||||
}
|
||||
|
||||
let { projectId, onProjectsList }: Props = $props();
|
||||
|
||||
const RECENT_LIMIT = 8;
|
||||
let open = $state(false);
|
||||
|
||||
const projectsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['projects'],
|
||||
queryFn: projects.list,
|
||||
enabled: open || projectId != null,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
);
|
||||
|
||||
const all = $derived($projectsQuery.data ?? []);
|
||||
const current = $derived(projectId == null ? null : (all.find((p) => p.id === projectId) ?? null));
|
||||
const recent = $derived(
|
||||
[...all]
|
||||
.sort((a, b) => (parseDbTs(b.updated_at) || 0) - (parseDbTs(a.updated_at) || 0))
|
||||
.slice(0, RECENT_LIMIT),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="scope">
|
||||
<button
|
||||
type="button"
|
||||
class="status-chip scope-btn"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={t('shell.scopeHint')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span class="scope-label">{current?.title ?? t('shell.scopeAll')}</span>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
|
||||
<ShellPop bind:open label={t('shell.scopeHint')} width="300px">
|
||||
<a
|
||||
class="a-menu-item"
|
||||
href="/"
|
||||
aria-current={onProjectsList ? 'page' : undefined}
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
<Icon name="folder" size={15} />
|
||||
<span class="a-menu-label">{t('shell.scopeAllList')}</span>
|
||||
{#if all.length > 0}<span class="a-menu-meta mono">{all.length}</span>{/if}
|
||||
</a>
|
||||
<div class="a-menu-group">{t('shell.scopeRecent')}</div>
|
||||
{#if $projectsQuery.isPending}
|
||||
<p class="scope-note">{t('common.loading')}…</p>
|
||||
{:else if $projectsQuery.isError}
|
||||
<p class="scope-note err">{t('shell.scopeLoadFailed')}</p>
|
||||
{:else if recent.length === 0}
|
||||
<a class="a-menu-item" href="/" onclick={() => (open = false)}>
|
||||
<Icon name="plus" size={15} />
|
||||
<span class="a-menu-label">{t('shell.scopeEmpty')}</span>
|
||||
</a>
|
||||
{:else}
|
||||
{#each recent as project (project.id)}
|
||||
<a
|
||||
class="a-menu-item"
|
||||
href="/workbench/{project.id}"
|
||||
aria-current={project.id === projectId ? 'true' : undefined}
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
<Icon name="story" size={15} />
|
||||
<span class="a-menu-label">{project.title}</span>
|
||||
{#if project.stats}
|
||||
<span class="a-menu-meta">
|
||||
{t('shell.scopeStage', { n: project.stats.stage_confirmed })}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scope {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scope-btn {
|
||||
max-width: 220px;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.scope-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.scope-note {
|
||||
margin: 0;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.scope-note.err {
|
||||
color: var(--err);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.scope-btn {
|
||||
max-width: 132px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { workflows, type Job } from '$lib/api';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { jobSourceLabel } from '$lib/jobLabels';
|
||||
import { jobStatusInfo, type JobStatusTone } from '$lib/jobStatus';
|
||||
import ShellPop from './ShellPop.svelte';
|
||||
|
||||
/**
|
||||
* 顶栏队列胶囊 + 任务托盘 (§二). The dot is cyan only while something is
|
||||
* actually generating — queued work stays gray, as on the task page.
|
||||
*/
|
||||
let { jobs }: { jobs: Job[] } = $props();
|
||||
|
||||
const TRAY_LIMIT = 6;
|
||||
const TAG_CLASS: Record<JobStatusTone, string> = { ok: 'ok', run: 'run', queue: '', fail: 'err' };
|
||||
|
||||
let open = $state(false);
|
||||
let capsule = $state<HTMLButtonElement | null>(null);
|
||||
|
||||
const workflowsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['workflows'],
|
||||
queryFn: workflows.list,
|
||||
enabled: open,
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
);
|
||||
|
||||
const active = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running'));
|
||||
const running = $derived(active.filter((j) => jobStatusInfo(j).tone === 'run').length);
|
||||
const queued = $derived(active.length - running);
|
||||
const recent = $derived([...jobs].sort((a, b) => b.id - a.id).slice(0, TRAY_LIMIT));
|
||||
|
||||
const label = $derived.by(() => {
|
||||
const parts: string[] = [];
|
||||
if (running > 0) parts.push(t('shell.queueRunning', { n: running }));
|
||||
if (queued > 0) parts.push(t('shell.queueQueued', { n: queued }));
|
||||
return parts.length ? parts.join(' · ') : t('shell.queueIdle');
|
||||
});
|
||||
|
||||
// 任务数变化时胶囊轻弹一次(320ms,只播一次).
|
||||
let lastCount: number | null = null;
|
||||
$effect(() => {
|
||||
const count = active.length;
|
||||
if (lastCount !== null && count !== lastCount && capsule) {
|
||||
capsule.classList.remove('bump');
|
||||
void capsule.offsetWidth;
|
||||
capsule.classList.add('bump');
|
||||
}
|
||||
lastCount = count;
|
||||
});
|
||||
|
||||
function workflowName(job: Job): string {
|
||||
return (
|
||||
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)?.name ??
|
||||
t('studio.jobName', { id: job.id })
|
||||
);
|
||||
}
|
||||
|
||||
function timeOf(job: Job): string {
|
||||
const ts = parseDbTs(job.created_at);
|
||||
if (!Number.isFinite(ts)) return '—';
|
||||
const d = new Date(ts);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tray">
|
||||
<button
|
||||
bind:this={capsule}
|
||||
type="button"
|
||||
class="status-chip queue-btn"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onclick={() => (open = !open)}
|
||||
onanimationend={() => capsule?.classList.remove('bump')}
|
||||
>
|
||||
<span class="status-chip-dot {running > 0 ? 'run' : 'idle'}" aria-hidden="true"></span>
|
||||
<span class="queue-label">{label}</span>
|
||||
<span class="queue-count mono">{active.length}</span>
|
||||
</button>
|
||||
|
||||
<ShellPop bind:open label={t('shell.trayTitle')} align="end" width="340px">
|
||||
<div class="a-menu-group">{t('shell.trayRecent')}</div>
|
||||
{#if recent.length === 0}
|
||||
<p class="tray-note">{t('shell.trayEmpty')}</p>
|
||||
{:else}
|
||||
{#each recent as job (job.id)}
|
||||
{@const info = jobStatusInfo(job)}
|
||||
<a class="a-menu-item tray-row" href="/tasks" title={info.detail} onclick={() => (open = false)}>
|
||||
<span class="tray-copy">
|
||||
<span class="a-menu-label">{workflowName(job)}</span>
|
||||
<span class="tray-meta">{jobSourceLabel(job)} · <span class="mono">{timeOf(job)}</span></span>
|
||||
</span>
|
||||
<span class="a-tag {TAG_CLASS[info.tone]}">{info.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
<a class="a-menu-item tray-all" href="/tasks" onclick={() => (open = false)}>
|
||||
<span class="a-menu-label">{t('shell.trayAll')}</span>
|
||||
</a>
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tray {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.queue-btn:global(.bump) {
|
||||
animation: queue-bump 320ms var(--ease-out);
|
||||
}
|
||||
@keyframes queue-bump {
|
||||
40% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
.queue-count {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.queue-label {
|
||||
display: none;
|
||||
}
|
||||
.queue-count {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
.tray-row {
|
||||
align-items: center;
|
||||
}
|
||||
.tray-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.tray-meta {
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tray-note {
|
||||
margin: 0;
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-5);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.tray-all {
|
||||
margin-top: var(--sp-1);
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: 0 0 var(--r-md) var(--r-md);
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cubicOut, quintOut } from 'svelte/easing';
|
||||
|
||||
/**
|
||||
* Anchored pop for the shell (§四.3 pop): opens 180ms from its trigger,
|
||||
* closes on Esc / outside press, Tab cycles inside, ↑↓ move between rows.
|
||||
* At ≤760px it becomes a bottom sheet. Render it next to its trigger
|
||||
* inside a `position: relative` wrapper — that wrapper counts as inside.
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
label: string;
|
||||
align?: 'start' | 'end';
|
||||
/** `top` opens above the trigger (triggers near the bottom edge, e.g. the composer). */
|
||||
placement?: 'bottom' | 'top';
|
||||
width?: string;
|
||||
/** Set false when the content runs its own ↑↓ roving selection. */
|
||||
arrowRoving?: boolean;
|
||||
onclose?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
label,
|
||||
align = 'start',
|
||||
placement = 'bottom',
|
||||
width = '300px',
|
||||
arrowRoving = true,
|
||||
onclose,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
const SHEET_QUERY = '(max-width: 760px)';
|
||||
|
||||
let panel = $state<HTMLDivElement | null>(null);
|
||||
let scrim = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function focusables(): HTMLElement[] {
|
||||
if (!panel) return [];
|
||||
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
open = false;
|
||||
return;
|
||||
}
|
||||
const list = focusables();
|
||||
if (list.length === 0) return;
|
||||
const current = document.activeElement as HTMLElement | null;
|
||||
const index = current ? list.indexOf(current) : -1;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
if (!arrowRoving || current?.tagName === 'SELECT') return;
|
||||
event.preventDefault();
|
||||
const step = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const next = index < 0 ? (step > 0 ? 0 : list.length - 1) : index + step;
|
||||
list[(next + list.length) % list.length].focus();
|
||||
} else if (event.key === 'Tab') {
|
||||
if (event.shiftKey && index <= 0) {
|
||||
event.preventDefault();
|
||||
list[list.length - 1].focus();
|
||||
} else if (!event.shiftKey && (index < 0 || index === list.length - 1)) {
|
||||
event.preventDefault();
|
||||
list[0].focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const host = panel?.parentElement;
|
||||
const target = event.target as Node;
|
||||
if (!host) return;
|
||||
if (!host.contains(target) || target === scrim) open = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const invoker = document.activeElement as HTMLElement | null;
|
||||
document.addEventListener('keydown', handleKeydown, true);
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
requestAnimationFrame(() => focusables()[0]?.focus());
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown, true);
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
const focusWasInside =
|
||||
document.activeElement === document.body || !!panel?.contains(document.activeElement);
|
||||
if (focusWasInside && invoker?.isConnected) invoker.focus();
|
||||
onclose?.();
|
||||
};
|
||||
});
|
||||
|
||||
function cssMs(name: string, fallback: number): number {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(name);
|
||||
const ms = parseFloat(raw);
|
||||
return Number.isFinite(ms) ? ms : fallback;
|
||||
}
|
||||
|
||||
function popIn(_node: Element) {
|
||||
const reduced = document.documentElement.dataset.reduce === '1';
|
||||
const sheet = window.matchMedia(SHEET_QUERY).matches;
|
||||
if (reduced) {
|
||||
return { duration: cssMs('--d-state', 120), css: (t: number) => `opacity: ${t}` };
|
||||
}
|
||||
if (sheet) {
|
||||
return {
|
||||
duration: cssMs('--d-drawer', 280),
|
||||
easing: quintOut,
|
||||
css: (t: number) => `transform: translateY(${(1 - t) * 100}%)`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
duration: cssMs('--d-pop', 180),
|
||||
easing: cubicOut,
|
||||
css: (t: number) => `opacity: ${t}; transform: scale(${0.96 + 0.04 * t})`,
|
||||
};
|
||||
}
|
||||
|
||||
function fadeScrim(_node: Element) {
|
||||
return { duration: cssMs('--d-pop', 180), css: (t: number) => `opacity: ${t}` };
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="pop-scrim" bind:this={scrim} aria-hidden="true" transition:fadeScrim></div>
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="shell-pop a-float align-{align} place-{placement}"
|
||||
style:--pop-w={width}
|
||||
role="dialog"
|
||||
aria-label={label}
|
||||
transition:popIn
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.shell-pop {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--sp-2));
|
||||
z-index: 60;
|
||||
width: var(--pop-w);
|
||||
max-width: calc(100vw - 2 * var(--sp-5));
|
||||
max-height: min(520px, 70vh);
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-2);
|
||||
box-sizing: border-box;
|
||||
transform-origin: top left;
|
||||
}
|
||||
.shell-pop.align-start {
|
||||
left: 0;
|
||||
}
|
||||
.shell-pop.align-end {
|
||||
right: 0;
|
||||
transform-origin: top right;
|
||||
}
|
||||
.shell-pop.place-top {
|
||||
top: auto;
|
||||
bottom: calc(100% + var(--sp-2));
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
.shell-pop.place-top.align-end {
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
.pop-scrim {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.pop-scrim {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 59;
|
||||
background: var(--overlay);
|
||||
}
|
||||
.shell-pop,
|
||||
.shell-pop.place-top {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
max-height: 78vh;
|
||||
border-radius: var(--r-lg) var(--r-lg) 0 0;
|
||||
padding: var(--sp-3) var(--sp-3) calc(var(--sp-3) + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
|
||||
export type NavId =
|
||||
| 'projects'
|
||||
| 'workbench'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'tasks'
|
||||
| 'agents'
|
||||
| 'connections'
|
||||
| 'models'
|
||||
| 'library'
|
||||
| 'settings';
|
||||
|
||||
export interface NavItem {
|
||||
id: NavId;
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
}
|
||||
|
||||
// Order is fixed by spec-v2 §3: 项目 / 创作工作台 / 生图 / 生视频 / 生成任务 /
|
||||
// 智能体 / ComfyUI 连接 / 模型设置 / 设置. HEL-649 §二 puts the connection
|
||||
// and settings entries at the foot of the 60px rail.
|
||||
export const NAV_MAIN: NavItem[] = [
|
||||
{ id: 'projects', labelKey: 'shell.navProjects', icon: 'home', href: '/' },
|
||||
{ id: 'workbench', labelKey: 'shell.navWorkbench', icon: 'story', href: '/workbench' },
|
||||
{ id: 'image', labelKey: 'shell.navImage', icon: 'image', href: '/image' },
|
||||
{ id: 'video', labelKey: 'shell.navVideo', icon: 'film', href: '/video' },
|
||||
{ id: 'tasks', labelKey: 'shell.navTasks', icon: 'queue', href: '/tasks' },
|
||||
{ id: 'agents', labelKey: 'shell.navAgents', icon: 'sparkle', href: '/agents' },
|
||||
];
|
||||
|
||||
export const NAV_FOOT: NavItem[] = [
|
||||
{ id: 'connections', labelKey: 'shell.navConnections', icon: 'link', href: '/connections' },
|
||||
{ id: 'models', labelKey: 'shell.navModels', icon: 'assets', href: '/models' },
|
||||
{ id: 'library', labelKey: 'shell.navLibrary', icon: 'folder', href: '/library' },
|
||||
{ id: 'settings', labelKey: 'shell.navSettings', icon: 'settings', href: '/settings' },
|
||||
];
|
||||
|
||||
export const NAV: NavItem[] = [...NAV_MAIN, ...NAV_FOOT];
|
||||
|
||||
export interface TabItem {
|
||||
id: 'projects' | 'workbench' | 'generate';
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
/** Pages that light this tab up. */
|
||||
covers: NavId[];
|
||||
}
|
||||
|
||||
/** ≤760px bottom tab bar (§二): 项目 / 创作 / 生成 / 更多. */
|
||||
export const TABS: TabItem[] = [
|
||||
{ id: 'projects', labelKey: 'shell.tabProjects', icon: 'home', href: '/', covers: ['projects'] },
|
||||
{ id: 'workbench', labelKey: 'shell.tabCreate', icon: 'story', href: '/workbench', covers: ['workbench'] },
|
||||
{
|
||||
id: 'generate',
|
||||
labelKey: 'shell.tabGenerate',
|
||||
icon: 'image',
|
||||
href: '/image',
|
||||
covers: ['image', 'video'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Every page a tab does not link to directly lives in the 更多 sheet. 生视频
|
||||
* is still its own route, so it is listed there too until the two
|
||||
* generation pages merge.
|
||||
*/
|
||||
export const MORE_ITEMS: NavItem[] = NAV.filter(
|
||||
(item) => !TABS.some((tab) => tab.href === item.href),
|
||||
);
|
||||
@@ -1,37 +1,33 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* A-style media upload slot (spec-v2 §5 上传槽, ref-upload-spec §2):
|
||||
* empty = 1.5px dashed border + "click or drop" + format line, drag-hover
|
||||
* = amber dashed, filled = solid border + thumb + name + size + 替换 + ✕,
|
||||
* error = red border + red reason.
|
||||
* Single media input slot as a composer tile (spec-v2 §3.3 输入槽):
|
||||
* empty = dashed + "+", drag-hover = gold, uploading = ring, filled = thumb
|
||||
* (click to replace) with a ✕ on hover/focus. `need` = required but empty
|
||||
* after a blocked 生成 (amber); `error` = rejected file / invalid (red).
|
||||
*/
|
||||
interface Props {
|
||||
label: string;
|
||||
/** Hint on the right of the label, e.g. 必填 · 1 张 / 可从生图结果送入. */
|
||||
/** Hint after the label, e.g. 必填 · 1 张. */
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
/** Current value: a server-side path ('' = empty). */
|
||||
value: string;
|
||||
kind: 'image' | 'video' | 'audio';
|
||||
/** Thumb aspect: wide = 16:9 (first/last frame slots). */
|
||||
aspect?: 'square' | 'wide';
|
||||
/** Empty-state main line (defaults to 点击或拖入). */
|
||||
emptyText?: string;
|
||||
/** Empty-state mono line, e.g. accepted formats and size limit. */
|
||||
emptySub?: string;
|
||||
/** Byte size of the current file when known. */
|
||||
size?: number;
|
||||
uploading?: boolean;
|
||||
uploadName?: string;
|
||||
need?: boolean;
|
||||
error?: string;
|
||||
/** Show the "pick from image results" link (video-page image slots). */
|
||||
/** Show the 从结果选 action (image slots fed from finished takes). */
|
||||
allowFromResults?: boolean;
|
||||
/** Anchor id so 「还缺:…」 can jump focus here. */
|
||||
anchorId?: string;
|
||||
onFile: (file: File) => void;
|
||||
onFromResults?: () => void;
|
||||
onClear: () => void;
|
||||
@@ -43,14 +39,13 @@
|
||||
required = false,
|
||||
value,
|
||||
kind,
|
||||
aspect = 'square',
|
||||
emptyText,
|
||||
emptySub,
|
||||
size,
|
||||
uploading = false,
|
||||
uploadName,
|
||||
need = false,
|
||||
error,
|
||||
allowFromResults = false,
|
||||
anchorId,
|
||||
onFile,
|
||||
onFromResults,
|
||||
onClear,
|
||||
@@ -66,7 +61,10 @@
|
||||
dims = '';
|
||||
});
|
||||
|
||||
const meta = $derived([dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '));
|
||||
const filled = $derived(!!value && !broken);
|
||||
const meta = $derived(
|
||||
[uploadBaseName(value), dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '),
|
||||
);
|
||||
|
||||
function accept(): string {
|
||||
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
|
||||
@@ -87,17 +85,34 @@
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragDepth = 0;
|
||||
if (uploading) return;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) onFile(file);
|
||||
}
|
||||
|
||||
const dragHandlers = {
|
||||
ondragenter: (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
},
|
||||
ondragleave: () => (dragDepth = Math.max(0, dragDepth - 1)),
|
||||
ondragover: (e: DragEvent) => e.preventDefault(),
|
||||
ondrop: onDrop,
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="media-slot" class:wide={aspect === 'wide'}>
|
||||
<div class="slot-label">
|
||||
<span class="lbl">{label}{#if required}<em>*</em>{/if}</span>
|
||||
{#if hint}<span class="slot-hint">{hint}</span>{/if}
|
||||
<div class="a-slot">
|
||||
<div class="a-slot-label">
|
||||
<span>{label}</span>
|
||||
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
|
||||
{#if hint}<span>{hint}</span>{/if}
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -113,110 +128,61 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if value && !broken}
|
||||
<div class="slot-filled" class:invalid={!!error}>
|
||||
<div class="slot-thumb">
|
||||
{#if uploading}
|
||||
<Spinner size="sm" />
|
||||
{:else if kind === 'image'}
|
||||
<img src={assetUrl(value)} alt={label} onload={noteDims} onerror={() => (broken = true)} />
|
||||
{:else if kind === 'video'}
|
||||
<video src={assetUrl(value)} preload="metadata" muted></video>
|
||||
<span class="kind-badge" aria-hidden="true"><Icon name="film" size={12} /></span>
|
||||
{:else}
|
||||
<span class="kind-badge" aria-hidden="true"><Icon name="music" size={12} /></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="slot-meta">
|
||||
<span class="name" title={uploading ? uploadName : uploadBaseName(value)}>
|
||||
{uploading ? uploadName : uploadBaseName(value)}
|
||||
</span>
|
||||
{#if meta && !uploading}<span class="mono dims">{meta}</span>{/if}
|
||||
</div>
|
||||
<div class="slot-actions">
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="mini-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="mini-link" disabled={uploading} onclick={pick}>
|
||||
{t('studio.refReplace')}
|
||||
</button>
|
||||
<div class="a-slot-row">
|
||||
{#if filled}
|
||||
<div class="a-tile" class:drag={dragDepth > 0} class:bad={!!error} {...dragHandlers}>
|
||||
<button
|
||||
type="button"
|
||||
class="slot-clear"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
id={anchorId}
|
||||
class="a-tile-hit"
|
||||
title={`${t('studio.refReplace')} · ${meta}`}
|
||||
aria-label={`${t('studio.refReplace')} ${label}`}
|
||||
disabled={uploading}
|
||||
onclick={onClear}
|
||||
onclick={pick}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
{#if kind === 'image'}
|
||||
<img src={assetUrl(value)} alt="" onload={noteDims} onerror={() => (broken = true)} />
|
||||
{:else if kind === 'video'}
|
||||
<video src={assetUrl(value)} preload="metadata" muted playsinline></video>
|
||||
{:else}
|
||||
<Icon name="music" size={18} />
|
||||
{/if}
|
||||
</button>
|
||||
{#if uploading}
|
||||
<span class="a-tile-ring" title={uploadName}><i></i></span>
|
||||
{:else}
|
||||
<button type="button" class="a-tile-x" aria-label={`${t('studio.deleteBtn')} ${label}`} onclick={onClear}>
|
||||
<Icon name="close" size={10} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="slot-empty"
|
||||
class:drag={dragDepth > 0}
|
||||
class:invalid={!!error}
|
||||
class:wide={aspect === 'wide'}
|
||||
disabled={uploading}
|
||||
aria-busy={uploading || undefined}
|
||||
onclick={pick}
|
||||
ondragenter={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
}}
|
||||
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if uploading}
|
||||
<Spinner size="sm" />
|
||||
<span class="pick-text">{uploadName ?? t('studio.slotPick')}</span>
|
||||
{:else}
|
||||
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'image'} size={16} />
|
||||
<span class="pick-text">{emptyText ?? t('studio.slotPick')}</span>
|
||||
{#if emptySub}<span class="pick-sub mono">{emptySub}</span>{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="from-results-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResults')}
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
id={anchorId}
|
||||
class="a-tile add"
|
||||
class:drag={dragDepth > 0}
|
||||
class:need={need && !error}
|
||||
class:bad={!!error}
|
||||
title={t('studio.slotPick')}
|
||||
aria-label={`${label} · ${t('studio.slotPick')}`}
|
||||
aria-busy={uploading || undefined}
|
||||
disabled={uploading}
|
||||
onclick={pick}
|
||||
{...dragHandlers}
|
||||
>
|
||||
{#if uploading}
|
||||
<span class="a-tile-ring" title={uploadName}><i></i></span>
|
||||
{:else}
|
||||
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'plus'} size={18} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if error}<span class="slot-error" role="alert">{error}</span>{/if}
|
||||
</div>
|
||||
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.media-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.slot-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lbl em {
|
||||
color: var(--error);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.slot-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -226,164 +192,4 @@
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.slot-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 86px;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
color 150ms;
|
||||
}
|
||||
.media-slot.wide .slot-empty {
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.slot-empty:hover:not(:disabled) {
|
||||
color: var(--text-2);
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
.slot-empty.drag {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.slot-empty.invalid {
|
||||
border-color: rgba(229, 72, 77, 0.65);
|
||||
}
|
||||
.slot-empty:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.pick-text {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.pick-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.from-results-link {
|
||||
align-self: flex-end;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.from-results-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.slot-filled {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
padding: 8px;
|
||||
}
|
||||
.slot-filled.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.slot-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.media-slot.wide .slot-thumb {
|
||||
width: 88px;
|
||||
height: 50px;
|
||||
}
|
||||
.slot-thumb img,
|
||||
.slot-thumb video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.slot-thumb .kind-badge {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.slot-meta {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-size: 12.5px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dims {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.slot-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mini-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-link:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini-link:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.slot-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.slot-clear:hover {
|
||||
color: var(--error);
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.slot-error {
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
|
||||
import {
|
||||
IMAGE_SLOT_ACCEPT,
|
||||
formatBytes,
|
||||
mediaSlotAccept,
|
||||
uploadBaseName,
|
||||
type MediaSlotKind,
|
||||
} from '$lib/comfy/imageFile';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Reference-image grid (ref-upload-spec §2 `.refgrid`): numbered cells
|
||||
* 参考图 1…N in the order they reach the workflow, each with replace and
|
||||
* ✕; a dashed add cell while below `max`; rejected files show as error
|
||||
* cells that take no place in the list.
|
||||
* Reference-media list as numbered composer tiles (spec-v2 §3.3 参考图):
|
||||
* 1…N in the order they reach the workflow — click a tile to replace it,
|
||||
* ✕ to remove; a dashed add tile while below `max`; rejected files show
|
||||
* as red tiles that take no place in the list.
|
||||
*/
|
||||
interface Reject {
|
||||
name: string;
|
||||
@@ -19,6 +24,8 @@
|
||||
label: string;
|
||||
paths: string[];
|
||||
max: number;
|
||||
/** Media family of the slots — drives thumbnails, accept and copy. */
|
||||
mediaKind?: MediaSlotKind;
|
||||
/** Byte sizes of files uploaded in this session, by server path. */
|
||||
sizes?: Record<string, number>;
|
||||
/** Name of the file being uploaded (add or replace), if any. */
|
||||
@@ -26,8 +33,12 @@
|
||||
/** Index being replaced while uploading (-1 = adding). */
|
||||
uploadingIndex?: number;
|
||||
rejects?: Reject[];
|
||||
/** Below the minimum after a blocked 生成 (amber add tile). */
|
||||
need?: boolean;
|
||||
error?: string;
|
||||
allowFromResults?: boolean;
|
||||
/** Anchor id on the add tile so 「还缺:…」 can jump focus here. */
|
||||
anchorId?: string;
|
||||
onAdd: (files: File[]) => void;
|
||||
onReplace: (index: number, file: File) => void;
|
||||
onRemove: (index: number) => void;
|
||||
@@ -39,12 +50,15 @@
|
||||
label,
|
||||
paths,
|
||||
max,
|
||||
mediaKind = 'image',
|
||||
sizes = {},
|
||||
uploadingName,
|
||||
uploadingIndex = -1,
|
||||
rejects = [],
|
||||
need = false,
|
||||
error,
|
||||
allowFromResults = false,
|
||||
anchorId,
|
||||
onAdd,
|
||||
onReplace,
|
||||
onRemove,
|
||||
@@ -57,9 +71,39 @@
|
||||
let replaceIndex = $state(-1);
|
||||
let dragDepth = $state(0);
|
||||
let dims = $state<Record<string, string>>({});
|
||||
let durations = $state<Record<string, number>>({});
|
||||
|
||||
const uploading = $derived(!!uploadingName);
|
||||
const remaining = $derived(Math.max(0, max - paths.length));
|
||||
const accept = $derived(mediaKind === 'image' ? IMAGE_SLOT_ACCEPT : mediaSlotAccept(mediaKind));
|
||||
const isMedia = $derived(mediaKind !== 'image');
|
||||
const hintLabel = $derived(
|
||||
isMedia
|
||||
? t('studio.refHintMedia', { n: paths.length, max })
|
||||
: t('studio.refHint', { n: paths.length, max }),
|
||||
);
|
||||
|
||||
function noteDuration(path: string, e: Event): void {
|
||||
const el = e.currentTarget as HTMLVideoElement | HTMLAudioElement | null;
|
||||
if (el && Number.isFinite(el.duration)) durations = { ...durations, [path]: el.duration };
|
||||
}
|
||||
|
||||
function noteDims(path: string, e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
|
||||
}
|
||||
|
||||
function metaOf(path: string): string {
|
||||
const d = durations[path];
|
||||
return [
|
||||
uploadBaseName(path),
|
||||
dims[path],
|
||||
d != null && Number.isFinite(d) ? `${d.toFixed(1)}s` : null,
|
||||
sizes[path] != null ? formatBytes(sizes[path]) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function pickAdd() {
|
||||
if (uploading || !addInput) return;
|
||||
@@ -76,26 +120,24 @@
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragDepth = 0;
|
||||
if (uploading) return;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) onAdd(files);
|
||||
}
|
||||
|
||||
function noteDims(path: string, e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
|
||||
}
|
||||
|
||||
function metaOf(path: string): string {
|
||||
return [dims[path], sizes[path] != null ? formatBytes(sizes[path]) : null].filter(Boolean).join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="refgrid-field">
|
||||
<div class="slot-label">
|
||||
<span class="lbl">{label}<em>*</em></span>
|
||||
<span class="slot-hint">{t('studio.refHint', { n: paths.length, max })}</span>
|
||||
<div class="a-slot">
|
||||
<div class="a-slot-label">
|
||||
<span>{label}</span>
|
||||
<span class="req" aria-hidden="true">*</span>
|
||||
<span class="mono">{hintLabel}</span>
|
||||
{#if allowFromResults && onFromResults && remaining > 0}
|
||||
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -104,7 +146,7 @@
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={IMAGE_SLOT_ACCEPT}
|
||||
{accept}
|
||||
multiple
|
||||
onchange={(e) => {
|
||||
const files = Array.from(e.currentTarget.files ?? []);
|
||||
@@ -117,70 +159,86 @@
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={IMAGE_SLOT_ACCEPT}
|
||||
{accept}
|
||||
onchange={(e) => {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
if (f && replaceIndex >= 0) onReplace(replaceIndex, f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="refgrid">
|
||||
<div class="a-slot-row">
|
||||
{#each paths as path, i (path + i)}
|
||||
<div class="ref-cell" class:busy={uploadingIndex === i}>
|
||||
<div class="a-tile">
|
||||
<button
|
||||
type="button"
|
||||
class="ref-thumb"
|
||||
title={t('studio.refReplace')}
|
||||
class="a-tile-hit"
|
||||
title={`${t('studio.refReplace')} · ${metaOf(path)}`}
|
||||
aria-label={`${t('studio.refReplace')} ${label} ${i + 1}`}
|
||||
disabled={uploading}
|
||||
onclick={() => pickReplace(i)}
|
||||
>
|
||||
<img src={assetUrl(path)} alt={t('studio.slotRefN', { n: i + 1 })} onload={(e) => noteDims(path, e)} />
|
||||
<span class="ref-no mono">{i + 1}</span>
|
||||
{#if uploadingIndex === i}
|
||||
<span class="ref-busy"><Spinner size="sm" /></span>
|
||||
{#if mediaKind === 'image'}
|
||||
<img src={assetUrl(path)} alt="" onload={(e) => noteDims(path, e)} />
|
||||
{:else if mediaKind === 'video'}
|
||||
<video
|
||||
src={assetUrl(path)}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
onloadedmetadata={(e) => noteDuration(path, e)}
|
||||
></video>
|
||||
{:else}
|
||||
<Icon name="music" size={18} />
|
||||
<audio src={assetUrl(path)} preload="metadata" onloadedmetadata={(e) => noteDuration(path, e)}
|
||||
></audio>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ref-x"
|
||||
aria-label={t('studio.refRemove', { n: i + 1 })}
|
||||
onclick={() => onRemove(i)}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
</button>
|
||||
<span class="ref-name" title={uploadBaseName(path)}>{uploadBaseName(path)}</span>
|
||||
<span class="ref-meta mono">
|
||||
{metaOf(path)}
|
||||
<button type="button" class="mini-link" onclick={() => pickReplace(i)}>{t('studio.refReplace')}</button>
|
||||
</span>
|
||||
<span class="a-tile-idx">{i + 1}</span>
|
||||
{#if uploadingIndex === i}
|
||||
<span class="a-tile-ring" title={uploadingName}><i></i></span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="a-tile-x"
|
||||
aria-label={isMedia
|
||||
? t('studio.refRemoveMedia', { kind: label, n: i + 1 })
|
||||
: t('studio.refRemove', { n: i + 1 })}
|
||||
onclick={() => onRemove(i)}
|
||||
>
|
||||
<Icon name="close" size={10} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each rejects as rej, i (rej.name + i)}
|
||||
<div class="ref-cell rejected" role="alert">
|
||||
<div class="ref-thumb error">
|
||||
<Icon name="close" size={16} />
|
||||
<span class="mono">{t('studio.refRejectedShort')}</span>
|
||||
</div>
|
||||
<div class="a-tile bad" title={`${rej.name} · ${rej.message}`} role="img" aria-label={rej.name}>
|
||||
<Icon name="alert" size={16} />
|
||||
<button
|
||||
type="button"
|
||||
class="ref-x"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
class="a-tile-x"
|
||||
aria-label={`${t('studio.deleteBtn')} ${rej.name}`}
|
||||
onclick={() => onDismissReject(i)}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
<Icon name="close" size={10} />
|
||||
</button>
|
||||
<span class="ref-name err" title={rej.name}>{rej.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if remaining > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="ref-add"
|
||||
id={anchorId}
|
||||
class="a-tile add"
|
||||
class:drag={dragDepth > 0}
|
||||
class:invalid={!!error}
|
||||
disabled={uploading}
|
||||
class:need={need && !error?.trim()}
|
||||
class:bad={!!error?.trim()}
|
||||
title={isMedia
|
||||
? t('studio.refAddSubMedia', { n: remaining, max })
|
||||
: t('studio.refAddSub', { n: remaining, max })}
|
||||
aria-label={isMedia ? t('studio.refAddMedia', { kind: label }) : t('studio.refAdd')}
|
||||
aria-busy={uploading || undefined}
|
||||
disabled={uploading}
|
||||
onclick={pickAdd}
|
||||
ondragenter={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -191,59 +249,24 @@
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if uploading && uploadingIndex < 0}
|
||||
<Spinner size="sm" />
|
||||
<span class="add-main">{uploadingName}</span>
|
||||
<span class="a-tile-ring" title={uploadingName}><i></i></span>
|
||||
{:else}
|
||||
<Icon name="plus" size={16} />
|
||||
<span class="add-main">{t('studio.refAdd')}</span>
|
||||
<span class="add-sub mono">{t('studio.refAddSub', { n: remaining, max })}</span>
|
||||
<Icon name="plus" size={18} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if allowFromResults && onFromResults && remaining > 0}
|
||||
<button type="button" class="from-results-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResults')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if remaining === 0}
|
||||
<span class="full-note">{t('studio.refFull', { max })}</span>
|
||||
<span class="a-slot-hint">{isMedia ? t('studio.refFullMedia', { max }) : t('studio.refFull', { max })}</span>
|
||||
{/if}
|
||||
{#each rejects as rej, i (rej.name + i)}
|
||||
<span class="slot-error">{rej.message}</span>
|
||||
<span class="a-slot-hint err" role="alert">{rej.message}</span>
|
||||
{/each}
|
||||
{#if error?.trim()}<span class="slot-error" role="alert">{error}</span>{/if}
|
||||
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.refgrid-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.slot-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lbl em {
|
||||
color: var(--error);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.slot-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -253,194 +276,7 @@
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.refgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.ref-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.ref-cell.rejected {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.ref-thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ref-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.ref-thumb.error {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
cursor: default;
|
||||
color: var(--error);
|
||||
font-size: 10px;
|
||||
}
|
||||
.ref-no {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 4px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: var(--text);
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.ref-busy {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.ref-x {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ref-x:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
.ref-name {
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ref-name.err {
|
||||
color: var(--error);
|
||||
}
|
||||
.ref-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
font-size: 9.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mini-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ref-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 110px;
|
||||
padding: 8px 4px;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
color 150ms;
|
||||
}
|
||||
.ref-add:hover:not(:disabled) {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.ref-add.drag {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.ref-add.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.ref-add:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.add-main {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.add-sub {
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.from-results-link {
|
||||
align-self: flex-end;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.from-results-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.full-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.slot-error {
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.refgrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
}
|
||||
audio {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,9 @@
|
||||
import Spinner from './Spinner.svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md';
|
||||
/** `danger` = outlined (list rows); `danger-solid` = confirm dialogs only. */
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-solid';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Stretch to the container width (e.g. a panel's main action). */
|
||||
block?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -32,6 +33,7 @@
|
||||
{type}
|
||||
class="a-btn btn {variant}"
|
||||
class:sm={size === 'sm'}
|
||||
class:lg={size === 'lg'}
|
||||
class:block
|
||||
class:loading
|
||||
disabled={disabled || loading}
|
||||
@@ -49,7 +51,7 @@
|
||||
</button>
|
||||
|
||||
<style>
|
||||
/* Look comes from the shared `.a-btn` rules in app.css (§5). */
|
||||
/* Look comes from the shared `.a-btn` rules in app.css (§四.1). */
|
||||
.btn {
|
||||
position: relative;
|
||||
}
|
||||
@@ -72,10 +74,14 @@
|
||||
justify-content: center;
|
||||
}
|
||||
.btn.primary .btn-spinner {
|
||||
--spinner-track: rgba(22, 19, 10, 0.3);
|
||||
--spinner-arc: var(--accent-contrast);
|
||||
--spinner-track: color-mix(in srgb, var(--accent-ink) 30%, transparent);
|
||||
--spinner-arc: var(--accent-ink);
|
||||
}
|
||||
.btn.danger .btn-spinner {
|
||||
--spinner-arc: var(--error);
|
||||
--spinner-arc: var(--err);
|
||||
}
|
||||
.btn.danger-solid .btn-spinner {
|
||||
--spinner-track: color-mix(in srgb, var(--on-media) 30%, transparent);
|
||||
--spinner-arc: var(--on-media);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,16 +46,18 @@
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
.card.hoverable {
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
transform 150ms ease;
|
||||
border-color var(--d-state) ease,
|
||||
transform var(--d-state) var(--ease-out);
|
||||
}
|
||||
.card.hoverable:hover {
|
||||
border-color: #3f3f46;
|
||||
transform: translateY(-1px);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card.hoverable:hover {
|
||||
border-color: var(--line-2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (open = false)}>{cancelLabel}</Button
|
||||
>
|
||||
<Button variant={danger ? 'danger' : 'primary'} onclick={handleConfirm}
|
||||
<Button variant={danger ? 'danger-solid' : 'primary'} onclick={handleConfirm}
|
||||
>{confirmLabel}</Button
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Drawer — the shared right-side floating layer (HEL-649 §四.3):
|
||||
* slides in over 280ms with --ease-drawer and reverses mid-flight,
|
||||
* focus is trapped while open, Esc / backdrop click close it, and
|
||||
* reduced-motion keeps only a ≤120ms opacity fade (no slide).
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
/** Panel width on >760px screens. */
|
||||
width?: number;
|
||||
dismissible?: boolean;
|
||||
onclose?: () => void;
|
||||
footer?: Snippet;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title,
|
||||
width = 380,
|
||||
dismissible = true,
|
||||
onclose,
|
||||
footer,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const titleId = `drawer-title-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
let panel = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function requestClose() {
|
||||
if (!dismissible) return;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Tab') trapFocus(event);
|
||||
}
|
||||
|
||||
function trapFocus(event: KeyboardEvent) {
|
||||
if (!panel) return;
|
||||
const focusables = Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(FOCUSABLE),
|
||||
).filter((el) => el.getClientRects().length > 0);
|
||||
if (focusables.length === 0) {
|
||||
event.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement;
|
||||
const focusInside = panel.contains(active);
|
||||
if (event.shiftKey && (!focusInside || active === first)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!focusInside || active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) requestClose();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const invoker = document.activeElement as HTMLElement | null;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.addEventListener('keydown', handleKeydown, true);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!panel || panel.contains(document.activeElement)) return;
|
||||
const first = panel.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(first ?? panel).focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeydown, true);
|
||||
if (invoker?.isConnected) invoker.focus();
|
||||
onclose?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="drawer-backdrop" onclick={handleBackdropClick}>
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="drawer-panel"
|
||||
style:--drawer-w="{width}px"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
aria-label={title ? undefined : t('modal.dialog')}
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if title || dismissible}
|
||||
<div class="drawer-header">
|
||||
{#if title}
|
||||
<h2 class="drawer-title" id={titleId}>{title}</h2>
|
||||
{/if}
|
||||
{#if dismissible}
|
||||
<button
|
||||
type="button"
|
||||
class="drawer-close"
|
||||
aria-label={t('modal.closeDialog')}
|
||||
onclick={requestClose}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="drawer-body">
|
||||
{@render children()}
|
||||
</div>
|
||||
{#if footer}
|
||||
<div class="drawer-footer">
|
||||
{@render footer()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: var(--overlay);
|
||||
animation: drawer-fade var(--d-state) ease-out;
|
||||
}
|
||||
.drawer-panel {
|
||||
width: min(var(--drawer-w), 100vw);
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--float-bg);
|
||||
border-radius: var(--r-lg) 0 0 var(--r-lg);
|
||||
box-shadow: var(--shadow-float);
|
||||
outline: none;
|
||||
animation: drawer-slide var(--d-drawer) var(--ease-drawer) both;
|
||||
}
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.drawer-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.drawer-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--d-state) ease,
|
||||
color var(--d-state) ease;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.drawer-close:hover {
|
||||
background: var(--bg-4);
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
.drawer-close:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.drawer-body {
|
||||
padding: 14px 20px 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
}
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes drawer-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes drawer-slide {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
/* 减少动态效果 (§五):去位移,只留 ≤120ms 透明度 */
|
||||
:global(:root[data-reduce='1']) .drawer-panel {
|
||||
animation: drawer-fade var(--d-drawer) ease-out both;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.drawer-panel {
|
||||
animation: drawer-fade var(--d-drawer) ease-out both;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.drawer-panel {
|
||||
width: 100vw;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -144,22 +144,19 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(5, 5, 8, 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
animation: modal-fade 150ms ease-out;
|
||||
background: var(--overlay);
|
||||
animation: modal-fade var(--d-state) ease-out;
|
||||
}
|
||||
.modal-panel {
|
||||
width: min(520px, 100%);
|
||||
max-height: calc(100vh - 96px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||||
background: var(--float-bg);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-float);
|
||||
outline: none;
|
||||
animation: modal-rise 180ms ease-out;
|
||||
animation: modal-rise var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.modal-panel.size-lg {
|
||||
width: min(920px, 100%);
|
||||
@@ -177,7 +174,7 @@
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -194,12 +191,14 @@
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
background-color var(--d-state) ease,
|
||||
color var(--d-state) ease;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.modal-close:hover {
|
||||
background: var(--bg-4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
.modal-close:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
indeterminate?: boolean;
|
||||
label?: string;
|
||||
size?: 'sm' | 'md';
|
||||
/** §5 进度条: running = amber, done = green, failed = red (static). */
|
||||
tone?: 'accent' | 'success' | 'error';
|
||||
/** 进度条: running = 信号青, done = green, failed = red (static). */
|
||||
tone?: 'run' | 'success' | 'error';
|
||||
/** Track only — no label / percentage row. */
|
||||
bare?: boolean;
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
indeterminate = false,
|
||||
label,
|
||||
size = 'md',
|
||||
tone = 'accent',
|
||||
tone = 'run',
|
||||
bare = false,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
{#if indeterminate}
|
||||
<div class="fill indeterminate"></div>
|
||||
{:else}
|
||||
<div class="fill" style:width={pct + '%'}></div>
|
||||
<div class="fill" style:transform="scaleX({pct / 100})"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,8 +75,8 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--surface-3);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-4);
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.progress-sm .track {
|
||||
height: 4px;
|
||||
@@ -86,15 +86,18 @@
|
||||
}
|
||||
.fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
background: var(--run-grad);
|
||||
box-shadow: 0 0 8px var(--run-soft);
|
||||
transform-origin: left;
|
||||
transition: transform var(--d-progress) linear;
|
||||
}
|
||||
.tone-success .fill {
|
||||
background: var(--success);
|
||||
background: var(--ok);
|
||||
box-shadow: none;
|
||||
}
|
||||
.tone-error .fill {
|
||||
background: var(--error);
|
||||
background: var(--err);
|
||||
box-shadow: none;
|
||||
}
|
||||
.fill.indeterminate {
|
||||
position: absolute;
|
||||
@@ -104,10 +107,11 @@
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--accent) 40%,
|
||||
var(--accent) 60%,
|
||||
var(--run) 40%,
|
||||
var(--run) 60%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: none;
|
||||
transition: none;
|
||||
animation: progress-slide 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -19,36 +19,26 @@
|
||||
></div>
|
||||
|
||||
<style>
|
||||
/* §四.9 骨架屏:--bg-3 圆角块,1.4s 呼吸透明度;减少动态时静态。 */
|
||||
.skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-3);
|
||||
border-radius: var(--r-sm);
|
||||
animation: skeleton-breath 1.4s ease-in-out infinite;
|
||||
}
|
||||
.skeleton.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
.skeleton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.06),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: skeleton-shimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
@keyframes skeleton-breath {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton::after {
|
||||
display: none;
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .skeleton {
|
||||
animation: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,27 +6,27 @@
|
||||
|
||||
let { status, label }: Props = $props();
|
||||
|
||||
type Tone = 'success' | 'info' | 'muted' | 'error' | 'warning' | 'accent';
|
||||
type Tone = 'ok' | 'run' | 'muted' | 'err' | 'warn';
|
||||
|
||||
const toneByStatus: Record<string, Tone> = {
|
||||
ready: 'success',
|
||||
done: 'success',
|
||||
completed: 'success',
|
||||
running: 'info',
|
||||
generating: 'info',
|
||||
queued: 'info',
|
||||
ready: 'ok',
|
||||
done: 'ok',
|
||||
completed: 'ok',
|
||||
running: 'run',
|
||||
generating: 'run',
|
||||
in_progress: 'run',
|
||||
queued: 'muted',
|
||||
pending: 'muted',
|
||||
idle: 'muted',
|
||||
draft: 'muted',
|
||||
failed: 'error',
|
||||
error: 'error',
|
||||
paused: 'warning',
|
||||
in_progress: 'accent',
|
||||
failed: 'err',
|
||||
error: 'err',
|
||||
paused: 'warn',
|
||||
};
|
||||
|
||||
const key = $derived(status.trim().toLowerCase());
|
||||
const tone: Tone = $derived(toneByStatus[key] ?? 'muted');
|
||||
const pulsing = $derived(key === 'running');
|
||||
const pulsing = $derived(tone === 'run');
|
||||
</script>
|
||||
|
||||
<span class="chip chip-{tone}">
|
||||
@@ -35,59 +35,48 @@
|
||||
</span>
|
||||
|
||||
<style>
|
||||
/* §四.2 状态标签:20px 高、--r-sm、12px;语义色文字 + 13% 软底。 */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
gap: var(--sp-2);
|
||||
height: 20px;
|
||||
padding: 0 var(--sp-2);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--fs-aux);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-success {
|
||||
color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--success) 35%, transparent);
|
||||
.chip-ok {
|
||||
color: var(--ok);
|
||||
background: var(--ok-soft);
|
||||
}
|
||||
.chip-info {
|
||||
color: var(--info);
|
||||
background: color-mix(in srgb, var(--info) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--info) 35%, transparent);
|
||||
.chip-run {
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.chip-error {
|
||||
color: var(--error);
|
||||
background: color-mix(in srgb, var(--error) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--error) 35%, transparent);
|
||||
.chip-err {
|
||||
color: var(--err);
|
||||
background: var(--err-soft);
|
||||
}
|
||||
.chip-warning {
|
||||
color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--warning) 35%, transparent);
|
||||
}
|
||||
.chip-accent {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
.chip-warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
.chip-muted {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: var(--border);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.dot.pulsing {
|
||||
animation: chip-pulse 1.2s ease-in-out infinite;
|
||||
animation: chip-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes chip-pulse {
|
||||
0%,
|
||||
@@ -103,4 +92,7 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .dot.pulsing {
|
||||
animation: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
let { checked, label, disabled = false, onchange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- §5 开关: 34×19 capsule; on = amber track + dark knob, off = --surface-3. -->
|
||||
<!-- §3.10 开关: 34×19 capsule; on = --accent-lo track + ink knob, off = --bg-4. -->
|
||||
<button
|
||||
type="button"
|
||||
class="switch"
|
||||
@@ -28,38 +28,38 @@
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 19px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-4);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition:
|
||||
background-color 150ms,
|
||||
border-color 150ms;
|
||||
background-color var(--d-state) ease,
|
||||
border-color var(--d-state) ease;
|
||||
}
|
||||
.switch:disabled {
|
||||
opacity: 0.4;
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--text-3);
|
||||
transition:
|
||||
transform 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background-color 150ms;
|
||||
transform var(--d-state) var(--ease-out),
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.switch.on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-lo);
|
||||
border-color: var(--accent-lo);
|
||||
}
|
||||
.switch.on .knob {
|
||||
transform: translateX(15px);
|
||||
background: var(--accent-contrast);
|
||||
background: var(--accent-ink);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import JobChip from './JobChip.svelte';
|
||||
import PromptBlock from './PromptBlock.svelte';
|
||||
import { deskHref } from '$lib/components/generate/context';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
stage: WorkbenchStage;
|
||||
/** Enables the 在生成台调参 link (desk opens with this asset's context). */
|
||||
projectId?: number;
|
||||
onSave?: (content: Record<string, unknown>) => void;
|
||||
onRun?: (instruction?: string) => void;
|
||||
onGenerate?: (payload: {
|
||||
@@ -20,7 +23,7 @@
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
|
||||
let { stage, projectId, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let draft = $state<WorkbenchAsset[]>([]);
|
||||
@@ -78,12 +81,12 @@
|
||||
{#each draft as asset (asset.id)}
|
||||
<div class="asset-edit">
|
||||
<div class="asset-edit-head">
|
||||
<select class="wb-input w-kind" bind:value={asset.kind}>
|
||||
<select class="a-input w-kind" bind:value={asset.kind}>
|
||||
<option value="character">{t('workbench.asset.kindCharacter')}</option>
|
||||
<option value="location">{t('workbench.asset.kindLocation')}</option>
|
||||
<option value="prop">{t('workbench.asset.kindProp')}</option>
|
||||
</select>
|
||||
<input class="wb-input name" bind:value={asset.name} placeholder={t('workbench.asset.namePh')} />
|
||||
<input class="a-input name" bind:value={asset.name} placeholder={t('workbench.asset.namePh')} />
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
@@ -93,15 +96,15 @@
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<input class="wb-input" bind:value={asset.description} placeholder={t('workbench.asset.descPh')} />
|
||||
<input class="a-input" bind:value={asset.description} placeholder={t('workbench.asset.descPh')} />
|
||||
<textarea
|
||||
class="wb-input mono"
|
||||
class="a-input mono"
|
||||
rows="2"
|
||||
bind:value={asset.prompt}
|
||||
placeholder={t('workbench.asset.promptPh')}
|
||||
></textarea>
|
||||
<input
|
||||
class="wb-input mono"
|
||||
class="a-input mono"
|
||||
bind:value={asset.negative_prompt}
|
||||
placeholder={t('workbench.style.negativePh')}
|
||||
/>
|
||||
@@ -109,17 +112,17 @@
|
||||
{/each}
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<button type="button" class="btn ghost" onclick={addAsset}>
|
||||
<button type="button" class="a-btn ghost" onclick={addAsset}>
|
||||
<Icon name="plus" size={13} />
|
||||
{t('workbench.asset.add')}
|
||||
</button>
|
||||
<div class="foot-btns">
|
||||
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
|
||||
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
|
||||
{t('workbench.cancelEdit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
class="a-btn primary"
|
||||
disabled={!draft.some((a) => a.name.trim() && a.prompt.trim())}
|
||||
onclick={save}
|
||||
>
|
||||
@@ -132,7 +135,7 @@
|
||||
<div class="todo-card">
|
||||
<p class="todo-title">{t('workbench.asset.todoTitle')}</p>
|
||||
<p class="todo-body">{t('workbench.asset.todoBody')}</p>
|
||||
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="sparkle" size={14} />
|
||||
{t('workbench.asset.generate')}
|
||||
</button>
|
||||
@@ -168,9 +171,28 @@
|
||||
<PromptBlock label={t('workbench.style.negativeLabel')} text={asset.negative_prompt} />
|
||||
{/if}
|
||||
<div class="asset-foot">
|
||||
{#if projectId}
|
||||
<a
|
||||
class="a-btn ghost sm"
|
||||
href={deskHref({
|
||||
kind: 'image',
|
||||
projectId,
|
||||
stage: 'assets',
|
||||
label: t('desk.ctxAsset', {
|
||||
kind: t(KIND_LABEL[asset.kind] ?? 'workbench.asset.kindProp'),
|
||||
name: asset.name,
|
||||
}),
|
||||
prompt: asset.prompt,
|
||||
negative: asset.negative_prompt || undefined,
|
||||
})}
|
||||
title={t('desk.openInDeskHint')}
|
||||
>
|
||||
{t('desk.openInDesk')}
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost gen"
|
||||
class="a-btn ghost sm"
|
||||
disabled={busy || jobActive(asset.job)}
|
||||
title={t('workbench.asset.generateHint')}
|
||||
onclick={() =>
|
||||
@@ -192,11 +214,11 @@
|
||||
<span class="hint">{t('workbench.asset.summary', { n: assets.length })}</span>
|
||||
<div class="foot-btns">
|
||||
{#if stage.status !== 'running'}
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<Icon name="edit" size={13} />
|
||||
{t('workbench.editDraft')}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('workbench.regenerate')}
|
||||
</button>
|
||||
@@ -294,7 +316,9 @@
|
||||
}
|
||||
.asset-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.retry-link {
|
||||
@@ -354,34 +378,14 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wb-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wb-input.mono {
|
||||
.a-input.mono {
|
||||
font-family: var(--font-a-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.wb-input.name {
|
||||
.a-input.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.wb-input.w-kind {
|
||||
.a-input.w-kind {
|
||||
width: 92px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -408,44 +412,4 @@
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
opacity 150ms;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.btn.gen {
|
||||
color: var(--accent);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,8 +60,8 @@
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.st-running {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.st-done {
|
||||
color: var(--success);
|
||||
@@ -83,7 +83,7 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: wb-chip-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
@keyframes wb-chip-breath {
|
||||
|
||||
@@ -56,15 +56,15 @@
|
||||
<blockquote class="idea-quote">{projectIdea}</blockquote>
|
||||
{/if}
|
||||
<div class="todo-actions">
|
||||
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="sparkle" size={14} />
|
||||
{t('workbench.script.generate')}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" onclick={() => (importOpen = !importOpen)}>
|
||||
<button type="button" class="a-btn ghost" onclick={() => (importOpen = !importOpen)}>
|
||||
<Icon name="upload" size={14} />
|
||||
{t('workbench.script.importBtn')}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" onclick={startEdit}>
|
||||
<button type="button" class="a-btn ghost" onclick={startEdit}>
|
||||
<Icon name="edit" size={14} />
|
||||
{t('workbench.script.writeManually')}
|
||||
</button>
|
||||
@@ -72,7 +72,7 @@
|
||||
{#if importOpen}
|
||||
<div class="import-box">
|
||||
<textarea
|
||||
class="wb-input"
|
||||
class="a-input"
|
||||
rows="8"
|
||||
bind:value={importText}
|
||||
placeholder={t('workbench.script.importPlaceholder')}
|
||||
@@ -81,7 +81,7 @@
|
||||
<span class="hint">{t('workbench.script.importHint')}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary sm"
|
||||
class="a-btn primary sm"
|
||||
disabled={importText.trim().length < 30}
|
||||
onclick={doImport}
|
||||
>
|
||||
@@ -94,7 +94,7 @@
|
||||
{:else if editing}
|
||||
<div class="editor">
|
||||
<textarea
|
||||
class="wb-input mono-area"
|
||||
class="a-input mono-area"
|
||||
rows="18"
|
||||
bind:value={draft}
|
||||
data-testid="script-editor"
|
||||
@@ -102,12 +102,12 @@
|
||||
<div class="editor-foot">
|
||||
<span class="hint">{t('workbench.script.editorHint')}</span>
|
||||
<div class="foot-btns">
|
||||
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
|
||||
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
|
||||
{t('workbench.cancelEdit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
class="a-btn primary"
|
||||
disabled={draft.trim().length < 30}
|
||||
onclick={save}
|
||||
>
|
||||
@@ -122,13 +122,13 @@
|
||||
<span class="char-count mono">{t('workbench.charCount', { n: charCount })}</span>
|
||||
<div class="foot-btns">
|
||||
{#if stage.status !== 'running'}
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<Icon name="edit" size={13} />
|
||||
{t('workbench.editDraft')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost"
|
||||
class="a-btn ghost"
|
||||
disabled={busy}
|
||||
onclick={() => onRun?.()}
|
||||
title={t('workbench.regenerateHint')}
|
||||
@@ -243,65 +243,5 @@
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.wb-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
opacity 150ms;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.btn.sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import JobChip from './JobChip.svelte';
|
||||
import PromptBlock from './PromptBlock.svelte';
|
||||
import { deskHref } from '$lib/components/generate/context';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
stage: WorkbenchStage;
|
||||
/** Enables the 在生成台调参 link (desk opens with this shot's context). */
|
||||
projectId?: number;
|
||||
onSave?: (content: Record<string, unknown>) => void;
|
||||
onRun?: (instruction?: string) => void;
|
||||
onGenerate?: (payload: {
|
||||
@@ -20,7 +23,7 @@
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
|
||||
let { stage, projectId, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let draft = $state<VideoShot[]>([]);
|
||||
@@ -71,9 +74,9 @@
|
||||
{#each draft as shot (shot.id)}
|
||||
<div class="vshot-edit">
|
||||
<div class="vshot-edit-head">
|
||||
<input class="wb-input w-no mono" bind:value={shot.shot_no} placeholder={t('workbench.vshot.noPh')} />
|
||||
<input class="a-input w-no mono" bind:value={shot.shot_no} placeholder={t('workbench.vshot.noPh')} />
|
||||
<input
|
||||
class="wb-input w-dur mono"
|
||||
class="a-input w-dur mono"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
@@ -89,9 +92,9 @@
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<input class="wb-input" bind:value={shot.description} placeholder={t('workbench.vshot.descPh')} />
|
||||
<input class="a-input" bind:value={shot.description} placeholder={t('workbench.vshot.descPh')} />
|
||||
<textarea
|
||||
class="wb-input mono"
|
||||
class="a-input mono"
|
||||
rows="3"
|
||||
bind:value={shot.prompt}
|
||||
placeholder={t('workbench.vshot.promptPh')}
|
||||
@@ -100,17 +103,17 @@
|
||||
{/each}
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<button type="button" class="btn ghost" onclick={addShot}>
|
||||
<button type="button" class="a-btn ghost" onclick={addShot}>
|
||||
<Icon name="plus" size={13} />
|
||||
{t('workbench.vshot.add')}
|
||||
</button>
|
||||
<div class="foot-btns">
|
||||
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
|
||||
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
|
||||
{t('workbench.cancelEdit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
class="a-btn primary"
|
||||
disabled={!draft.some((s) => s.prompt.trim())}
|
||||
onclick={save}
|
||||
>
|
||||
@@ -123,7 +126,7 @@
|
||||
<div class="todo-card">
|
||||
<p class="todo-title">{t('workbench.vshot.todoTitle')}</p>
|
||||
<p class="todo-body">{t('workbench.vshot.todoBody')}</p>
|
||||
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="sparkle" size={14} />
|
||||
{t('workbench.vshot.generate')}
|
||||
</button>
|
||||
@@ -159,9 +162,25 @@
|
||||
{/if}
|
||||
<PromptBlock label={t('workbench.vshot.promptLabel')} text={shot.prompt} />
|
||||
<div class="vshot-foot">
|
||||
{#if projectId}
|
||||
<a
|
||||
class="a-btn ghost sm"
|
||||
href={deskHref({
|
||||
kind: 'video',
|
||||
projectId,
|
||||
stage: 'shot',
|
||||
label: t('desk.ctxShot', { no: shot.shot_no }),
|
||||
prompt: shot.prompt,
|
||||
durationSec: shot.duration_sec,
|
||||
})}
|
||||
title={t('desk.openInDeskHint')}
|
||||
>
|
||||
{t('desk.openInDesk')}
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost gen"
|
||||
class="a-btn ghost sm"
|
||||
disabled={busy || jobActive(shot.job)}
|
||||
title={t('workbench.vshot.generateHint')}
|
||||
onclick={() =>
|
||||
@@ -183,11 +202,11 @@
|
||||
<span class="hint">{t('workbench.vshot.summary', { n: shots.length })}</span>
|
||||
<div class="foot-btns">
|
||||
{#if stage.status !== 'running'}
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<Icon name="edit" size={13} />
|
||||
{t('workbench.editDraft')}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('workbench.regenerate')}
|
||||
</button>
|
||||
@@ -241,9 +260,10 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vshot-no {
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
color: var(--accent-lo);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.vshot-dur {
|
||||
@@ -260,13 +280,15 @@
|
||||
.vshot-video {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: #000;
|
||||
border-radius: var(--r-md);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--media-scrim);
|
||||
}
|
||||
.vshot-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.retry-link {
|
||||
@@ -326,35 +348,15 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wb-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wb-input.mono {
|
||||
.a-input.mono {
|
||||
font-family: var(--font-a-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.wb-input.w-no {
|
||||
.a-input.w-no {
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wb-input.w-dur {
|
||||
.a-input.w-dur {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -382,44 +384,4 @@
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
opacity 150ms;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.btn.gen {
|
||||
color: var(--accent);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Five-stage stepper (a2): one step per agent with the spec §2.1 semantic
|
||||
* colors — gray=待开始, amber=进行中/待确认 (+breath while running),
|
||||
* green=已确认, blue=用户改过草稿. A step unlocks when itself or any
|
||||
* Five-stage stepper (a2): one step per agent with the fusion status
|
||||
* colors — gray=待开始, cyan=进行中 (+breath), amber=待确认,
|
||||
* green=已确认, --info=用户改过草稿. A step unlocks when itself or any
|
||||
* earlier stage has content; later steps stay clickable only after the
|
||||
* previous one is confirmed.
|
||||
*/
|
||||
@@ -50,7 +50,7 @@
|
||||
function stateLabel(stage: WorkbenchStage): string {
|
||||
const s = stateOf(stage);
|
||||
if (s === 'running') return t('workbench.state.running');
|
||||
if (s === 'confirmed') return t('workbench.state.confirmed');
|
||||
if (s === 'confirmed') return `✓ ${t('workbench.state.confirmed')}`;
|
||||
if (s === 'edited') return t('workbench.state.edited');
|
||||
if (s === 'draft') return t('workbench.state.draft');
|
||||
return t('workbench.state.todo');
|
||||
@@ -144,6 +144,14 @@
|
||||
.step-btn.active {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
/* §3.2 当前段:金色下划 2px 内阴影 + 段名金色。 */
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
.step-btn.active .step-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
.step-btn[data-state='confirmed']:not(.active) .step-name {
|
||||
color: var(--success);
|
||||
}
|
||||
.step-btn.locked {
|
||||
opacity: 0.55;
|
||||
@@ -159,7 +167,7 @@
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #edeef0;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -188,12 +196,14 @@
|
||||
.st-todo {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.st-running,
|
||||
.st-running {
|
||||
color: var(--run);
|
||||
}
|
||||
.st-draft {
|
||||
color: var(--accent);
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-edited {
|
||||
color: var(--info);
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-confirmed {
|
||||
color: var(--success);
|
||||
@@ -201,14 +211,14 @@
|
||||
|
||||
.step-btn.active[data-state='running'] .avatar,
|
||||
.step-btn[data-state='running'] .avatar {
|
||||
box-shadow: 0 0 10px rgba(232, 163, 61, 0.35);
|
||||
box-shadow: 0 0 10px var(--run-soft);
|
||||
}
|
||||
|
||||
.breath {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: wb-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,11 +71,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="shot-fields">
|
||||
<input class="wb-input w-xs" bind:value={shot.scene} placeholder={t('workbench.board.scene')} />
|
||||
<input class="wb-input w-xs" bind:value={shot.shot_size} placeholder={t('workbench.board.size')} />
|
||||
<input class="wb-input w-xs" bind:value={shot.camera} placeholder={t('workbench.board.camera')} />
|
||||
<input class="a-input w-xs" bind:value={shot.scene} placeholder={t('workbench.board.scene')} />
|
||||
<input class="a-input w-xs" bind:value={shot.shot_size} placeholder={t('workbench.board.size')} />
|
||||
<input class="a-input w-xs" bind:value={shot.camera} placeholder={t('workbench.board.camera')} />
|
||||
<input
|
||||
class="wb-input w-xs mono"
|
||||
class="a-input w-xs mono"
|
||||
type="number"
|
||||
min="0.5"
|
||||
step="0.5"
|
||||
@@ -84,27 +84,27 @@
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
class="wb-input"
|
||||
class="a-input"
|
||||
rows="2"
|
||||
bind:value={shot.description}
|
||||
placeholder={t('workbench.board.descPh')}
|
||||
></textarea>
|
||||
<input class="wb-input" bind:value={shot.dialog} placeholder={t('workbench.board.dialogPh')} />
|
||||
<input class="a-input" bind:value={shot.dialog} placeholder={t('workbench.board.dialogPh')} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<button type="button" class="btn ghost" onclick={addShot}>
|
||||
<button type="button" class="a-btn ghost" onclick={addShot}>
|
||||
<Icon name="plus" size={13} />
|
||||
{t('workbench.board.add')}
|
||||
</button>
|
||||
<div class="foot-btns">
|
||||
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
|
||||
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
|
||||
{t('workbench.cancelEdit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
class="a-btn primary"
|
||||
disabled={!draft.some((s) => s.description.trim())}
|
||||
onclick={save}
|
||||
>
|
||||
@@ -117,7 +117,7 @@
|
||||
<div class="todo-card">
|
||||
<p class="todo-title">{t('workbench.board.todoTitle')}</p>
|
||||
<p class="todo-body">{t('workbench.board.todoBody')}</p>
|
||||
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="sparkle" size={14} />
|
||||
{t('workbench.board.generate')}
|
||||
</button>
|
||||
@@ -149,11 +149,11 @@
|
||||
<span class="hint mono">{t('workbench.board.summary', { n: shots.length, sec: totalDuration })}</span>
|
||||
<div class="foot-btns">
|
||||
{#if stage.status !== 'running'}
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<Icon name="edit" size={13} />
|
||||
{t('workbench.editDraft')}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('workbench.regenerate')}
|
||||
</button>
|
||||
@@ -282,31 +282,11 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.wb-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wb-input.mono {
|
||||
.a-input.mono {
|
||||
font-family: var(--font-a-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.wb-input.w-xs {
|
||||
.a-input.w-xs {
|
||||
width: 110px;
|
||||
flex: 1;
|
||||
min-width: 96px;
|
||||
@@ -356,40 +336,4 @@
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
opacity 150ms;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<div class="dir-edit">
|
||||
<div class="dir-edit-head">
|
||||
<span class="dir-index mono">{i + 1}</span>
|
||||
<input class="wb-input name" bind:value={dir.name} placeholder={t('workbench.style.namePh')} />
|
||||
<input class="a-input name" bind:value={dir.name} placeholder={t('workbench.style.namePh')} />
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn danger"
|
||||
@@ -78,35 +78,35 @@
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
class="wb-input"
|
||||
class="a-input"
|
||||
bind:value={dir.description}
|
||||
placeholder={t('workbench.style.descPh')}
|
||||
/>
|
||||
<textarea
|
||||
class="wb-input mono"
|
||||
class="a-input mono"
|
||||
rows="2"
|
||||
bind:value={dir.prompt}
|
||||
placeholder={t('workbench.style.promptPh')}
|
||||
></textarea>
|
||||
<input
|
||||
class="wb-input mono"
|
||||
class="a-input mono"
|
||||
bind:value={dir.negative_prompt}
|
||||
placeholder={t('workbench.style.negativePh')}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="editor-foot">
|
||||
<button type="button" class="btn ghost" onclick={addDirection}>
|
||||
<button type="button" class="a-btn ghost" onclick={addDirection}>
|
||||
<Icon name="plus" size={13} />
|
||||
{t('workbench.style.add')}
|
||||
</button>
|
||||
<div class="foot-btns">
|
||||
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
|
||||
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
|
||||
{t('workbench.cancelEdit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
class="a-btn primary"
|
||||
disabled={draft.filter((d) => d.name.trim() && d.prompt.trim()).length < 2}
|
||||
onclick={save}
|
||||
>
|
||||
@@ -119,7 +119,7 @@
|
||||
<div class="todo-card">
|
||||
<p class="todo-title">{t('workbench.style.todoTitle')}</p>
|
||||
<p class="todo-body">{t('workbench.style.todoBody')}</p>
|
||||
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="sparkle" size={14} />
|
||||
{t('workbench.style.generate')}
|
||||
</button>
|
||||
@@ -153,11 +153,11 @@
|
||||
<span class="hint">{t('workbench.style.chooseHint')}</span>
|
||||
<div class="foot-btns">
|
||||
{#if stage.status !== 'running'}
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
|
||||
<Icon name="edit" size={13} />
|
||||
{t('workbench.editDraft')}
|
||||
</button>
|
||||
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('workbench.regenerate')}
|
||||
</button>
|
||||
@@ -214,7 +214,7 @@
|
||||
}
|
||||
.dir-card.chosen {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 10px rgba(232, 163, 61, 0.35);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
.dir-card input[type='radio'] {
|
||||
position: absolute;
|
||||
@@ -295,31 +295,11 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wb-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wb-input.mono {
|
||||
.a-input.mono {
|
||||
font-family: var(--font-a-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.wb-input.name {
|
||||
.a-input.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -345,40 +325,4 @@
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
opacity 150ms;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
.btn.ghost {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -64,6 +64,7 @@ export const de: Dict = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': 'Ausblenden',
|
||||
'toast.undo': 'Rückgängig',
|
||||
'toast.uploadFailed': 'Upload fehlgeschlagen',
|
||||
|
||||
// --- Modal ---
|
||||
@@ -220,6 +221,25 @@ export const de: Dict = {
|
||||
'lightbox.videoPreview': 'Videovorschau',
|
||||
'lightbox.videoUnavailable': 'Video nicht verfügbar',
|
||||
'lightbox.zoom100': '100%',
|
||||
'lastFrame.export': 'Letztes Bild exportieren',
|
||||
'lastFrame.exporting': 'Letztes Bild wird exportiert…',
|
||||
'lastFrame.hint': 'Speichert das letzte Bild in Originalgröße als PNG, z. B. als Startbild-Referenz',
|
||||
'lastFrame.fileTag': 'letztes-bild',
|
||||
'lastFrame.done': 'Letztes Bild exportiert {name} ({size})',
|
||||
'lastFrame.errTimeout': 'Das Video lädt zu lange. Bitte später erneut versuchen.',
|
||||
'lastFrame.errNotFound': 'Die Videodatei fehlt oder wurde entfernt.',
|
||||
'lastFrame.errForbidden': 'Keine Berechtigung, dieses Video zu lesen.',
|
||||
'lastFrame.errNetwork': 'Das Video konnte nicht gelesen werden (Netzwerkfehler).',
|
||||
'lastFrame.errDecode': 'Das Video ist beschädigt; das Ende lässt sich nicht dekodieren.',
|
||||
'lastFrame.errUnsupported': 'Dieser Browser unterstützt das Videoformat nicht, oder die Datei ist beschädigt.',
|
||||
'lastFrame.errCrossOrigin':
|
||||
'Das Video stammt von einer anderen Website, die das Auslesen von Bildern sperrt (Cross-Origin). Video zuerst herunterladen.',
|
||||
'lastFrame.errNoPicture': 'Diese Datei enthält kein Videobild.',
|
||||
'lastFrame.errUnseekable':
|
||||
'Das Videoende ist nicht erreichbar (Länge unbekannt oder Quelle erlaubt kein Springen); kein Bild exportiert.',
|
||||
'lastFrame.errBlack': 'Die letzte Sekunde des Videos ist schwarz; kein Bild exportiert.',
|
||||
'lastFrame.errEmpty': 'Der Browser konnte das Ende nicht dekodieren. Erneut versuchen oder Desktop-Chrome / Edge nutzen.',
|
||||
'lastFrame.errUnknown': 'Export des letzten Bildes fehlgeschlagen: {msg}',
|
||||
|
||||
// --- Artifact / canvas nodes ---
|
||||
'artifact.artifactLabel': 'Artefakt',
|
||||
@@ -948,6 +968,8 @@ export const de: Dict = {
|
||||
'library.selectAll': 'Alle auswählen',
|
||||
'library.deselectAll': 'Auswahl aufheben',
|
||||
'library.itemCount': '{count} Element(e)',
|
||||
'library.loadError': 'Bibliothek konnte nicht geladen werden',
|
||||
'library.subtitle': 'Generierte Assets an einem Ort – Bilder und Videos ohne Projektbezug ansehen, filtern und löschen',
|
||||
'library.deleteSelected': 'Löschen ({count})',
|
||||
'library.addToProject': 'Zum Projekt hinzufügen',
|
||||
'library.filterLabel': 'Nach Art filtern',
|
||||
@@ -1259,15 +1281,46 @@ export const de: Dict = {
|
||||
'shell.navAgents': 'Agenten',
|
||||
'shell.navConnections': 'ComfyUI-Verbindungen',
|
||||
'shell.navModels': 'Modelle',
|
||||
'shell.navLibrary': 'Asset-Bibliothek',
|
||||
'shell.navSettings': 'Einstellungen',
|
||||
'shell.wip': 'In Entwicklung',
|
||||
'shell.wipHint': 'Diese Seite ist noch im Bau und folgt später',
|
||||
'shell.comfy_ok': '{name} verbunden',
|
||||
'shell.comfy_fail': '{name} nicht erreichbar',
|
||||
'shell.comfy_testing': '{name} wird getestet…',
|
||||
'shell.comfyUnset': 'ComfyUI · nicht konfiguriert',
|
||||
'shell.comfyManage': 'ComfyUI-Adresse in den Einstellungen pflegen',
|
||||
'shell.mobileNotice': 'Bitte öffne diese Werkbank in einem Desktop-Browser (mindestens 768px breit); Handys werden noch nicht unterstützt.',
|
||||
'shell.scopeHint': 'Projekt wechseln',
|
||||
'shell.scopeAll': 'Alle Projekte',
|
||||
'shell.scopeAllList': 'Liste aller Projekte',
|
||||
'shell.scopeRecent': 'Letzte Projekte',
|
||||
'shell.scopeLoadFailed': 'Projekte konnten nicht geladen werden',
|
||||
'shell.scopeEmpty': 'Noch keine Projekte – jetzt anlegen',
|
||||
'shell.scopeStage': '{n}/5 bestätigt',
|
||||
'shell.queueRunning': '{n} in Arbeit',
|
||||
'shell.queueQueued': '{n} in der Warteschlange',
|
||||
'shell.queueIdle': 'Warteschlange leer',
|
||||
'shell.queueActive': '{n} aktiv',
|
||||
'shell.trayTitle': 'Auftragsablage',
|
||||
'shell.trayRecent': 'Letzte Aufträge · klicken für alle Aufträge',
|
||||
'shell.trayEmpty': 'Noch keine Generierungsaufträge',
|
||||
'shell.trayAll': 'Alle Aufträge anzeigen',
|
||||
'shell.searchTitle': 'Seiten und Projekte suchen',
|
||||
'shell.searchPlaceholder': 'Seiten, Projekte suchen',
|
||||
'shell.searchShortcut': 'Suchen (Strg/⌘ + K)',
|
||||
'shell.searchEmpty': 'Keine Seite und kein Projekt passt zu „{q}“',
|
||||
'shell.searchGroupPages': 'Seiten',
|
||||
'shell.searchGroupProjects': 'Projekte · Werkbank öffnen',
|
||||
'shell.searchHintMove': 'auswählen',
|
||||
'shell.searchHintOpen': 'öffnen',
|
||||
'shell.searchHintClose': 'schließen',
|
||||
'shell.reduceMotion': 'Weniger Bewegung',
|
||||
'shell.reduceHint': 'Bewegungs-, Zoom- und Glüheffekte aus; nur kurze Überblendungen',
|
||||
'shell.reduceSystem': 'Folgt der Systemeinstellung „Bewegung reduzieren“',
|
||||
'shell.tabAria': 'Hauptnavigation (schmal)',
|
||||
'shell.tabProjects': 'Projekte',
|
||||
'shell.tabCreate': 'Erstellen',
|
||||
'shell.tabGenerate': 'Generieren',
|
||||
'shell.tabMore': 'Mehr',
|
||||
'shell.morePages': 'Weitere Seiten',
|
||||
'shell.moreTools': 'Werkzeuge',
|
||||
'home.title': 'Projektübersicht',
|
||||
'home.subtitle': 'Verwalte deine Projekte — starte mit einer Idee oder einem fertigen Drehbuch.',
|
||||
'home.projectCount': '{n} Projekte · {m} in Arbeit',
|
||||
@@ -1456,13 +1509,11 @@ export const de: Dict = {
|
||||
'studio.genParams': 'Parameter',
|
||||
'studio.modeTextToImage': 'Text zu Bild',
|
||||
'studio.modeTextToVideo': 'Text zu Video',
|
||||
'studio.chipIdle': '{name} · Warteschlange frei',
|
||||
'studio.chipBusy': '{name} · {n} in Warteschlange',
|
||||
'studio.video.resultsTitle': 'Vorschau',
|
||||
'studio.image.historyTitle': 'Bildverlauf',
|
||||
'studio.video.historyTitle': 'Videoverlauf',
|
||||
'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 +1629,14 @@ 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.deleteFailedToast': 'Löschen fehlgeschlagen: {detail}',
|
||||
'studio.deletedTakeNoFilesToast': '„{name}“ gelöscht',
|
||||
'studio.deletedTakeToast': '„{name}“ und seine Ergebnisdateien wurden gelöscht',
|
||||
'studio.deletedToast': 'Auftrag und zugehörige Ergebnisdateien gelöscht',
|
||||
'studio.wfDeletedTag': 'Workflow gelöscht',
|
||||
'studio.wfEmptyTitle': 'Noch keine {kind}-Workflows',
|
||||
'studio.wfEmptyBody': 'Entwicklermodus in ComfyUI aktivieren, Workflow als API-JSON exportieren und auf der Seite ComfyUI-Verbindungen als {kind} importieren.',
|
||||
'studio.wfEmptyGo': 'In ComfyUI-Verbindungen importieren',
|
||||
@@ -1597,9 +1655,12 @@ export const de: Dict = {
|
||||
'tasks.subtitle': 'Alle Bild-/Videoaufgaben — eigenständige Fenster und Kreativfluss',
|
||||
'tasks.filterAll': 'Alle',
|
||||
'tasks.filterImage': 'Bilder',
|
||||
'tasks.filterFailed': 'Fehlgeschlagen',
|
||||
'tasks.filterActive': 'Aktiv',
|
||||
'tasks.filterVideo': 'Videos',
|
||||
'tasks.count': '{n} Aufgaben · {m} aktiv',
|
||||
'tasks.empty': 'Noch keine Aufgaben. Reiche die erste in Bild oder Video ein.',
|
||||
'tasks.emptyFiltered': 'Keine Aufgaben passen zum aktuellen Filter.',
|
||||
'tasks.goGenerate': 'Bild öffnen',
|
||||
'tasks.sourceStudioImage': 'Eigenständiges Bild',
|
||||
'tasks.sourceStudioVideo': 'Eigenständiges Video',
|
||||
@@ -1613,8 +1674,10 @@ export const de: Dict = {
|
||||
'tasks.retry': 'Erneut',
|
||||
'tasks.cancel': 'Abbrechen',
|
||||
'tasks.delete': 'Löschen',
|
||||
'tasks.download': 'Herunterladen',
|
||||
'tasks.delTitle': 'Aufgabe löschen',
|
||||
'tasks.delConfirm': 'Die Ergebnisdateien werden mitgelöscht. Löschen?',
|
||||
'tasks.searchPh': 'Aufgaben suchen…',
|
||||
'tasks.outputs': '{n} Ergebnisse',
|
||||
|
||||
'agentsPage.title': 'Agenten',
|
||||
@@ -1844,6 +1907,8 @@ export const de: Dict = {
|
||||
'jobst.dryRunDetail': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
|
||||
'jobst.noOutput': 'Keine Ausgabe',
|
||||
'jobst.noOutputDetail': 'Der Job endete ohne Ergebnisdatei – keine fertige Generierung',
|
||||
'jobst.missing': 'Dateien fehlen',
|
||||
'jobst.missingDetail': 'Der Task lief erfolgreich, aber die Ergebnisdateien sind nicht mehr auf dem Server',
|
||||
'jobst.queued': 'Wartet',
|
||||
'jobst.queuedDetail': 'Wartet in der Workbench-Warteschlange auf ComfyUI',
|
||||
'jobst.connecting': 'Verbinde',
|
||||
@@ -1872,4 +1937,163 @@ export const de: Dict = {
|
||||
'studio.doneToast': 'Fertig: {n} Datei(en) (ComfyUI-Prompt-ID {id})',
|
||||
'studio.dryRunToast': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
|
||||
'studio.dryRunNote': 'Dry-run ist aktiv: Generieren schreibt nur Platzhalter und ruft ComfyUI nie auf. In den Einstellungen abschalten.',
|
||||
// --- HEL-643: Import-Prüfung / Echtlauf-Test / Mediengruppen ---
|
||||
'studio.errVideoFormat': '„{name}“ ist kein unterstütztes Videoformat — MP4 / WebM / MOV verwenden',
|
||||
'studio.errAudioFormat': '„{name}“ ist kein unterstütztes Audioformat — MP3 / WAV / M4A verwenden',
|
||||
'studio.errMediaSize': '„{name}“ überschreitet die Größenbeschränkung — bitte vorher komprimieren',
|
||||
'studio.errNeedRefMedia': '„{label}“ benötigt mindestens {n} Clips',
|
||||
'studio.refHintMedia': 'Pflicht · 1–{max} Clips · {n} ausgewählt',
|
||||
'studio.refAddMedia': '{kind} hinzufügen',
|
||||
'studio.refAddSubMedia': 'Noch {n} · max {max}',
|
||||
'studio.refRemoveMedia': '{kind} {n} entfernen',
|
||||
'studio.refFullMedia': 'Maximum von {max} Clips erreicht — erst eines entfernen',
|
||||
'wfMedia.h3Limits': 'Offizielle Limits des MiniMax H3 Ref2VA: bis zu 9 Referenzbilder, 3 Referenzvideos und 3 Referenzaudios; je Clip 2–15 s, Summen je Typ ≤15 s; Audio allein ist keine gültige Referenz; insgesamt höchstens 12 Referenzdateien.',
|
||||
'wfMedia.h3TotalOver': '{n} Referenzdateien — über dem offiziellen Limit von 12; die Generierung kann fehlschlagen',
|
||||
'wfMedia.h3AudioAlone': 'Audio allein kann nicht die einzige Referenz sein — mindestens ein Bild oder Video ergänzen',
|
||||
'conn.wfCheckTitle': 'Kompatibilitätsprüfung der Verbindung',
|
||||
'conn.wfCheckEndpoint': 'Ziel: {name}',
|
||||
'conn.wfCheckBtn': 'Erneut prüfen',
|
||||
'conn.wfChecking': 'Prüfe…',
|
||||
'conn.wfCheckOk': 'Diese Verbindung besitzt die Knoten und Modelle, die dieser Workflow braucht',
|
||||
'conn.wfCheckProblems': '{n} fehlende Knoten/Modelle gefunden',
|
||||
'conn.wfCheckUnreachable': 'Keine Prüfung möglich (ComfyUI unerreichbar) — Speichern bleibt möglich; nach Reparatur erneut prüfen',
|
||||
'conn.wfCheckSaveHint': 'Der Entwurf kann trotzdem gespeichert werden (wird nicht als generierbar markiert); Fehlendes installieren und erneut prüfen.',
|
||||
'conn.wfTestBtn': 'Testen',
|
||||
'conn.wfLastTest': 'Letzter Test',
|
||||
'conn.wfTestPassed': '{time} bestanden ({n} Dateien)',
|
||||
'conn.wfTestRunning': '{time} läuft',
|
||||
'conn.wfTestFailed': '{time} nicht bestanden',
|
||||
'wft.title': 'Workflow testen',
|
||||
'wft.connection': 'Zielverbindung',
|
||||
'wft.costNote': 'Ein Test schickt einen echten Auftrag an den gewählten ComfyUI und belegt VRAM und Warteschlange.',
|
||||
'wft.submit': 'Test starten',
|
||||
'wft.confirmTitle': 'Echten Lauf bestätigen',
|
||||
'wft.confirmBody': 'An „{endpoint}“ wird ein echter Auftrag gesendet ({n} Aufträge in der Warteschlange). Der Test gilt nur als bestanden, wenn mindestens eine herunterladbare Ausgabedatei des richtigen Typs zurückkommt.',
|
||||
'wft.running': 'Test läuft…',
|
||||
'wft.cancel': 'Test abbrechen',
|
||||
'wft.passed': 'Test bestanden: {n} Datei(en) erzeugt',
|
||||
'wft.passedShort': 'Bestanden ({n} Dateien)',
|
||||
'wft.failed': 'Test nicht bestanden',
|
||||
'wft.failedShort': 'Nicht bestanden',
|
||||
'wft.cancelledShort': 'Abgebrochen',
|
||||
'wft.reusedToast': 'Für diesen Workflow läuft bereits ein Test — angebunden',
|
||||
'wft.history': 'Testverlauf ({n})',
|
||||
'wft.clear': 'Testverlauf löschen',
|
||||
'wft.cleared': 'Testverlauf gelöscht',
|
||||
'wft.never': 'Nie getestet',
|
||||
'wft.download': 'Herunterladen',
|
||||
'wft.errRequired': 'Bitte „{label}“ ausfüllen',
|
||||
'wft.errListMin': '„{label}“ benötigt mindestens {n} {unit}',
|
||||
'tasks.testBadge': 'Test',
|
||||
'tasks.sourceWorkflowTest': 'Workflow-Test',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': 'Original {v}',
|
||||
'desk.title': 'Generieren',
|
||||
'desk.advanced': 'Erweitert · {n} · unverändert gelten die Workflow-Werte',
|
||||
'desk.chipResolution': 'Auflösung {v}',
|
||||
'desk.chipSeconds': '{v} s',
|
||||
'desk.chipSeed': 'Seed {v}',
|
||||
'desk.closeInspector': 'Inspektor schließen',
|
||||
'desk.countActive': '{n} laufen',
|
||||
'desk.countTakes': '{n} Einträge',
|
||||
'desk.ctxAsset': '{kind} „{name}“',
|
||||
'desk.ctxShot': 'Einstellung {no}',
|
||||
'desk.ctxBack': 'Zurück zu {stage}',
|
||||
'desk.ctxDismiss': 'Herkunftshinweis schließen',
|
||||
'desk.ctxFrom': 'Aus „{project}“',
|
||||
'desk.ctxNoBackfill': 'Ergebnisse bleiben im Verlauf und werden nicht in {stage} übernommen',
|
||||
'desk.dayDate': '{d}.{m}.',
|
||||
'desk.download': 'Herunterladen',
|
||||
'desk.dropNoSlot': '{n} Datei(en) ohne passenden Slot: dieser Workflow hat keinen solchen Eingang',
|
||||
'desk.dryRun': 'Probelauf',
|
||||
'desk.durMin': '{m} min {s} s',
|
||||
'desk.durSec': '{s} s',
|
||||
'desk.elapsed': 'Dauer',
|
||||
'desk.emptyFailed': 'Keine fehlgeschlagenen Einträge',
|
||||
'desk.emptyFeed': 'Noch keine Einträge. Prompt unten eingeben und „Generieren“ drücken.',
|
||||
'desk.filterAria': 'Einträge filtern',
|
||||
'desk.filterAll': 'Alle',
|
||||
'desk.filterImage': 'Bilder',
|
||||
'desk.filterVideo': 'Videos',
|
||||
'desk.filterFailed': 'Fehlgeschlagen',
|
||||
'desk.generate': 'Generieren',
|
||||
'desk.genHint': 'Generieren (Strg/⌘ + Enter)',
|
||||
'desk.inputs': 'Eingaben',
|
||||
'desk.inspector': 'Inspektor',
|
||||
'desk.keyClose': 'Schließen',
|
||||
'desk.keyInspector': 'Ein- / ausblenden',
|
||||
'desk.keySelect': 'Auswählen',
|
||||
'desk.keyUse': 'Verwenden',
|
||||
'desk.kvCreated': 'Eingereicht',
|
||||
'desk.kvName': 'Name',
|
||||
'desk.kvPromptId': 'ComfyUI-Auftrags-ID',
|
||||
'desk.kvUsage': 'Zweck',
|
||||
'desk.loadEarlier': '{n} ältere Takes anzeigen',
|
||||
'desk.missingSub': 'Mit gleichen Parametern wiederholen oder diesen Take entfernen',
|
||||
'desk.missingTitle': 'Die Ergebnisdateien sind nicht mehr auf dem Server',
|
||||
'desk.needWorkflow': 'Fehlt: Workflow',
|
||||
'desk.noConn': 'Nicht verbunden',
|
||||
'desk.noPromptInput': 'Dieser Workflow hat keinen Prompt-Eingang und läuft mit seinen eigenen Werten.',
|
||||
'desk.notGenerated': 'Nicht erzeugt',
|
||||
'desk.noWorkflows': 'Noch kein Bild- oder Video-Workflow bereit',
|
||||
'desk.openInDesk': 'Im Generator anpassen',
|
||||
'desk.openInDeskHint': 'Generator mit diesem Prompt öffnen, Workflow und Parameter selbst wählen; Ergebnisse bleiben im Verlauf',
|
||||
'desk.openInspector': 'Inspektor',
|
||||
'desk.origValue': 'Workflow-Wert',
|
||||
'desk.outputN': 'Ergebnis {n}',
|
||||
'desk.outputs': 'Alle Ergebnisse',
|
||||
'desk.paramError': 'Ungültiger Parameter: {msg}',
|
||||
'desk.paramsAll': 'Alle Parameter',
|
||||
'desk.paramsChanged': 'Parameter · {n} geändert',
|
||||
'desk.paramsCommon': 'Häufige Parameter',
|
||||
'desk.pickTake': 'Eintrag im Verlauf wählen, um das Ergebnis zu sehen',
|
||||
'desk.pickWorkflow': 'Workflow wählen',
|
||||
'desk.promptOnly': 'Nur Prompt',
|
||||
'desk.promptPh': 'Motiv beschreiben… (Strg/⌘ + Enter generiert, / fokussiert)',
|
||||
'desk.restoreAll': 'Alle Workflow-Werte wiederherstellen',
|
||||
'desk.redo': 'Mit gleichen Parametern wiederholen',
|
||||
'desk.removeRecord': 'Eintrag entfernen',
|
||||
'desk.restoreOrig': 'Original wiederherstellen',
|
||||
'desk.reuse': 'Wiederverwenden',
|
||||
'desk.reuseToComposer': 'In Eingabe übernehmen',
|
||||
'desk.seedRandom': 'Zufall',
|
||||
'desk.sendToSlot': 'In Bild-Slot senden',
|
||||
'desk.sent': 'Eingereicht',
|
||||
'desk.sentToSlotToast': 'In den Bild-Slot des aktuellen Workflows gesendet',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': 'Größe',
|
||||
'desk.stillNeed': 'Fehlt: {what}',
|
||||
'desk.tabParams': 'Parameter',
|
||||
'desk.tabResult': 'Ergebnis',
|
||||
'desk.today': 'Heute',
|
||||
'desk.took': 'Dauer {d}',
|
||||
'desk.wfBroken': 'Nicht nutzbar',
|
||||
'desk.wfCurrent': 'aktuell',
|
||||
'desk.wfDefaultConn': 'Standardverbindung',
|
||||
'desk.wfImport': 'Workflows importieren / verwalten',
|
||||
'desk.wfLastFailed': 'Letzter Lauf fehlgeschlagen',
|
||||
'desk.wfNoMatch': 'Kein passender Workflow',
|
||||
'desk.wfOk': 'Bereit',
|
||||
'desk.wfSearchPh': 'Workflows oder Zweck suchen: Bild zu Video, Referenz…',
|
||||
'desk.wfTesting': 'Testlauf läuft',
|
||||
'desk.wfUntested': 'Nicht getestet',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Gestern',
|
||||
'wfd.stepParse': 'JSON parsen',
|
||||
'wfd.stepNodes': 'Knoten erkennen',
|
||||
'wfd.stepModels': 'Modelle prüfen',
|
||||
'wfd.stepSlots': 'Eingangsslots aufbauen',
|
||||
'wfd.noOutput': 'Der Workflow hat keinen Ausgabeknoten — Import nicht möglich',
|
||||
'wfd.nodesAutoOk': 'Verwendung automatisch erkannt: {usage}',
|
||||
'wfd.nodesUserOk': 'Verwendung bestätigt: {usage}',
|
||||
'wfd.modelsOk': '„{name}“ hat alle benötigten Knoten und Modelle',
|
||||
'wfd.slotsOk': '{n} Eingangsslot(s) aufgebaut ({m} Medien)',
|
||||
'wfd.checksAria': 'Import-Prüfergebnisse',
|
||||
'wfd.showDetails': 'Fehlerdetails anzeigen',
|
||||
'wfd.hideDetails': 'Fehlerdetails ausblenden',
|
||||
'wfd.unreachableSuggest': 'Eine misslungene Prüfung verhindert das Speichern nicht; nach Reparatur erneut prüfen.',
|
||||
'wfd.nodesPickSuggest': 'Wähle unten die Verwendung und schließe die Ausgangszuordnung ab, bevor du speicherst.',
|
||||
'wfd.showRaw': 'Rohes JSON anzeigen',
|
||||
'wfd.hideRaw': 'Rohes JSON ausblenden',
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ export const en = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': 'Dismiss',
|
||||
'toast.undo': 'Undo',
|
||||
'toast.uploadFailed': 'Upload failed',
|
||||
|
||||
// --- Modal ---
|
||||
@@ -220,6 +221,25 @@ export const en = {
|
||||
'lightbox.videoPreview': 'Video preview',
|
||||
'lightbox.videoUnavailable': 'Video unavailable',
|
||||
'lightbox.zoom100': '100%',
|
||||
'lastFrame.export': 'Export last frame',
|
||||
'lastFrame.exporting': 'Exporting last frame…',
|
||||
'lastFrame.hint': 'Save the final frame as a full-size PNG, e.g. as a first-frame reference',
|
||||
'lastFrame.fileTag': 'last-frame',
|
||||
'lastFrame.done': 'Exported last frame {name} ({size})',
|
||||
'lastFrame.errTimeout': 'The video took too long to load. Try again later.',
|
||||
'lastFrame.errNotFound': 'The video file is missing or was removed.',
|
||||
'lastFrame.errForbidden': 'You do not have permission to read this video.',
|
||||
'lastFrame.errNetwork': 'Reading the video failed (network error). Check the connection and retry.',
|
||||
'lastFrame.errDecode': 'The video is damaged; its ending cannot be decoded.',
|
||||
'lastFrame.errUnsupported': 'This browser cannot play the video format, or the file is damaged.',
|
||||
'lastFrame.errCrossOrigin':
|
||||
'The video comes from another site that blocks reading its frames (cross-origin). Download the video first.',
|
||||
'lastFrame.errNoPicture': 'This file has no video picture.',
|
||||
'lastFrame.errUnseekable':
|
||||
'Cannot reach the end of the video (unknown length, or the source does not allow seeking); no frame exported.',
|
||||
'lastFrame.errBlack': 'The last second of the video is black; no frame exported.',
|
||||
'lastFrame.errEmpty': 'The browser could not decode the ending. Retry, or use desktop Chrome / Edge.',
|
||||
'lastFrame.errUnknown': 'Exporting the last frame failed: {msg}',
|
||||
|
||||
// --- Artifact / canvas nodes ---
|
||||
'artifact.artifactLabel': 'Artifact',
|
||||
@@ -948,6 +968,8 @@ export const en = {
|
||||
'library.selectAll': 'Select all',
|
||||
'library.deselectAll': 'Deselect all',
|
||||
'library.itemCount': '{count} item(s)',
|
||||
'library.loadError': 'Failed to load the library',
|
||||
'library.subtitle': 'Generated assets in one place — preview, filter and remove images and videos not tied to a project',
|
||||
'library.deleteSelected': 'Delete ({count})',
|
||||
'library.addToProject': 'Add to Project',
|
||||
'library.filterLabel': 'Filter by kind',
|
||||
@@ -1261,15 +1283,46 @@ export const en = {
|
||||
'shell.navAgents': 'Agents',
|
||||
'shell.navConnections': 'ComfyUI Connections',
|
||||
'shell.navModels': 'Models',
|
||||
'shell.navLibrary': 'Asset Library',
|
||||
'shell.navSettings': 'Settings',
|
||||
'shell.wip': 'In development',
|
||||
'shell.wipHint': 'This page is under construction and will open later',
|
||||
'shell.comfy_ok': '{name} connected',
|
||||
'shell.comfy_fail': '{name} unreachable',
|
||||
'shell.comfy_testing': 'Testing {name}…',
|
||||
'shell.comfyUnset': 'ComfyUI · not configured',
|
||||
'shell.comfyManage': 'Configure the ComfyUI address in Settings',
|
||||
'shell.mobileNotice': 'Please open this workbench in a desktop browser (at least 768px wide). Phones are not supported yet.',
|
||||
'shell.scopeHint': 'Switch project',
|
||||
'shell.scopeAll': 'All projects',
|
||||
'shell.scopeAllList': 'All projects list',
|
||||
'shell.scopeRecent': 'Recent projects',
|
||||
'shell.scopeLoadFailed': 'Could not load projects',
|
||||
'shell.scopeEmpty': 'No projects yet — create one',
|
||||
'shell.scopeStage': '{n}/5 confirmed',
|
||||
'shell.queueRunning': '{n} generating',
|
||||
'shell.queueQueued': '{n} queued',
|
||||
'shell.queueIdle': 'Queue idle',
|
||||
'shell.queueActive': '{n} active',
|
||||
'shell.trayTitle': 'Job tray',
|
||||
'shell.trayRecent': 'Recent jobs · click to see all jobs',
|
||||
'shell.trayEmpty': 'No generation jobs yet',
|
||||
'shell.trayAll': 'View all jobs',
|
||||
'shell.searchTitle': 'Search pages and projects',
|
||||
'shell.searchPlaceholder': 'Search pages, projects',
|
||||
'shell.searchShortcut': 'Search (Ctrl/⌘ + K)',
|
||||
'shell.searchEmpty': 'No page or project matches “{q}”',
|
||||
'shell.searchGroupPages': 'Pages',
|
||||
'shell.searchGroupProjects': 'Projects · open the workbench',
|
||||
'shell.searchHintMove': 'select',
|
||||
'shell.searchHintOpen': 'open',
|
||||
'shell.searchHintClose': 'close',
|
||||
'shell.reduceMotion': 'Reduce motion',
|
||||
'shell.reduceHint': 'Turn off movement, scaling and glow animations; keep only short fades',
|
||||
'shell.reduceSystem': 'Following the system “Reduce motion” setting',
|
||||
'shell.tabAria': 'Primary (narrow screens)',
|
||||
'shell.tabProjects': 'Projects',
|
||||
'shell.tabCreate': 'Create',
|
||||
'shell.tabGenerate': 'Generate',
|
||||
'shell.tabMore': 'More',
|
||||
'shell.morePages': 'More pages',
|
||||
'shell.moreTools': 'Tools',
|
||||
'home.title': 'Projects overview',
|
||||
'home.subtitle': 'Manage your projects — start from an idea or a ready-made script.',
|
||||
'home.projectCount': '{n} projects · {m} in progress',
|
||||
@@ -1455,13 +1508,11 @@ export const en = {
|
||||
'studio.genParams': 'Parameters',
|
||||
'studio.modeTextToImage': 'Text to image',
|
||||
'studio.modeTextToVideo': 'Text to video',
|
||||
'studio.chipIdle': '{name} · queue idle',
|
||||
'studio.chipBusy': '{name} · {n} in queue',
|
||||
'studio.video.resultsTitle': 'Preview',
|
||||
'studio.image.historyTitle': 'Image history',
|
||||
'studio.video.historyTitle': 'Video history',
|
||||
'studio.historyCount': '{n} total',
|
||||
'studio.lastDone': 'Last done · {time}',
|
||||
'studio.doneAt': 'Done at {time}',
|
||||
'studio.kindImageText': 'image',
|
||||
'studio.kindVideoText': 'video',
|
||||
'studio.loading': 'Loading…',
|
||||
@@ -1577,7 +1628,14 @@ 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.deleteFailedToast': 'Delete failed: {detail}',
|
||||
'studio.deletedTakeNoFilesToast': 'Deleted “{name}”',
|
||||
'studio.deletedTakeToast': 'Deleted “{name}” together with its result files',
|
||||
'studio.deletedToast': 'Job deleted together with its result files',
|
||||
'studio.wfDeletedTag': 'Workflow deleted',
|
||||
'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',
|
||||
@@ -1596,9 +1654,12 @@ export const en = {
|
||||
'tasks.subtitle': 'All image / video tasks — standalone windows and the creation flow',
|
||||
'tasks.filterAll': 'All',
|
||||
'tasks.filterImage': 'Images',
|
||||
'tasks.filterFailed': 'Failed',
|
||||
'tasks.filterActive': 'Active',
|
||||
'tasks.filterVideo': 'Videos',
|
||||
'tasks.count': '{n} tasks · {m} active',
|
||||
'tasks.empty': 'No generation tasks yet. Submit the first one in Image or Video.',
|
||||
'tasks.emptyFiltered': 'No tasks match the current filter.',
|
||||
'tasks.goGenerate': 'Open Image',
|
||||
'tasks.sourceStudioImage': 'Standalone image',
|
||||
'tasks.sourceStudioVideo': 'Standalone video',
|
||||
@@ -1612,8 +1673,10 @@ export const en = {
|
||||
'tasks.retry': 'Retry',
|
||||
'tasks.cancel': 'Cancel',
|
||||
'tasks.delete': 'Delete',
|
||||
'tasks.download': 'Download',
|
||||
'tasks.delTitle': 'Delete task',
|
||||
'tasks.delConfirm': 'The result files will be removed together with the task. Delete it?',
|
||||
'tasks.searchPh': 'Search tasks…',
|
||||
'tasks.outputs': '{n} outputs',
|
||||
|
||||
'agentsPage.title': 'Agents',
|
||||
@@ -1843,6 +1906,8 @@ export const en = {
|
||||
'jobst.dryRunDetail': 'Placeholder only: Dry-run is on, ComfyUI was not called',
|
||||
'jobst.noOutput': 'No output',
|
||||
'jobst.noOutputDetail': 'The job ended without any result file — not a finished generation',
|
||||
'jobst.missing': 'Files missing',
|
||||
'jobst.missingDetail': 'The task succeeded, but its result files are no longer on the server',
|
||||
'jobst.queued': 'Queued',
|
||||
'jobst.queuedDetail': 'Waiting in the workbench queue to be sent to ComfyUI',
|
||||
'jobst.connecting': 'Connecting',
|
||||
@@ -1871,6 +1936,165 @@ 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',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': 'Original {v}',
|
||||
'desk.title': 'Generate',
|
||||
'desk.advanced': 'Advanced · {n} · untouched ones keep the workflow values',
|
||||
'desk.chipResolution': 'Resolution {v}',
|
||||
'desk.chipSeconds': '{v}s',
|
||||
'desk.chipSeed': 'Seed {v}',
|
||||
'desk.closeInspector': 'Close inspector',
|
||||
'desk.countActive': '{n} running',
|
||||
'desk.countTakes': '{n} takes',
|
||||
'desk.ctxAsset': '{kind} “{name}”',
|
||||
'desk.ctxShot': 'Shot {no}',
|
||||
'desk.ctxBack': 'Back to {stage}',
|
||||
'desk.ctxDismiss': 'Dismiss source note',
|
||||
'desk.ctxFrom': 'From “{project}”',
|
||||
'desk.ctxNoBackfill': 'Results stay in the desk feed and are not filled back into {stage}',
|
||||
'desk.dayDate': '{m}/{d}',
|
||||
'desk.download': 'Download',
|
||||
'desk.dropNoSlot': '{n} file(s) have no slot: this workflow has no input of that kind',
|
||||
'desk.dryRun': 'Dry run',
|
||||
'desk.durMin': '{m}m {s}s',
|
||||
'desk.durSec': '{s}s',
|
||||
'desk.elapsed': 'Time',
|
||||
'desk.emptyFailed': 'No failed takes',
|
||||
'desk.emptyFeed': 'No takes yet. Write a prompt below and press Generate.',
|
||||
'desk.filterAria': 'Filter takes',
|
||||
'desk.filterAll': 'All',
|
||||
'desk.filterImage': 'Images',
|
||||
'desk.filterVideo': 'Videos',
|
||||
'desk.filterFailed': 'Failed',
|
||||
'desk.generate': 'Generate',
|
||||
'desk.genHint': 'Generate (Ctrl/⌘ + Enter)',
|
||||
'desk.inputs': 'Inputs',
|
||||
'desk.inspector': 'Inspector',
|
||||
'desk.keyClose': 'Close',
|
||||
'desk.keyInspector': 'Show / hide',
|
||||
'desk.keySelect': 'Select',
|
||||
'desk.keyUse': 'Use',
|
||||
'desk.kvCreated': 'Submitted',
|
||||
'desk.kvName': 'Name',
|
||||
'desk.kvPromptId': 'ComfyUI prompt id',
|
||||
'desk.kvUsage': 'Usage',
|
||||
'desk.loadEarlier': 'Show {n} earlier takes',
|
||||
'desk.missingSub': 'Redo with the same params, or remove this take',
|
||||
'desk.missingTitle': 'The result files are no longer on the server',
|
||||
'desk.needWorkflow': 'Missing: workflow',
|
||||
'desk.noConn': 'Not connected',
|
||||
'desk.noPromptInput': 'This workflow takes no prompt; it runs with its own values.',
|
||||
'desk.notGenerated': 'Not generated',
|
||||
'desk.noWorkflows': 'No image or video workflow is ready yet',
|
||||
'desk.openInDesk': 'Tune in desk',
|
||||
'desk.openInDeskHint': 'Open the desk with this prompt and pick the workflow and params yourself; results stay in the desk feed',
|
||||
'desk.openInspector': 'Inspector',
|
||||
'desk.origValue': 'Workflow value',
|
||||
'desk.outputN': 'Result {n}',
|
||||
'desk.outputs': 'All results',
|
||||
'desk.paramError': 'Invalid parameter: {msg}',
|
||||
'desk.paramsAll': 'All params',
|
||||
'desk.paramsChanged': 'Params · {n} changed',
|
||||
'desk.paramsCommon': 'Common params',
|
||||
'desk.pickTake': 'Pick a take in the feed to see its result',
|
||||
'desk.pickWorkflow': 'Choose workflow',
|
||||
'desk.promptOnly': 'Prompt only',
|
||||
'desk.promptPh': 'Describe the shot… (Ctrl/⌘ + Enter to generate, / to focus)',
|
||||
'desk.restoreAll': 'Restore all workflow values',
|
||||
'desk.redo': 'Redo with same params',
|
||||
'desk.removeRecord': 'Remove record',
|
||||
'desk.restoreOrig': 'Restore original',
|
||||
'desk.reuse': 'Reuse',
|
||||
'desk.reuseToComposer': 'Reuse in composer',
|
||||
'desk.seedRandom': 'Random',
|
||||
'desk.sendToSlot': 'Send to image slot',
|
||||
'desk.sent': 'Submitted',
|
||||
'desk.sentToSlotToast': 'Sent to the current workflow’s image slot',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': 'Size',
|
||||
'desk.stillNeed': 'Missing: {what}',
|
||||
'desk.tabParams': 'Params',
|
||||
'desk.tabResult': 'Result',
|
||||
'desk.today': 'Today',
|
||||
'desk.took': 'Took {d}',
|
||||
'desk.wfBroken': 'Unavailable',
|
||||
'desk.wfCurrent': 'current',
|
||||
'desk.wfDefaultConn': 'Default connection',
|
||||
'desk.wfImport': 'Import / manage workflows',
|
||||
'desk.wfLastFailed': 'Last run failed',
|
||||
'desk.wfNoMatch': 'No matching workflow',
|
||||
'desk.wfOk': 'Ready',
|
||||
'desk.wfSearchPh': 'Search workflows or a usage: image to video, reference…',
|
||||
'desk.wfTesting': 'Test running',
|
||||
'desk.wfUntested': 'Not tested',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Yesterday',
|
||||
'wfd.stepParse': 'Parse JSON',
|
||||
'wfd.stepNodes': 'Identify nodes',
|
||||
'wfd.stepModels': 'Verify models',
|
||||
'wfd.stepSlots': 'Build input slots',
|
||||
'wfd.noOutput': 'The workflow has no output node — cannot import',
|
||||
'wfd.nodesAutoOk': 'Usage auto-detected: {usage}',
|
||||
'wfd.nodesUserOk': 'Usage confirmed: {usage}',
|
||||
'wfd.modelsOk': '"{name}" has all required nodes and models',
|
||||
'wfd.slotsOk': '{n} input slot(s) built ({m} media)',
|
||||
'wfd.checksAria': 'Import check results',
|
||||
'wfd.showDetails': 'Show error details',
|
||||
'wfd.hideDetails': 'Hide error details',
|
||||
'wfd.unreachableSuggest': 'Being unverifiable does not block saving; re-check after fixing the connection.',
|
||||
'wfd.nodesPickSuggest': 'Pick the usage and finish the output mapping below before saving.',
|
||||
'wfd.showRaw': 'View raw JSON',
|
||||
'wfd.hideRaw': 'Hide raw JSON',
|
||||
};
|
||||
|
||||
export type Dict = typeof en;
|
||||
|
||||
@@ -64,6 +64,7 @@ export const es: Dict = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': 'Descartar',
|
||||
'toast.undo': 'Deshacer',
|
||||
'toast.uploadFailed': 'Error al subir',
|
||||
|
||||
// --- Modal ---
|
||||
@@ -220,6 +221,25 @@ export const es: Dict = {
|
||||
'lightbox.videoPreview': 'Vista previa de vídeo',
|
||||
'lightbox.videoUnavailable': 'Vídeo no disponible',
|
||||
'lightbox.zoom100': '100%',
|
||||
'lastFrame.export': 'Exportar último fotograma',
|
||||
'lastFrame.exporting': 'Exportando último fotograma…',
|
||||
'lastFrame.hint': 'Guarda el último fotograma como PNG a tamaño completo, p. ej. como referencia de primer fotograma',
|
||||
'lastFrame.fileTag': 'ultimo-fotograma',
|
||||
'lastFrame.done': 'Último fotograma exportado {name} ({size})',
|
||||
'lastFrame.errTimeout': 'El vídeo tardó demasiado en cargar. Inténtalo más tarde.',
|
||||
'lastFrame.errNotFound': 'El archivo de vídeo no existe o fue eliminado.',
|
||||
'lastFrame.errForbidden': 'No tienes permiso para leer este vídeo.',
|
||||
'lastFrame.errNetwork': 'No se pudo leer el vídeo (error de red).',
|
||||
'lastFrame.errDecode': 'El vídeo está dañado; su final no se puede decodificar.',
|
||||
'lastFrame.errUnsupported': 'Este navegador no admite el formato del vídeo o el archivo está dañado.',
|
||||
'lastFrame.errCrossOrigin':
|
||||
'El vídeo procede de otro sitio que no permite leer sus fotogramas (origen cruzado). Descarga primero el vídeo.',
|
||||
'lastFrame.errNoPicture': 'Este archivo no tiene imagen de vídeo.',
|
||||
'lastFrame.errUnseekable':
|
||||
'No se puede llegar al final del vídeo (duración desconocida o el origen no permite buscar); no se exportó nada.',
|
||||
'lastFrame.errBlack': 'El último segundo del vídeo es negro; no se exportó ningún fotograma.',
|
||||
'lastFrame.errEmpty': 'El navegador no pudo decodificar el final. Reintenta o usa Chrome / Edge de escritorio.',
|
||||
'lastFrame.errUnknown': 'Error al exportar el último fotograma: {msg}',
|
||||
|
||||
// --- Artifact / canvas nodes ---
|
||||
'artifact.artifactLabel': 'Artefacto',
|
||||
@@ -948,6 +968,8 @@ export const es: Dict = {
|
||||
'library.selectAll': 'Seleccionar todo',
|
||||
'library.deselectAll': 'Deseleccionar todo',
|
||||
'library.itemCount': '{count} elemento(s)',
|
||||
'library.loadError': 'No se pudo cargar la biblioteca',
|
||||
'library.subtitle': 'Recursos generados en un solo lugar: previsualiza, filtra y elimina imágenes y vídeos sin proyecto',
|
||||
'library.deleteSelected': 'Eliminar ({count})',
|
||||
'library.addToProject': 'Añadir al proyecto',
|
||||
'library.filterLabel': 'Filtrar por tipo',
|
||||
@@ -1259,15 +1281,46 @@ export const es: Dict = {
|
||||
'shell.navAgents': 'Agentes',
|
||||
'shell.navConnections': 'Conexiones ComfyUI',
|
||||
'shell.navModels': 'Modelos',
|
||||
'shell.navLibrary': 'Biblioteca de assets',
|
||||
'shell.navSettings': 'Ajustes',
|
||||
'shell.wip': 'En desarrollo',
|
||||
'shell.wipHint': 'Esta página está en construcción y llegará más adelante',
|
||||
'shell.comfy_ok': '{name} conectado',
|
||||
'shell.comfy_fail': '{name} sin conexión',
|
||||
'shell.comfy_testing': 'Probando {name}…',
|
||||
'shell.comfyUnset': 'ComfyUI · sin configurar',
|
||||
'shell.comfyManage': 'Configura la dirección de ComfyUI en Ajustes',
|
||||
'shell.mobileNotice': 'Abre este banco de trabajo en un navegador de escritorio (mínimo 768 px); los móviles aún no se admiten.',
|
||||
'shell.scopeHint': 'Cambiar de proyecto',
|
||||
'shell.scopeAll': 'Todos los proyectos',
|
||||
'shell.scopeAllList': 'Lista de proyectos',
|
||||
'shell.scopeRecent': 'Proyectos recientes',
|
||||
'shell.scopeLoadFailed': 'No se pudieron cargar los proyectos',
|
||||
'shell.scopeEmpty': 'Aún no hay proyectos: crea uno',
|
||||
'shell.scopeStage': '{n}/5 confirmadas',
|
||||
'shell.queueRunning': '{n} generando',
|
||||
'shell.queueQueued': '{n} en cola',
|
||||
'shell.queueIdle': 'Cola libre',
|
||||
'shell.queueActive': '{n} activos',
|
||||
'shell.trayTitle': 'Bandeja de tareas',
|
||||
'shell.trayRecent': 'Tareas recientes · clic para ver todas',
|
||||
'shell.trayEmpty': 'Aún no hay tareas de generación',
|
||||
'shell.trayAll': 'Ver todas las tareas',
|
||||
'shell.searchTitle': 'Buscar páginas y proyectos',
|
||||
'shell.searchPlaceholder': 'Buscar páginas, proyectos',
|
||||
'shell.searchShortcut': 'Buscar (Ctrl/⌘ + K)',
|
||||
'shell.searchEmpty': 'Ninguna página ni proyecto coincide con «{q}»',
|
||||
'shell.searchGroupPages': 'Páginas',
|
||||
'shell.searchGroupProjects': 'Proyectos · abrir el banco de trabajo',
|
||||
'shell.searchHintMove': 'elegir',
|
||||
'shell.searchHintOpen': 'abrir',
|
||||
'shell.searchHintClose': 'cerrar',
|
||||
'shell.reduceMotion': 'Reducir movimiento',
|
||||
'shell.reduceHint': 'Desactiva desplazamientos, escalados y brillos; solo quedan fundidos breves',
|
||||
'shell.reduceSystem': 'Se sigue el ajuste del sistema «Reducir movimiento»',
|
||||
'shell.tabAria': 'Navegación principal (estrecha)',
|
||||
'shell.tabProjects': 'Proyectos',
|
||||
'shell.tabCreate': 'Crear',
|
||||
'shell.tabGenerate': 'Generar',
|
||||
'shell.tabMore': 'Más',
|
||||
'shell.morePages': 'Más páginas',
|
||||
'shell.moreTools': 'Herramientas',
|
||||
'home.title': 'Resumen de proyectos',
|
||||
'home.subtitle': 'Gestiona tus proyectos: empieza con una idea o un guion terminado.',
|
||||
'home.projectCount': '{n} proyectos · {m} en curso',
|
||||
@@ -1456,13 +1509,11 @@ export const es: Dict = {
|
||||
'studio.genParams': 'Parámetros',
|
||||
'studio.modeTextToImage': 'Texto a imagen',
|
||||
'studio.modeTextToVideo': 'Texto a vídeo',
|
||||
'studio.chipIdle': '{name} · cola libre',
|
||||
'studio.chipBusy': '{name} · {n} en cola',
|
||||
'studio.video.resultsTitle': 'Vista previa',
|
||||
'studio.image.historyTitle': 'Historial de imágenes',
|
||||
'studio.video.historyTitle': 'Historial de vídeos',
|
||||
'studio.historyCount': '{n} en total',
|
||||
'studio.lastDone': 'Último · {time}',
|
||||
'studio.doneAt': 'Completado a las {time}',
|
||||
'studio.kindImageText': 'imagen',
|
||||
'studio.kindVideoText': 'vídeo',
|
||||
'studio.loading': 'Cargando…',
|
||||
@@ -1578,7 +1629,14 @@ 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.deleteFailedToast': 'No se pudo eliminar: {detail}',
|
||||
'studio.deletedTakeNoFilesToast': 'Se eliminó «{name}»',
|
||||
'studio.deletedTakeToast': 'Se eliminó «{name}» junto con sus archivos de resultado',
|
||||
'studio.deletedToast': 'Tarea eliminada junto con sus archivos de resultados',
|
||||
'studio.wfDeletedTag': 'Flujo eliminado',
|
||||
'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',
|
||||
@@ -1597,9 +1655,12 @@ export const es: Dict = {
|
||||
'tasks.subtitle': 'Todas las tareas de imagen / vídeo: ventanas independientes y flujo de creación',
|
||||
'tasks.filterAll': 'Todas',
|
||||
'tasks.filterImage': 'Imágenes',
|
||||
'tasks.filterFailed': 'Fallidas',
|
||||
'tasks.filterActive': 'En curso',
|
||||
'tasks.filterVideo': 'Vídeos',
|
||||
'tasks.count': '{n} tareas · {m} activas',
|
||||
'tasks.empty': 'Aún no hay tareas. Envía la primera en Imagen o Vídeo.',
|
||||
'tasks.emptyFiltered': 'Ninguna tarea coincide con el filtro actual.',
|
||||
'tasks.goGenerate': 'Abrir Imagen',
|
||||
'tasks.sourceStudioImage': 'Imagen independiente',
|
||||
'tasks.sourceStudioVideo': 'Vídeo independiente',
|
||||
@@ -1613,8 +1674,10 @@ export const es: Dict = {
|
||||
'tasks.retry': 'Reintentar',
|
||||
'tasks.cancel': 'Cancelar',
|
||||
'tasks.delete': 'Eliminar',
|
||||
'tasks.download': 'Descargar',
|
||||
'tasks.delTitle': 'Eliminar tarea',
|
||||
'tasks.delConfirm': 'Los archivos de resultados se eliminarán junto con la tarea. ¿Eliminar?',
|
||||
'tasks.searchPh': 'Buscar tareas…',
|
||||
'tasks.outputs': '{n} resultados',
|
||||
|
||||
'agentsPage.title': 'Agentes',
|
||||
@@ -1844,6 +1907,8 @@ export const es: Dict = {
|
||||
'jobst.dryRunDetail': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
|
||||
'jobst.noOutput': 'Sin salida',
|
||||
'jobst.noOutputDetail': 'El trabajo terminó sin archivos de resultado: no cuenta como generado',
|
||||
'jobst.missing': 'Archivos no disponibles',
|
||||
'jobst.missingDetail': 'La tarea terminó correctamente, pero sus archivos ya no están en el servidor',
|
||||
'jobst.queued': 'En cola',
|
||||
'jobst.queuedDetail': 'Esperando en la cola del taller para enviarse a ComfyUI',
|
||||
'jobst.connecting': 'Conectando',
|
||||
@@ -1872,4 +1937,163 @@ 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',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': 'Original {v}',
|
||||
'desk.title': 'Generar',
|
||||
'desk.advanced': 'Avanzado · {n} · sin cambios se usan los valores del flujo',
|
||||
'desk.chipResolution': 'Resolución {v}',
|
||||
'desk.chipSeconds': '{v} s',
|
||||
'desk.chipSeed': 'Semilla {v}',
|
||||
'desk.closeInspector': 'Cerrar inspector',
|
||||
'desk.countActive': '{n} en curso',
|
||||
'desk.countTakes': '{n} registros',
|
||||
'desk.ctxAsset': '{kind} «{name}»',
|
||||
'desk.ctxShot': 'Plano {no}',
|
||||
'desk.ctxBack': 'Volver a {stage}',
|
||||
'desk.ctxDismiss': 'Cerrar aviso de origen',
|
||||
'desk.ctxFrom': 'De «{project}»',
|
||||
'desk.ctxNoBackfill': 'Los resultados quedan en el registro y no se devuelven a {stage}',
|
||||
'desk.dayDate': '{d}/{m}',
|
||||
'desk.download': 'Descargar',
|
||||
'desk.dropNoSlot': '{n} archivo(s) sin ranura: este flujo no tiene esa entrada',
|
||||
'desk.dryRun': 'Simulación',
|
||||
'desk.durMin': '{m} min {s} s',
|
||||
'desk.durSec': '{s} s',
|
||||
'desk.elapsed': 'Tiempo',
|
||||
'desk.emptyFailed': 'No hay registros fallidos',
|
||||
'desk.emptyFeed': 'Aún no hay registros. Escribe un prompt abajo y pulsa Generar.',
|
||||
'desk.filterAria': 'Filtrar registros',
|
||||
'desk.filterAll': 'Todo',
|
||||
'desk.filterImage': 'Imágenes',
|
||||
'desk.filterVideo': 'Vídeos',
|
||||
'desk.filterFailed': 'Fallidos',
|
||||
'desk.generate': 'Generar',
|
||||
'desk.genHint': 'Generar (Ctrl/⌘ + Enter)',
|
||||
'desk.inputs': 'Entradas',
|
||||
'desk.inspector': 'Inspector',
|
||||
'desk.keyClose': 'Cerrar',
|
||||
'desk.keyInspector': 'Mostrar / ocultar',
|
||||
'desk.keySelect': 'Elegir',
|
||||
'desk.keyUse': 'Usar',
|
||||
'desk.kvCreated': 'Enviado',
|
||||
'desk.kvName': 'Nombre',
|
||||
'desk.kvPromptId': 'ID de ComfyUI',
|
||||
'desk.kvUsage': 'Uso',
|
||||
'desk.loadEarlier': 'Mostrar {n} tomas anteriores',
|
||||
'desk.missingSub': 'Rehacer con los mismos parámetros, o quitar esta toma',
|
||||
'desk.missingTitle': 'Los archivos de resultado ya no están en el servidor',
|
||||
'desk.needWorkflow': 'Falta: flujo',
|
||||
'desk.noConn': 'Sin conexión',
|
||||
'desk.noPromptInput': 'Este flujo no tiene prompt; se ejecuta con sus propios valores.',
|
||||
'desk.notGenerated': 'No generado',
|
||||
'desk.noWorkflows': 'Aún no hay flujos de imagen o vídeo listos',
|
||||
'desk.openInDesk': 'Ajustar en el generador',
|
||||
'desk.openInDeskHint': 'Abre el generador con este prompt y elige flujo y parámetros; los resultados quedan en el registro',
|
||||
'desk.openInspector': 'Inspector',
|
||||
'desk.origValue': 'Valor del flujo',
|
||||
'desk.outputN': 'Resultado {n}',
|
||||
'desk.outputs': 'Todos los resultados',
|
||||
'desk.paramError': 'Parámetro no válido: {msg}',
|
||||
'desk.paramsAll': 'Todos los parámetros',
|
||||
'desk.paramsChanged': 'Parámetros · {n} cambiados',
|
||||
'desk.paramsCommon': 'Parámetros comunes',
|
||||
'desk.pickTake': 'Elige un registro para ver su resultado',
|
||||
'desk.pickWorkflow': 'Elegir flujo',
|
||||
'desk.promptOnly': 'Solo prompt',
|
||||
'desk.promptPh': 'Describe la escena… (Ctrl/⌘ + Enter genera, / enfoca)',
|
||||
'desk.restoreAll': 'Restaurar todos los valores del flujo',
|
||||
'desk.redo': 'Rehacer con los mismos parámetros',
|
||||
'desk.removeRecord': 'Quitar el registro',
|
||||
'desk.restoreOrig': 'Restaurar original',
|
||||
'desk.reuse': 'Reutilizar',
|
||||
'desk.reuseToComposer': 'Reutilizar en el editor',
|
||||
'desk.seedRandom': 'Aleatoria',
|
||||
'desk.sendToSlot': 'Enviar a la ranura de imagen',
|
||||
'desk.sent': 'Enviado',
|
||||
'desk.sentToSlotToast': 'Enviado a la ranura de imagen del flujo actual',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': 'Tamaño',
|
||||
'desk.stillNeed': 'Falta: {what}',
|
||||
'desk.tabParams': 'Parámetros',
|
||||
'desk.tabResult': 'Resultado',
|
||||
'desk.today': 'Hoy',
|
||||
'desk.took': 'Tardó {d}',
|
||||
'desk.wfBroken': 'No disponible',
|
||||
'desk.wfCurrent': 'actual',
|
||||
'desk.wfDefaultConn': 'Conexión predeterminada',
|
||||
'desk.wfImport': 'Importar / gestionar flujos',
|
||||
'desk.wfLastFailed': 'Falló la última vez',
|
||||
'desk.wfNoMatch': 'Ningún flujo coincide',
|
||||
'desk.wfOk': 'Listo',
|
||||
'desk.wfSearchPh': 'Busca flujos o un uso: imagen a vídeo, referencia…',
|
||||
'desk.wfTesting': 'Prueba en curso',
|
||||
'desk.wfUntested': 'Sin probar',
|
||||
'desk.workflow': 'Flujo',
|
||||
'desk.yesterday': 'Ayer',
|
||||
'wfd.stepParse': 'Analizar JSON',
|
||||
'wfd.stepNodes': 'Identificar nodos',
|
||||
'wfd.stepModels': 'Verificar modelos',
|
||||
'wfd.stepSlots': 'Crear slots de entrada',
|
||||
'wfd.noOutput': 'El flujo no tiene nodos de salida; no se puede importar',
|
||||
'wfd.nodesAutoOk': 'Uso detectado automáticamente: {usage}',
|
||||
'wfd.nodesUserOk': 'Uso confirmado: {usage}',
|
||||
'wfd.modelsOk': '«{name}» tiene todos los nodos y modelos necesarios',
|
||||
'wfd.slotsOk': '{n} slot(s) de entrada creados ({m} de medios)',
|
||||
'wfd.checksAria': 'Resultados de la comprobación de importación',
|
||||
'wfd.showDetails': 'Mostrar detalles del error',
|
||||
'wfd.hideDetails': 'Ocultar detalles del error',
|
||||
'wfd.unreachableSuggest': 'No poder verificar no impide guardar; vuelve a comprobar tras arreglar la conexión.',
|
||||
'wfd.nodesPickSuggest': 'Elige el uso y completa el mapeo de salida abajo antes de guardar.',
|
||||
'wfd.showRaw': 'Ver JSON original',
|
||||
'wfd.hideRaw': 'Ocultar JSON original',
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ export const fr: Dict = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': 'Ignorer',
|
||||
'toast.undo': 'Annuler',
|
||||
'toast.uploadFailed': "Échec de l'import",
|
||||
|
||||
// --- Modal ---
|
||||
@@ -220,6 +221,25 @@ export const fr: Dict = {
|
||||
'lightbox.videoPreview': 'Aperçu de la vidéo',
|
||||
'lightbox.videoUnavailable': 'Vidéo indisponible',
|
||||
'lightbox.zoom100': '100 %',
|
||||
'lastFrame.export': 'Exporter la dernière image',
|
||||
'lastFrame.exporting': 'Export de la dernière image…',
|
||||
'lastFrame.hint': 'Enregistre la dernière image en PNG pleine taille, p. ex. comme référence de première image',
|
||||
'lastFrame.fileTag': 'derniere-image',
|
||||
'lastFrame.done': 'Dernière image exportée {name} ({size})',
|
||||
'lastFrame.errTimeout': 'Le chargement de la vidéo a expiré. Réessayez plus tard.',
|
||||
'lastFrame.errNotFound': 'Le fichier vidéo est introuvable ou a été supprimé.',
|
||||
'lastFrame.errForbidden': "Vous n'avez pas l'autorisation de lire cette vidéo.",
|
||||
'lastFrame.errNetwork': 'Lecture de la vidéo impossible (erreur réseau).',
|
||||
'lastFrame.errDecode': 'La vidéo est endommagée ; sa fin ne peut pas être décodée.',
|
||||
'lastFrame.errUnsupported': 'Ce navigateur ne prend pas en charge ce format vidéo, ou le fichier est endommagé.',
|
||||
'lastFrame.errCrossOrigin':
|
||||
"La vidéo provient d'un autre site qui bloque la lecture des images (cross-origin). Téléchargez d'abord la vidéo.",
|
||||
'lastFrame.errNoPicture': "Ce fichier ne contient pas d'image vidéo.",
|
||||
'lastFrame.errUnseekable':
|
||||
"Impossible d'atteindre la fin de la vidéo (durée inconnue ou source sans recherche) ; aucune image exportée.",
|
||||
'lastFrame.errBlack': 'La dernière seconde de la vidéo est noire ; aucune image exportée.',
|
||||
'lastFrame.errEmpty': "Le navigateur n'a pas pu décoder la fin. Réessayez ou utilisez Chrome / Edge sur ordinateur.",
|
||||
'lastFrame.errUnknown': "Échec de l'export de la dernière image : {msg}",
|
||||
|
||||
// --- Artifact / canvas nodes ---
|
||||
'artifact.artifactLabel': 'Artefact',
|
||||
@@ -948,6 +968,8 @@ export const fr: Dict = {
|
||||
'library.selectAll': 'Tout sélectionner',
|
||||
'library.deselectAll': 'Tout désélectionner',
|
||||
'library.itemCount': '{count} élément(s)',
|
||||
'library.loadError': 'Échec du chargement de la bibliothèque',
|
||||
'library.subtitle': 'Les ressources générées au même endroit : prévisualisez, filtrez et supprimez les images et vidéos sans projet',
|
||||
'library.deleteSelected': 'Supprimer ({count})',
|
||||
'library.addToProject': 'Ajouter au projet',
|
||||
'library.filterLabel': 'Filtrer par type',
|
||||
@@ -1259,15 +1281,46 @@ export const fr: Dict = {
|
||||
'shell.navAgents': 'Agents',
|
||||
'shell.navConnections': 'Connexions ComfyUI',
|
||||
'shell.navModels': 'Modèles',
|
||||
'shell.navLibrary': 'Bibliothèque d’assets',
|
||||
'shell.navSettings': 'Réglages',
|
||||
'shell.wip': 'En développement',
|
||||
'shell.wipHint': 'Cette page est en construction et arrivera plus tard',
|
||||
'shell.comfy_ok': '{name} connecté',
|
||||
'shell.comfy_fail': '{name} injoignable',
|
||||
'shell.comfy_testing': 'Test de {name}…',
|
||||
'shell.comfyUnset': 'ComfyUI · non configuré',
|
||||
'shell.comfyManage': 'Configurez l\'adresse ComfyUI dans les Réglages',
|
||||
'shell.mobileNotice': 'Ouvrez cet atelier dans un navigateur de bureau (largeur minimum 768 px) ; les téléphones ne sont pas encore pris en charge.',
|
||||
'shell.scopeHint': 'Changer de projet',
|
||||
'shell.scopeAll': 'Tous les projets',
|
||||
'shell.scopeAllList': 'Liste des projets',
|
||||
'shell.scopeRecent': 'Projets récents',
|
||||
'shell.scopeLoadFailed': 'Impossible de charger les projets',
|
||||
'shell.scopeEmpty': 'Aucun projet — en créer un',
|
||||
'shell.scopeStage': '{n}/5 validées',
|
||||
'shell.queueRunning': '{n} en cours',
|
||||
'shell.queueQueued': '{n} en file',
|
||||
'shell.queueIdle': 'File libre',
|
||||
'shell.queueActive': '{n} actifs',
|
||||
'shell.trayTitle': 'Plateau des tâches',
|
||||
'shell.trayRecent': 'Tâches récentes · cliquer pour tout voir',
|
||||
'shell.trayEmpty': 'Aucune tâche de génération',
|
||||
'shell.trayAll': 'Voir toutes les tâches',
|
||||
'shell.searchTitle': 'Rechercher pages et projets',
|
||||
'shell.searchPlaceholder': 'Rechercher pages, projets',
|
||||
'shell.searchShortcut': 'Rechercher (Ctrl/⌘ + K)',
|
||||
'shell.searchEmpty': 'Aucune page ni projet ne correspond à « {q} »',
|
||||
'shell.searchGroupPages': 'Pages',
|
||||
'shell.searchGroupProjects': 'Projets · ouvrir l’atelier',
|
||||
'shell.searchHintMove': 'choisir',
|
||||
'shell.searchHintOpen': 'ouvrir',
|
||||
'shell.searchHintClose': 'fermer',
|
||||
'shell.reduceMotion': 'Réduire les animations',
|
||||
'shell.reduceHint': 'Coupe déplacements, zooms et halos ; garde seulement de courts fondus',
|
||||
'shell.reduceSystem': 'Réglage système « Réduire les animations » appliqué',
|
||||
'shell.tabAria': 'Navigation principale (étroite)',
|
||||
'shell.tabProjects': 'Projets',
|
||||
'shell.tabCreate': 'Créer',
|
||||
'shell.tabGenerate': 'Générer',
|
||||
'shell.tabMore': 'Plus',
|
||||
'shell.morePages': 'Autres pages',
|
||||
'shell.moreTools': 'Outils',
|
||||
'home.title': 'Aperçu des projets',
|
||||
'home.subtitle': 'Gérez vos projets — commencez avec une idée ou un scénario prêt.',
|
||||
'home.projectCount': '{n} projets · {m} en cours',
|
||||
@@ -1456,13 +1509,11 @@ export const fr: Dict = {
|
||||
'studio.genParams': 'Paramètres',
|
||||
'studio.modeTextToImage': 'Texte vers image',
|
||||
'studio.modeTextToVideo': 'Texte vers vidéo',
|
||||
'studio.chipIdle': '{name} · file libre',
|
||||
'studio.chipBusy': '{name} · {n} en file',
|
||||
'studio.video.resultsTitle': 'Aperçu',
|
||||
'studio.image.historyTitle': 'Historique images',
|
||||
'studio.video.historyTitle': 'Historique vidéos',
|
||||
'studio.historyCount': '{n} au total',
|
||||
'studio.lastDone': 'Dernier · {time}',
|
||||
'studio.doneAt': 'Terminé à {time}',
|
||||
'studio.kindImageText': 'image',
|
||||
'studio.kindVideoText': 'vidéo',
|
||||
'studio.loading': 'Chargement…',
|
||||
@@ -1578,7 +1629,14 @@ 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.deleteFailedToast': 'Échec de la suppression : {detail}',
|
||||
'studio.deletedTakeNoFilesToast': '« {name} » supprimé',
|
||||
'studio.deletedTakeToast': '« {name} » et ses fichiers de résultat ont été supprimés',
|
||||
'studio.deletedToast': 'Tâche supprimée avec ses fichiers de résultats',
|
||||
'studio.wfDeletedTag': 'Workflow supprimé',
|
||||
'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',
|
||||
@@ -1597,9 +1655,12 @@ export const fr: Dict = {
|
||||
'tasks.subtitle': 'Toutes les tâches image / vidéo : fenêtres indépendantes et flux de création',
|
||||
'tasks.filterAll': 'Toutes',
|
||||
'tasks.filterImage': 'Images',
|
||||
'tasks.filterFailed': 'Échecs',
|
||||
'tasks.filterActive': 'En cours',
|
||||
'tasks.filterVideo': 'Vidéos',
|
||||
'tasks.count': '{n} tâches · {m} actives',
|
||||
'tasks.empty': 'Aucune tâche. Lancez la première dans Image ou Vidéo.',
|
||||
'tasks.emptyFiltered': 'Aucune tâche ne correspond au filtre actuel.',
|
||||
'tasks.goGenerate': 'Ouvrir Image',
|
||||
'tasks.sourceStudioImage': 'Image indépendante',
|
||||
'tasks.sourceStudioVideo': 'Vidéo indépendante',
|
||||
@@ -1613,8 +1674,10 @@ export const fr: Dict = {
|
||||
'tasks.retry': 'Réessayer',
|
||||
'tasks.cancel': 'Annuler',
|
||||
'tasks.delete': 'Supprimer',
|
||||
'tasks.download': 'Télécharger',
|
||||
'tasks.delTitle': 'Supprimer la tâche',
|
||||
'tasks.delConfirm': 'Les fichiers de résultats seront supprimés avec la tâche. Supprimer ?',
|
||||
'tasks.searchPh': 'Rechercher des tâches…',
|
||||
'tasks.outputs': '{n} résultats',
|
||||
|
||||
'agentsPage.title': 'Agents',
|
||||
@@ -1844,6 +1907,8 @@ export const fr: Dict = {
|
||||
'jobst.dryRunDetail': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
|
||||
'jobst.noOutput': 'Aucune sortie',
|
||||
'jobst.noOutputDetail': 'La tâche s\'est terminée sans fichier de résultat : pas une génération',
|
||||
'jobst.missing': 'Fichiers manquants',
|
||||
'jobst.missingDetail': 'La tâche a réussi, mais ses fichiers ne sont plus sur le serveur',
|
||||
'jobst.queued': 'En attente',
|
||||
'jobst.queuedDetail': 'En attente dans la file de l\'atelier avant envoi à ComfyUI',
|
||||
'jobst.connecting': 'Connexion',
|
||||
@@ -1872,4 +1937,163 @@ 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',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': 'Original {v}',
|
||||
'desk.title': 'Générer',
|
||||
'desk.advanced': 'Avancé · {n} · inchangés, les valeurs du workflow s’appliquent',
|
||||
'desk.chipResolution': 'Résolution {v}',
|
||||
'desk.chipSeconds': '{v} s',
|
||||
'desk.chipSeed': 'Graine {v}',
|
||||
'desk.closeInspector': 'Fermer l’inspecteur',
|
||||
'desk.countActive': '{n} en cours',
|
||||
'desk.countTakes': '{n} prises',
|
||||
'desk.ctxAsset': '{kind} « {name} »',
|
||||
'desk.ctxShot': 'Plan {no}',
|
||||
'desk.ctxBack': 'Retour à {stage}',
|
||||
'desk.ctxDismiss': 'Fermer la note d’origine',
|
||||
'desk.ctxFrom': 'Depuis « {project} »',
|
||||
'desk.ctxNoBackfill': 'Les résultats restent dans le fil et ne sont pas renvoyés vers {stage}',
|
||||
'desk.dayDate': '{d}/{m}',
|
||||
'desk.download': 'Télécharger',
|
||||
'desk.dropNoSlot': '{n} fichier(s) sans emplacement : ce workflow n’a pas cette entrée',
|
||||
'desk.dryRun': 'Simulation',
|
||||
'desk.durMin': '{m} min {s} s',
|
||||
'desk.durSec': '{s} s',
|
||||
'desk.elapsed': 'Durée',
|
||||
'desk.emptyFailed': 'Aucune prise échouée',
|
||||
'desk.emptyFeed': 'Aucune prise. Écrivez un prompt ci-dessous et cliquez sur Générer.',
|
||||
'desk.filterAria': 'Filtrer les prises',
|
||||
'desk.filterAll': 'Tout',
|
||||
'desk.filterImage': 'Images',
|
||||
'desk.filterVideo': 'Vidéos',
|
||||
'desk.filterFailed': 'Échecs',
|
||||
'desk.generate': 'Générer',
|
||||
'desk.genHint': 'Générer (Ctrl/⌘ + Entrée)',
|
||||
'desk.inputs': 'Entrées',
|
||||
'desk.inspector': 'Inspecteur',
|
||||
'desk.keyClose': 'Fermer',
|
||||
'desk.keyInspector': 'Afficher / masquer',
|
||||
'desk.keySelect': 'Choisir',
|
||||
'desk.keyUse': 'Utiliser',
|
||||
'desk.kvCreated': 'Soumis',
|
||||
'desk.kvName': 'Nom',
|
||||
'desk.kvPromptId': 'ID ComfyUI',
|
||||
'desk.kvUsage': 'Usage',
|
||||
'desk.loadEarlier': 'Afficher {n} prises plus anciennes',
|
||||
'desk.missingSub': 'Refaire avec les mêmes paramètres, ou retirer cette prise',
|
||||
'desk.missingTitle': 'Les fichiers de résultat ne sont plus sur le serveur',
|
||||
'desk.needWorkflow': 'Manque : workflow',
|
||||
'desk.noConn': 'Non connecté',
|
||||
'desk.noPromptInput': 'Ce workflow n’a pas de prompt ; il s’exécute avec ses propres valeurs.',
|
||||
'desk.notGenerated': 'Non généré',
|
||||
'desk.noWorkflows': 'Aucun workflow image ou vidéo prêt',
|
||||
'desk.openInDesk': 'Régler dans le générateur',
|
||||
'desk.openInDeskHint': 'Ouvre le générateur avec ce prompt ; choisissez workflow et paramètres, les résultats restent dans le fil',
|
||||
'desk.openInspector': 'Inspecteur',
|
||||
'desk.origValue': 'Valeur du workflow',
|
||||
'desk.outputN': 'Résultat {n}',
|
||||
'desk.outputs': 'Tous les résultats',
|
||||
'desk.paramError': 'Paramètre invalide : {msg}',
|
||||
'desk.paramsAll': 'Tous les paramètres',
|
||||
'desk.paramsChanged': 'Paramètres · {n} modifiés',
|
||||
'desk.paramsCommon': 'Paramètres courants',
|
||||
'desk.pickTake': 'Choisissez une prise pour voir son résultat',
|
||||
'desk.pickWorkflow': 'Choisir un workflow',
|
||||
'desk.promptOnly': 'Prompt seul',
|
||||
'desk.promptPh': 'Décrivez l’image… (Ctrl/⌘ + Entrée pour générer, / pour cibler)',
|
||||
'desk.restoreAll': 'Rétablir toutes les valeurs du workflow',
|
||||
'desk.redo': 'Refaire avec les mêmes paramètres',
|
||||
'desk.removeRecord': 'Retirer la ligne',
|
||||
'desk.restoreOrig': 'Rétablir l’original',
|
||||
'desk.reuse': 'Réutiliser',
|
||||
'desk.reuseToComposer': 'Réutiliser dans l’éditeur',
|
||||
'desk.seedRandom': 'Aléatoire',
|
||||
'desk.sendToSlot': 'Envoyer vers l’emplacement image',
|
||||
'desk.sent': 'Soumis',
|
||||
'desk.sentToSlotToast': 'Envoyé vers l’emplacement image du workflow actuel',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': 'Taille',
|
||||
'desk.stillNeed': 'Manque : {what}',
|
||||
'desk.tabParams': 'Paramètres',
|
||||
'desk.tabResult': 'Résultat',
|
||||
'desk.today': 'Aujourd’hui',
|
||||
'desk.took': 'Durée {d}',
|
||||
'desk.wfBroken': 'Indisponible',
|
||||
'desk.wfCurrent': 'actuel',
|
||||
'desk.wfDefaultConn': 'Connexion par défaut',
|
||||
'desk.wfImport': 'Importer / gérer les workflows',
|
||||
'desk.wfLastFailed': 'Dernier essai échoué',
|
||||
'desk.wfNoMatch': 'Aucun workflow correspondant',
|
||||
'desk.wfOk': 'Prêt',
|
||||
'desk.wfSearchPh': 'Chercher un workflow ou un usage : image vers vidéo, référence…',
|
||||
'desk.wfTesting': 'Test en cours',
|
||||
'desk.wfUntested': 'Non testé',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Hier',
|
||||
'wfd.stepParse': 'Analyser le JSON',
|
||||
'wfd.stepNodes': 'Identifier les nœuds',
|
||||
'wfd.stepModels': 'Vérifier les modèles',
|
||||
'wfd.stepSlots': 'Créer les slots d’entrée',
|
||||
'wfd.noOutput': "Le flux n'a aucun nœud de sortie — import impossible",
|
||||
'wfd.nodesAutoOk': 'Usage détecté automatiquement : {usage}',
|
||||
'wfd.nodesUserOk': 'Usage confirmé : {usage}',
|
||||
'wfd.modelsOk': '« {name} » possède tous les nœuds et modèles requis',
|
||||
'wfd.slotsOk': "{n} slot(s) d'entrée créés ({m} média)",
|
||||
'wfd.checksAria': "Résultats de la vérification d'import",
|
||||
'wfd.showDetails': "Afficher les détails de l'erreur",
|
||||
'wfd.hideDetails': "Masquer les détails de l'erreur",
|
||||
'wfd.unreachableSuggest': "L'impossibilité de vérifier n'empêche pas l'enregistrement ; revérifiez après avoir réparé la connexion.",
|
||||
'wfd.nodesPickSuggest': "Choisissez l'usage et terminez le mappage des sorties ci-dessous avant d'enregistrer.",
|
||||
'wfd.showRaw': 'Voir le JSON brut',
|
||||
'wfd.hideRaw': 'Masquer le JSON brut',
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ export const ja: Dict = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': '閉じる',
|
||||
'toast.undo': '元に戻す',
|
||||
'toast.uploadFailed': 'アップロードに失敗しました',
|
||||
|
||||
// --- Modal ---
|
||||
@@ -220,6 +221,23 @@ export const ja: Dict = {
|
||||
'lightbox.videoPreview': 'ビデオプレビュー',
|
||||
'lightbox.videoUnavailable': 'ビデオを再生できません',
|
||||
'lightbox.zoom100': '100%',
|
||||
'lastFrame.export': '最終フレームを書き出す',
|
||||
'lastFrame.exporting': '最終フレームを書き出し中…',
|
||||
'lastFrame.hint': '動画の最後のフレームを元のサイズの PNG で保存します(先頭フレーム参照用)',
|
||||
'lastFrame.fileTag': '最終フレーム',
|
||||
'lastFrame.done': '最終フレームを書き出しました {name}({size})',
|
||||
'lastFrame.errTimeout': '動画の読み込みがタイムアウトしました。後でもう一度お試しください。',
|
||||
'lastFrame.errNotFound': '動画ファイルが存在しないか削除されています。',
|
||||
'lastFrame.errForbidden': 'この動画へのアクセス権限がありません。',
|
||||
'lastFrame.errNetwork': '動画の読み込みに失敗しました(ネットワークエラー)。',
|
||||
'lastFrame.errDecode': '動画が破損しており、末尾をデコードできません。',
|
||||
'lastFrame.errUnsupported': 'このブラウザーは動画形式に対応していないか、ファイルが破損しています。',
|
||||
'lastFrame.errCrossOrigin': '他サイトの動画のためフレームを読み取れません(クロスオリジン制限)。先に動画をダウンロードしてください。',
|
||||
'lastFrame.errNoPicture': 'このファイルには映像がありません。',
|
||||
'lastFrame.errUnseekable': '動画の末尾に移動できません(長さ不明、またはシーク非対応)。書き出しませんでした。',
|
||||
'lastFrame.errBlack': '動画の最後の 1 秒が黒画面のため、書き出しませんでした。',
|
||||
'lastFrame.errEmpty': '末尾をデコードできませんでした。再試行するか、PC の Chrome / Edge をお使いください。',
|
||||
'lastFrame.errUnknown': '最終フレームの書き出しに失敗しました:{msg}',
|
||||
|
||||
// --- Artifact / canvas nodes ---
|
||||
'artifact.artifactLabel': '成果物',
|
||||
@@ -948,6 +966,8 @@ export const ja: Dict = {
|
||||
'library.selectAll': 'すべて選択',
|
||||
'library.deselectAll': '選択を解除',
|
||||
'library.itemCount': '{count} 件',
|
||||
'library.loadError': 'ライブラリの読み込みに失敗しました',
|
||||
'library.subtitle': '生成素材を一元管理——プロジェクトに紐付いていない画像・動画をプレビュー・絞り込み・削除できます',
|
||||
'library.deleteSelected': '削除({count})',
|
||||
'library.addToProject': 'プロジェクトに追加',
|
||||
'library.filterLabel': '種類で絞り込み',
|
||||
@@ -1259,15 +1279,46 @@ export const ja: Dict = {
|
||||
'shell.navAgents': 'エージェント',
|
||||
'shell.navConnections': 'ComfyUI 接続',
|
||||
'shell.navModels': 'モデル設定',
|
||||
'shell.navLibrary': 'アセットライブラリ',
|
||||
'shell.navSettings': '設定',
|
||||
'shell.wip': '開発中',
|
||||
'shell.wipHint': 'このページは準備中です。後ほど公開されます',
|
||||
'shell.comfy_ok': '{name} 接続済み',
|
||||
'shell.comfy_fail': '{name} 接続失敗',
|
||||
'shell.comfy_testing': '{name} テスト中…',
|
||||
'shell.comfyUnset': 'ComfyUI · 未設定',
|
||||
'shell.comfyManage': '「設定」で ComfyUI のアドレスを設定できます',
|
||||
'shell.mobileNotice': 'このワークベンチはデスクトップブラウザ(幅 768px 以上)で開いてください。スマートフォンには未対応です。',
|
||||
'shell.scopeHint': 'プロジェクト切替',
|
||||
'shell.scopeAll': 'すべてのプロジェクト',
|
||||
'shell.scopeAllList': 'プロジェクト一覧',
|
||||
'shell.scopeRecent': '最近のプロジェクト',
|
||||
'shell.scopeLoadFailed': 'プロジェクトを読み込めませんでした',
|
||||
'shell.scopeEmpty': 'プロジェクトはまだありません — 作成する',
|
||||
'shell.scopeStage': '{n}/5 確定',
|
||||
'shell.queueRunning': '{n} 件生成中',
|
||||
'shell.queueQueued': '{n} 件待機',
|
||||
'shell.queueIdle': 'キューは空です',
|
||||
'shell.queueActive': '{n} 件進行中',
|
||||
'shell.trayTitle': 'タスクトレイ',
|
||||
'shell.trayRecent': '最近のタスク · クリックで全タスク',
|
||||
'shell.trayEmpty': '生成タスクはまだありません',
|
||||
'shell.trayAll': 'すべてのタスクを見る',
|
||||
'shell.searchTitle': 'ページとプロジェクトを検索',
|
||||
'shell.searchPlaceholder': 'ページ・プロジェクトを検索',
|
||||
'shell.searchShortcut': '検索(Ctrl/⌘ + K)',
|
||||
'shell.searchEmpty': '「{q}」に一致するページやプロジェクトはありません',
|
||||
'shell.searchGroupPages': 'ページ',
|
||||
'shell.searchGroupProjects': 'プロジェクト · ワークベンチを開く',
|
||||
'shell.searchHintMove': '選択',
|
||||
'shell.searchHintOpen': '開く',
|
||||
'shell.searchHintClose': '閉じる',
|
||||
'shell.reduceMotion': '動きを減らす',
|
||||
'shell.reduceHint': '移動・拡大・発光アニメーションを止め、短いフェードのみ残します',
|
||||
'shell.reduceSystem': 'システムの「視差効果を減らす」設定に従っています',
|
||||
'shell.tabAria': 'メインナビ(狭い画面)',
|
||||
'shell.tabProjects': 'プロジェクト',
|
||||
'shell.tabCreate': '創作',
|
||||
'shell.tabGenerate': '生成',
|
||||
'shell.tabMore': 'その他',
|
||||
'shell.morePages': 'その他のページ',
|
||||
'shell.moreTools': 'ツール',
|
||||
'home.title': 'プロジェクト概要',
|
||||
'home.subtitle': '創作プロジェクトを管理 — アイデアでも完成台本でも始められます。',
|
||||
'home.projectCount': 'プロジェクト {n} 件 · 進行中 {m} 件',
|
||||
@@ -1456,13 +1507,11 @@ export const ja: Dict = {
|
||||
'studio.genParams': '生成パラメータ',
|
||||
'studio.modeTextToImage': 'テキストから画像',
|
||||
'studio.modeTextToVideo': 'テキストから動画',
|
||||
'studio.chipIdle': '{name} · キュー空き',
|
||||
'studio.chipBusy': '{name} · キュー {n} 件',
|
||||
'studio.video.resultsTitle': 'プレビュー',
|
||||
'studio.image.historyTitle': '画像生成履歴',
|
||||
'studio.video.historyTitle': '動画生成履歴',
|
||||
'studio.historyCount': '計 {n} 回',
|
||||
'studio.lastDone': '最終完了 · {time}',
|
||||
'studio.doneAt': '完了 {time}',
|
||||
'studio.kindImageText': '画像',
|
||||
'studio.kindVideoText': '動画',
|
||||
'studio.loading': '読み込み中…',
|
||||
@@ -1578,7 +1627,14 @@ export const ja: Dict = {
|
||||
'studio.cancelToast': 'キャンセルを要求しました',
|
||||
'studio.deleteBtn': '削除',
|
||||
'studio.delTitle': 'タスク記録を削除',
|
||||
'studio.delConfirm': '結果ファイルも一緒に削除され、履歴は復元できません。削除しますか?',
|
||||
'studio.delConfirm': '削除するとこのタスクの結果ファイルも削除され、履歴は元に戻せません。削除しますか?',
|
||||
'studio.delTitleJob': 'タスク「{name}」を削除',
|
||||
'studio.delConfirmMulti': 'このタスクには結果が {n} 件あります。削除するとタスク記録と {n} 件すべての結果ファイルが失われ、元に戻せません。削除しますか?',
|
||||
'studio.deleteFailedToast': '削除に失敗しました:{detail}',
|
||||
'studio.deletedTakeNoFilesToast': '「{name}」の記録を削除しました',
|
||||
'studio.deletedTakeToast': '「{name}」の記録と結果ファイルを削除しました',
|
||||
'studio.deletedToast': 'タスクと結果ファイルを削除しました',
|
||||
'studio.wfDeletedTag': 'ワークフローは削除済み',
|
||||
'studio.wfEmptyTitle': '{kind}ワークフローがまだありません',
|
||||
'studio.wfEmptyBody': 'ComfyUI の開発者モードで API 形式 JSON を書き出し、「ComfyUI 接続」ページで{kind}として取り込んでください。',
|
||||
'studio.wfEmptyGo': '「ComfyUI 接続」で取り込む',
|
||||
@@ -1597,9 +1653,12 @@ export const ja: Dict = {
|
||||
'tasks.subtitle': '独立画像・独立動画・創作フロー内の画像/動画タスクをまとめて表示',
|
||||
'tasks.filterAll': 'すべて',
|
||||
'tasks.filterImage': '画像',
|
||||
'tasks.filterFailed': '失敗',
|
||||
'tasks.filterActive': '実行中',
|
||||
'tasks.filterVideo': '動画',
|
||||
'tasks.count': '{n} 件 · 実行中 {m} 件',
|
||||
'tasks.empty': '生成タスクがまだありません。「画像生成」「動画生成」から最初のタスクを送信してください。',
|
||||
'tasks.emptyFiltered': '現在のフィルターに一致するタスクはありません。',
|
||||
'tasks.goGenerate': '画像生成を開く',
|
||||
'tasks.sourceStudioImage': '独立画像',
|
||||
'tasks.sourceStudioVideo': '独立動画',
|
||||
@@ -1613,8 +1672,10 @@ export const ja: Dict = {
|
||||
'tasks.retry': '再試行',
|
||||
'tasks.cancel': 'キャンセル',
|
||||
'tasks.delete': '削除',
|
||||
'tasks.download': 'ダウンロード',
|
||||
'tasks.delTitle': 'タスクを削除',
|
||||
'tasks.delConfirm': '結果ファイルも一緒に削除されます。削除しますか?',
|
||||
'tasks.searchPh': 'タスクを検索…',
|
||||
'tasks.outputs': '結果 {n} 件',
|
||||
|
||||
'agentsPage.title': 'エージェント',
|
||||
@@ -1844,6 +1905,8 @@ export const ja: Dict = {
|
||||
'jobst.dryRunDetail': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
|
||||
'jobst.noOutput': '出力なし',
|
||||
'jobst.noOutputDetail': '結果ファイルなしで終了しました(生成完了ではありません)',
|
||||
'jobst.missing': 'ファイル欠落',
|
||||
'jobst.missingDetail': 'タスクは成功しましたが、結果ファイルはサーバー上にありません',
|
||||
'jobst.queued': '待機中',
|
||||
'jobst.queuedDetail': 'ワークベンチのキューで ComfyUI への送信を待っています',
|
||||
'jobst.connecting': '接続中',
|
||||
@@ -1872,4 +1935,163 @@ 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': 'ワークフローテスト',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': '元の値 {v}',
|
||||
'desk.title': '生成台',
|
||||
'desk.advanced': '詳細パラメータ {n} 項目 · 変更しなければワークフローの値を使用',
|
||||
'desk.chipResolution': '解像度 {v}',
|
||||
'desk.chipSeconds': '{v} 秒',
|
||||
'desk.chipSeed': 'シード {v}',
|
||||
'desk.closeInspector': 'インスペクターを閉じる',
|
||||
'desk.countActive': '{n} 件実行中',
|
||||
'desk.countTakes': '{n} 件の記録',
|
||||
'desk.ctxAsset': '{kind}「{name}」',
|
||||
'desk.ctxShot': 'ショット {no}',
|
||||
'desk.ctxBack': '{stage}に戻る',
|
||||
'desk.ctxDismiss': '出典の表示を閉じる',
|
||||
'desk.ctxFrom': '『{project}』から',
|
||||
'desk.ctxNoBackfill': '結果は生成台の記録にのみ残り、{stage}へ自動で戻りません',
|
||||
'desk.dayDate': '{m}月{d}日',
|
||||
'desk.download': 'ダウンロード',
|
||||
'desk.dropNoSlot': '{n} 件のファイルを入れる枠がありません:このワークフローにはその入力がありません',
|
||||
'desk.dryRun': 'ドライラン',
|
||||
'desk.durMin': '{m} 分 {s} 秒',
|
||||
'desk.durSec': '{s} 秒',
|
||||
'desk.elapsed': '所要時間',
|
||||
'desk.emptyFailed': '失敗した記録はありません',
|
||||
'desk.emptyFeed': 'まだ記録がありません。下にプロンプトを書いて「生成」を押してください。',
|
||||
'desk.filterAria': '記録を絞り込む',
|
||||
'desk.filterAll': 'すべて',
|
||||
'desk.filterImage': '画像',
|
||||
'desk.filterVideo': '動画',
|
||||
'desk.filterFailed': '失敗',
|
||||
'desk.generate': '生成',
|
||||
'desk.genHint': '生成(Ctrl/⌘ + Enter)',
|
||||
'desk.inputs': '入力',
|
||||
'desk.inspector': 'インスペクター',
|
||||
'desk.keyClose': '閉じる',
|
||||
'desk.keyInspector': '表示 / 非表示',
|
||||
'desk.keySelect': '選択',
|
||||
'desk.keyUse': '使用',
|
||||
'desk.kvCreated': '送信時刻',
|
||||
'desk.kvName': '名前',
|
||||
'desk.kvPromptId': 'ComfyUI タスク ID',
|
||||
'desk.kvUsage': '用途',
|
||||
'desk.loadEarlier': '過去の {n} 件を表示',
|
||||
'desk.missingSub': '同じパラメータでやり直すか、この記録を削除できます',
|
||||
'desk.missingTitle': '結果ファイルはサーバー上にありません',
|
||||
'desk.needWorkflow': '不足:ワークフロー',
|
||||
'desk.noConn': '未接続',
|
||||
'desk.noPromptInput': 'このワークフローにはプロンプト入力がなく、元の値で生成します。',
|
||||
'desk.notGenerated': '未生成',
|
||||
'desk.noWorkflows': '使える画像 / 動画ワークフローがまだありません',
|
||||
'desk.openInDesk': '生成台で調整',
|
||||
'desk.openInDeskHint': 'このプロンプトで生成台を開き、ワークフローとパラメータを選びます。結果は生成台の記録に残ります',
|
||||
'desk.openInspector': 'インスペクター',
|
||||
'desk.origValue': 'ワークフローの値',
|
||||
'desk.outputN': '結果 {n}',
|
||||
'desk.outputs': 'すべての結果',
|
||||
'desk.paramError': 'パラメータが不正です:{msg}',
|
||||
'desk.paramsAll': 'すべてのパラメータ',
|
||||
'desk.paramsChanged': 'パラメータ · {n} 項目変更',
|
||||
'desk.paramsCommon': 'よく使うパラメータ',
|
||||
'desk.pickTake': '記録を選ぶと結果を表示します',
|
||||
'desk.pickWorkflow': 'ワークフローを選択',
|
||||
'desk.promptOnly': 'プロンプトのみ',
|
||||
'desk.promptPh': '欲しい画面を説明…(Ctrl/⌘ + Enter で生成、/ でフォーカス)',
|
||||
'desk.restoreAll': 'すべてワークフローの値に戻す',
|
||||
'desk.redo': '同じパラメータでやり直す',
|
||||
'desk.removeRecord': '記録を削除',
|
||||
'desk.restoreOrig': '元に戻す',
|
||||
'desk.reuse': '再利用',
|
||||
'desk.reuseToComposer': '入力台で再利用',
|
||||
'desk.seedRandom': 'ランダム',
|
||||
'desk.sendToSlot': '現在の画像枠へ送る',
|
||||
'desk.sent': '送信済み',
|
||||
'desk.sentToSlotToast': '現在のワークフローの画像枠へ送りました',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': 'サイズ',
|
||||
'desk.stillNeed': '不足:{what}',
|
||||
'desk.tabParams': 'パラメータ',
|
||||
'desk.tabResult': '結果',
|
||||
'desk.today': '今日',
|
||||
'desk.took': '所要 {d}',
|
||||
'desk.wfBroken': '使用不可',
|
||||
'desk.wfCurrent': '現在',
|
||||
'desk.wfDefaultConn': '既定の接続に従う',
|
||||
'desk.wfImport': 'ワークフローを取り込む / 管理',
|
||||
'desk.wfLastFailed': '前回失敗',
|
||||
'desk.wfNoMatch': '一致するワークフローがありません',
|
||||
'desk.wfOk': '使用可',
|
||||
'desk.wfSearchPh': 'ワークフローや用途を検索:画像から動画、参照画像…',
|
||||
'desk.wfTesting': 'テスト実行中',
|
||||
'desk.wfUntested': '未テスト',
|
||||
'desk.workflow': 'ワークフロー',
|
||||
'desk.yesterday': '昨日',
|
||||
'wfd.stepParse': 'JSON を解析',
|
||||
'wfd.stepNodes': 'ノードを識別',
|
||||
'wfd.stepModels': 'モデルを確認',
|
||||
'wfd.stepSlots': '入力スロットを構築',
|
||||
'wfd.noOutput': '出力ノードがないためインポートできません',
|
||||
'wfd.nodesAutoOk': '用途を自動判定:{usage}',
|
||||
'wfd.nodesUserOk': '用途を確認済み:{usage}',
|
||||
'wfd.modelsOk': '「{name}」に必要なノードとモデルが揃っています',
|
||||
'wfd.slotsOk': '入力スロット {n} 件を構築(うちメディア {m} 件)',
|
||||
'wfd.checksAria': 'インポート検査結果',
|
||||
'wfd.showDetails': 'エラー詳細を展開',
|
||||
'wfd.hideDetails': 'エラー詳細を折りたたむ',
|
||||
'wfd.unreachableSuggest': '検査できなくても保存は可能です。接続を直して「再チェック」してください。',
|
||||
'wfd.nodesPickSuggest': '保存前に下で用途を選び、出力マッピングを完了してください。',
|
||||
'wfd.showRaw': '元の JSON を見る',
|
||||
'wfd.hideRaw': '元の JSON を閉じる',
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ export const ko: Dict = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': '닫기',
|
||||
'toast.undo': '실행 취소',
|
||||
'toast.uploadFailed': '업로드 실패',
|
||||
|
||||
// --- Modal ---
|
||||
@@ -220,6 +221,23 @@ export const ko: Dict = {
|
||||
'lightbox.videoPreview': '영상 미리보기',
|
||||
'lightbox.videoUnavailable': '영상을 표시할 수 없습니다',
|
||||
'lightbox.zoom100': '100%',
|
||||
'lastFrame.export': '마지막 프레임 내보내기',
|
||||
'lastFrame.exporting': '마지막 프레임 내보내는 중…',
|
||||
'lastFrame.hint': '영상의 마지막 프레임을 원본 크기 PNG로 저장합니다(첫 프레임 참조용)',
|
||||
'lastFrame.fileTag': '마지막프레임',
|
||||
'lastFrame.done': '마지막 프레임을 내보냈습니다 {name} ({size})',
|
||||
'lastFrame.errTimeout': '영상 로딩 시간이 초과되었습니다. 잠시 후 다시 시도하세요.',
|
||||
'lastFrame.errNotFound': '영상 파일이 없거나 삭제되었습니다.',
|
||||
'lastFrame.errForbidden': '이 영상에 접근할 권한이 없습니다.',
|
||||
'lastFrame.errNetwork': '영상을 읽지 못했습니다(네트워크 오류).',
|
||||
'lastFrame.errDecode': '영상이 손상되어 끝부분을 디코딩할 수 없습니다.',
|
||||
'lastFrame.errUnsupported': '이 브라우저가 영상 형식을 지원하지 않거나 파일이 손상되었습니다.',
|
||||
'lastFrame.errCrossOrigin': '다른 사이트의 영상이라 프레임을 읽을 수 없습니다(교차 출처 제한). 먼저 영상을 다운로드하세요.',
|
||||
'lastFrame.errNoPicture': '이 파일에는 영상 화면이 없습니다.',
|
||||
'lastFrame.errUnseekable': '영상 끝으로 이동할 수 없습니다(길이 불명 또는 탐색 미지원). 내보내지 않았습니다.',
|
||||
'lastFrame.errBlack': '영상의 마지막 1초가 검은 화면이라 내보내지 않았습니다.',
|
||||
'lastFrame.errEmpty': '끝부분을 디코딩하지 못했습니다. 다시 시도하거나 PC Chrome / Edge를 사용하세요.',
|
||||
'lastFrame.errUnknown': '마지막 프레임 내보내기 실패: {msg}',
|
||||
|
||||
// --- Artifact / canvas nodes ---
|
||||
'artifact.artifactLabel': '아티팩트',
|
||||
@@ -948,6 +966,8 @@ export const ko: Dict = {
|
||||
'library.selectAll': '모두 선택',
|
||||
'library.deselectAll': '선택 해제',
|
||||
'library.itemCount': '{count}개 항목',
|
||||
'library.loadError': '라이브러리를 불러오지 못했습니다',
|
||||
'library.subtitle': '생성 자산을 한곳에서 관리——프로젝트에 연결되지 않은 이미지와 동영상을 미리보기·필터·삭제',
|
||||
'library.deleteSelected': '삭제 ({count})',
|
||||
'library.addToProject': '프로젝트에 추가',
|
||||
'library.filterLabel': '종류로 필터',
|
||||
@@ -1259,15 +1279,46 @@ export const ko: Dict = {
|
||||
'shell.navAgents': '에이전트',
|
||||
'shell.navConnections': 'ComfyUI 연결',
|
||||
'shell.navModels': '모델 설정',
|
||||
'shell.navLibrary': '애셋 라이브러리',
|
||||
'shell.navSettings': '설정',
|
||||
'shell.wip': '개발 중',
|
||||
'shell.wipHint': '이 페이지는 준비 중이며 곧 열립니다',
|
||||
'shell.comfy_ok': '{name} 연결됨',
|
||||
'shell.comfy_fail': '{name} 연결 실패',
|
||||
'shell.comfy_testing': '{name} 테스트 중…',
|
||||
'shell.comfyUnset': 'ComfyUI · 미설정',
|
||||
'shell.comfyManage': '「설정」에서 ComfyUI 주소를 설정하세요',
|
||||
'shell.mobileNotice': '이 워크벤치는 데스크톱 브라우저(폭 768px 이상)에서 열어 주세요. 휴대폰은 아직 지원되지 않습니다.',
|
||||
'shell.scopeHint': '프로젝트 전환',
|
||||
'shell.scopeAll': '전체 프로젝트',
|
||||
'shell.scopeAllList': '프로젝트 목록',
|
||||
'shell.scopeRecent': '최근 프로젝트',
|
||||
'shell.scopeLoadFailed': '프로젝트를 불러오지 못했습니다',
|
||||
'shell.scopeEmpty': '아직 프로젝트가 없습니다 — 만들기',
|
||||
'shell.scopeStage': '{n}/5 확정',
|
||||
'shell.queueRunning': '{n}개 생성 중',
|
||||
'shell.queueQueued': '{n}개 대기',
|
||||
'shell.queueIdle': '대기열 비어 있음',
|
||||
'shell.queueActive': '{n}개 진행 중',
|
||||
'shell.trayTitle': '작업 트레이',
|
||||
'shell.trayRecent': '최근 작업 · 클릭하면 전체 작업',
|
||||
'shell.trayEmpty': '아직 생성 작업이 없습니다',
|
||||
'shell.trayAll': '전체 작업 보기',
|
||||
'shell.searchTitle': '페이지와 프로젝트 검색',
|
||||
'shell.searchPlaceholder': '페이지, 프로젝트 검색',
|
||||
'shell.searchShortcut': '검색 (Ctrl/⌘ + K)',
|
||||
'shell.searchEmpty': '‘{q}’와 일치하는 페이지나 프로젝트가 없습니다',
|
||||
'shell.searchGroupPages': '페이지',
|
||||
'shell.searchGroupProjects': '프로젝트 · 워크벤치 열기',
|
||||
'shell.searchHintMove': '선택',
|
||||
'shell.searchHintOpen': '열기',
|
||||
'shell.searchHintClose': '닫기',
|
||||
'shell.reduceMotion': '동작 줄이기',
|
||||
'shell.reduceHint': '이동·확대·발광 애니메이션을 끄고 짧은 페이드만 남깁니다',
|
||||
'shell.reduceSystem': '시스템의 ‘동작 줄이기’ 설정을 따르는 중',
|
||||
'shell.tabAria': '주 탐색 (좁은 화면)',
|
||||
'shell.tabProjects': '프로젝트',
|
||||
'shell.tabCreate': '창작',
|
||||
'shell.tabGenerate': '생성',
|
||||
'shell.tabMore': '더보기',
|
||||
'shell.morePages': '더 많은 페이지',
|
||||
'shell.moreTools': '도구',
|
||||
'home.title': '프로젝트 개요',
|
||||
'home.subtitle': '창작 프로젝트를 관리하세요 — 아이디어나 완성된 대본으로 시작할 수 있습니다.',
|
||||
'home.projectCount': '프로젝트 {n}개 · 진행 중 {m}개',
|
||||
@@ -1456,13 +1507,11 @@ export const ko: Dict = {
|
||||
'studio.genParams': '생성 매개변수',
|
||||
'studio.modeTextToImage': '텍스트→이미지',
|
||||
'studio.modeTextToVideo': '텍스트→영상',
|
||||
'studio.chipIdle': '{name} · 대기열 비어 있음',
|
||||
'studio.chipBusy': '{name} · 대기열 {n}건',
|
||||
'studio.video.resultsTitle': '미리보기',
|
||||
'studio.image.historyTitle': '이미지 생성 기록',
|
||||
'studio.video.historyTitle': '영상 생성 기록',
|
||||
'studio.historyCount': '총 {n}회',
|
||||
'studio.lastDone': '최근 완료 · {time}',
|
||||
'studio.doneAt': '완료 {time}',
|
||||
'studio.kindImageText': '이미지',
|
||||
'studio.kindVideoText': '비디오',
|
||||
'studio.loading': '불러오는 중…',
|
||||
@@ -1578,7 +1627,14 @@ export const ko: Dict = {
|
||||
'studio.cancelToast': '취소를 요청했습니다',
|
||||
'studio.deleteBtn': '삭제',
|
||||
'studio.delTitle': '작업 기록 삭제',
|
||||
'studio.delConfirm': '결과 파일도 함께 삭제되며 기록은 복구할 수 없습니다. 삭제할까요?',
|
||||
'studio.delConfirm': '삭제 시 이 작업의 결과 파일도 함께 제거되며 기록은 복구할 수 없습니다. 삭제할까요?',
|
||||
'studio.delTitleJob': '작업 «{name}» 삭제',
|
||||
'studio.delConfirmMulti': '이 작업에는 결과가 {n}개 있습니다. 삭제하면 작업 기록과 결과 파일 {n}개 모두 제거되며 복구할 수 없습니다. 삭제할까요?',
|
||||
'studio.deleteFailedToast': '삭제 실패: {detail}',
|
||||
'studio.deletedTakeNoFilesToast': '\'{name}\' 기록을 삭제했습니다',
|
||||
'studio.deletedTakeToast': '\'{name}\' 기록과 결과 파일을 삭제했습니다',
|
||||
'studio.deletedToast': '작업과 결과 파일을 삭제했습니다',
|
||||
'studio.wfDeletedTag': '삭제된 워크플로',
|
||||
'studio.wfEmptyTitle': '{kind} 워크플로가 아직 없습니다',
|
||||
'studio.wfEmptyBody': 'ComfyUI 개발자 모드를 켜고 API 형식 JSON으로 내보낸 뒤, ComfyUI 연결 페이지에서 {kind}(으)로 가져오세요.',
|
||||
'studio.wfEmptyGo': 'ComfyUI 연결에서 가져오기',
|
||||
@@ -1597,9 +1653,12 @@ export const ko: Dict = {
|
||||
'tasks.subtitle': '독립 이미지·독립 비디오·창작 흐름의 이미지/비디오 작업 모아 보기',
|
||||
'tasks.filterAll': '전체',
|
||||
'tasks.filterImage': '이미지',
|
||||
'tasks.filterFailed': '실패',
|
||||
'tasks.filterActive': '진행 중',
|
||||
'tasks.filterVideo': '비디오',
|
||||
'tasks.count': '{n}건 · 진행 중 {m}건',
|
||||
'tasks.empty': '생성 작업이 아직 없습니다. 이미지/비디오 생성에서 첫 작업을 제출하세요.',
|
||||
'tasks.emptyFiltered': '현재 필터에 일치하는 작업이 없습니다.',
|
||||
'tasks.goGenerate': '이미지 생성 열기',
|
||||
'tasks.sourceStudioImage': '독립 이미지',
|
||||
'tasks.sourceStudioVideo': '독립 비디오',
|
||||
@@ -1613,8 +1672,10 @@ export const ko: Dict = {
|
||||
'tasks.retry': '재시도',
|
||||
'tasks.cancel': '취소',
|
||||
'tasks.delete': '삭제',
|
||||
'tasks.download': '다운로드',
|
||||
'tasks.delTitle': '작업 삭제',
|
||||
'tasks.delConfirm': '결과 파일도 함께 삭제됩니다. 삭제할까요?',
|
||||
'tasks.searchPh': '작업 검색…',
|
||||
'tasks.outputs': '결과 {n}개',
|
||||
|
||||
'agentsPage.title': '에이전트',
|
||||
@@ -1844,6 +1905,8 @@ export const ko: Dict = {
|
||||
'jobst.dryRunDetail': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
|
||||
'jobst.noOutput': '출력 없음',
|
||||
'jobst.noOutputDetail': '결과 파일 없이 끝났습니다 — 생성 완료가 아닙니다',
|
||||
'jobst.missing': '파일 없음',
|
||||
'jobst.missingDetail': '작업은 성공했지만 결과 파일이 서버에 없습니다',
|
||||
'jobst.queued': '대기 중',
|
||||
'jobst.queuedDetail': '워크벤치 대기열에서 ComfyUI 전송을 기다리는 중',
|
||||
'jobst.connecting': '연결 중',
|
||||
@@ -1872,4 +1935,163 @@ 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': '워크플로 테스트',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': '원래 값 {v}',
|
||||
'desk.title': '생성대',
|
||||
'desk.advanced': '고급 매개변수 {n}개 · 바꾸지 않으면 워크플로 값을 사용',
|
||||
'desk.chipResolution': '해상도 {v}',
|
||||
'desk.chipSeconds': '{v}초',
|
||||
'desk.chipSeed': '시드 {v}',
|
||||
'desk.closeInspector': '인스펙터 닫기',
|
||||
'desk.countActive': '{n}개 진행 중',
|
||||
'desk.countTakes': '기록 {n}개',
|
||||
'desk.ctxAsset': '{kind} 「{name}」',
|
||||
'desk.ctxShot': '샷 {no}',
|
||||
'desk.ctxBack': '{stage}(으)로 돌아가기',
|
||||
'desk.ctxDismiss': '출처 안내 닫기',
|
||||
'desk.ctxFrom': '《{project}》에서',
|
||||
'desk.ctxNoBackfill': '결과는 생성대 기록에만 남고 {stage}에 자동으로 반영되지 않습니다',
|
||||
'desk.dayDate': '{m}월 {d}일',
|
||||
'desk.download': '다운로드',
|
||||
'desk.dropNoSlot': '파일 {n}개를 넣을 슬롯이 없습니다: 이 워크플로에는 해당 입력이 없습니다',
|
||||
'desk.dryRun': '드라이런',
|
||||
'desk.durMin': '{m}분 {s}초',
|
||||
'desk.durSec': '{s}초',
|
||||
'desk.elapsed': '소요 시간',
|
||||
'desk.emptyFailed': '실패한 기록이 없습니다',
|
||||
'desk.emptyFeed': '아직 기록이 없습니다. 아래에 프롬프트를 쓰고 「생성」을 누르세요.',
|
||||
'desk.filterAria': '기록 필터',
|
||||
'desk.filterAll': '전체',
|
||||
'desk.filterImage': '이미지',
|
||||
'desk.filterVideo': '동영상',
|
||||
'desk.filterFailed': '실패',
|
||||
'desk.generate': '생성',
|
||||
'desk.genHint': '생성 (Ctrl/⌘ + Enter)',
|
||||
'desk.inputs': '입력',
|
||||
'desk.inspector': '인스펙터',
|
||||
'desk.keyClose': '닫기',
|
||||
'desk.keyInspector': '표시 / 숨기기',
|
||||
'desk.keySelect': '선택',
|
||||
'desk.keyUse': '사용',
|
||||
'desk.kvCreated': '제출 시각',
|
||||
'desk.kvName': '이름',
|
||||
'desk.kvPromptId': 'ComfyUI 작업 ID',
|
||||
'desk.kvUsage': '용도',
|
||||
'desk.loadEarlier': '이전 {n}개 표시',
|
||||
'desk.missingSub': '같은 설정으로 다시 만들거나 이 기록을 제거할 수 있습니다',
|
||||
'desk.missingTitle': '결과 파일이 서버에 없습니다',
|
||||
'desk.needWorkflow': '부족: 워크플로',
|
||||
'desk.noConn': '연결 안 됨',
|
||||
'desk.noPromptInput': '이 워크플로에는 프롬프트 입력이 없어 원래 값으로 생성합니다.',
|
||||
'desk.notGenerated': '생성 안 됨',
|
||||
'desk.noWorkflows': '사용할 수 있는 이미지 / 동영상 워크플로가 아직 없습니다',
|
||||
'desk.openInDesk': '생성대에서 조정',
|
||||
'desk.openInDeskHint': '이 프롬프트로 생성대를 열고 워크플로와 매개변수를 직접 고릅니다. 결과는 생성대 기록에 남습니다',
|
||||
'desk.openInspector': '인스펙터',
|
||||
'desk.origValue': '워크플로 값',
|
||||
'desk.outputN': '결과 {n}',
|
||||
'desk.outputs': '모든 결과',
|
||||
'desk.paramError': '잘못된 매개변수: {msg}',
|
||||
'desk.paramsAll': '모든 매개변수',
|
||||
'desk.paramsChanged': '매개변수 · {n}개 변경',
|
||||
'desk.paramsCommon': '자주 쓰는 매개변수',
|
||||
'desk.pickTake': '기록을 선택하면 결과가 표시됩니다',
|
||||
'desk.pickWorkflow': '워크플로 선택',
|
||||
'desk.promptOnly': '프롬프트만',
|
||||
'desk.promptPh': '원하는 장면을 설명… (Ctrl/⌘ + Enter 생성, / 포커스)',
|
||||
'desk.restoreAll': '모두 워크플로 값으로 되돌리기',
|
||||
'desk.redo': '같은 설정으로 다시 생성',
|
||||
'desk.removeRecord': '기록 제거',
|
||||
'desk.restoreOrig': '원래 값으로',
|
||||
'desk.reuse': '재사용',
|
||||
'desk.reuseToComposer': '입력대에서 재사용',
|
||||
'desk.seedRandom': '무작위',
|
||||
'desk.sendToSlot': '현재 이미지 슬롯으로 보내기',
|
||||
'desk.sent': '제출됨',
|
||||
'desk.sentToSlotToast': '현재 워크플로의 이미지 슬롯으로 보냈습니다',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': '크기',
|
||||
'desk.stillNeed': '부족: {what}',
|
||||
'desk.tabParams': '매개변수',
|
||||
'desk.tabResult': '결과',
|
||||
'desk.today': '오늘',
|
||||
'desk.took': '소요 {d}',
|
||||
'desk.wfBroken': '사용 불가',
|
||||
'desk.wfCurrent': '현재',
|
||||
'desk.wfDefaultConn': '기본 연결 따름',
|
||||
'desk.wfImport': '워크플로 가져오기 / 관리',
|
||||
'desk.wfLastFailed': '지난번 실패',
|
||||
'desk.wfNoMatch': '일치하는 워크플로가 없습니다',
|
||||
'desk.wfOk': '사용 가능',
|
||||
'desk.wfSearchPh': '워크플로나 용도 검색: 이미지→동영상, 참조 이미지…',
|
||||
'desk.wfTesting': '테스트 실행 중',
|
||||
'desk.wfUntested': '테스트 안 함',
|
||||
'desk.workflow': '워크플로',
|
||||
'desk.yesterday': '어제',
|
||||
'wfd.stepParse': 'JSON 파싱',
|
||||
'wfd.stepNodes': '노드 식별',
|
||||
'wfd.stepModels': '모델 확인',
|
||||
'wfd.stepSlots': '입력 슬롯 구성',
|
||||
'wfd.noOutput': '출력 노드가 없어 가져올 수 없습니다',
|
||||
'wfd.nodesAutoOk': '용도 자동 판정: {usage}',
|
||||
'wfd.nodesUserOk': '용도 확인됨: {usage}',
|
||||
'wfd.modelsOk': "'{name}'에 필요한 노드와 모델이 모두 있습니다",
|
||||
'wfd.slotsOk': '입력 슬롯 {n}개 구성(미디어 {m}개)',
|
||||
'wfd.checksAria': '가져오기 검사 결과',
|
||||
'wfd.showDetails': '오류 자세히 보기',
|
||||
'wfd.hideDetails': '오류 자세히 접기',
|
||||
'wfd.unreachableSuggest': '확인 실패는 저장을 막지 않습니다. 연결을 고친 뒤 다시 검사하세요.',
|
||||
'wfd.nodesPickSuggest': '저장하기 전에 아래에서 용도를 선택하고 출력 매핑을 완료하세요.',
|
||||
'wfd.showRaw': '원본 JSON 보기',
|
||||
'wfd.hideRaw': '원본 JSON 접기',
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ export const zh: Dict = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': '关闭',
|
||||
'toast.undo': '撤销',
|
||||
'toast.uploadFailed': '上传失败',
|
||||
|
||||
// --- Modal ---
|
||||
@@ -220,6 +221,23 @@ export const zh: Dict = {
|
||||
'lightbox.videoPreview': '视频预览',
|
||||
'lightbox.videoUnavailable': '视频不可用',
|
||||
'lightbox.zoom100': '100%',
|
||||
'lastFrame.export': '导出尾帧',
|
||||
'lastFrame.exporting': '正在导出尾帧…',
|
||||
'lastFrame.hint': '把视频最后一帧按原尺寸保存为 PNG,可作首帧参考',
|
||||
'lastFrame.fileTag': '尾帧',
|
||||
'lastFrame.done': '已导出尾帧 {name}({size})',
|
||||
'lastFrame.errTimeout': '视频加载超时,未能导出尾帧,请稍后重试',
|
||||
'lastFrame.errNotFound': '视频文件不存在或已被移除,无法导出尾帧',
|
||||
'lastFrame.errForbidden': '没有访问该视频的权限,无法导出尾帧',
|
||||
'lastFrame.errNetwork': '视频读取失败(网络中断),请检查连接后重试',
|
||||
'lastFrame.errDecode': '视频文件已损坏,片尾画面无法解码',
|
||||
'lastFrame.errUnsupported': '当前浏览器不支持该视频格式,或文件已损坏,无法导出尾帧',
|
||||
'lastFrame.errCrossOrigin': '该视频来自其他站点,浏览器禁止读取画面(跨域限制),请先下载原视频',
|
||||
'lastFrame.errNoPicture': '该文件没有视频画面,无法导出尾帧',
|
||||
'lastFrame.errUnseekable': '无法定位到视频结尾(时长未知或来源不支持拖动进度),未导出尾帧',
|
||||
'lastFrame.errBlack': '视频结尾 1 秒内都是黑屏,未导出尾帧',
|
||||
'lastFrame.errEmpty': '浏览器未能解码出片尾画面,请重试或换用电脑端 Chrome / Edge',
|
||||
'lastFrame.errUnknown': '导出尾帧失败:{msg}',
|
||||
|
||||
// --- Artifact / canvas nodes ---
|
||||
'artifact.artifactLabel': '产物',
|
||||
@@ -948,6 +966,8 @@ export const zh: Dict = {
|
||||
'library.selectAll': '全选',
|
||||
'library.deselectAll': '取消全选',
|
||||
'library.itemCount': '{count} 个项目',
|
||||
'library.loadError': '素材库加载失败',
|
||||
'library.subtitle': '生成素材集中在这里——未关联项目的图片和视频可预览、筛选和删除',
|
||||
'library.deleteSelected': '删除({count})',
|
||||
'library.addToProject': '添加到项目',
|
||||
'library.filterLabel': '按类型筛选',
|
||||
@@ -1261,15 +1281,46 @@ export const zh: Dict = {
|
||||
'shell.navAgents': '智能体',
|
||||
'shell.navConnections': 'ComfyUI 连接',
|
||||
'shell.navModels': '模型设置',
|
||||
'shell.navLibrary': '素材库',
|
||||
'shell.navSettings': '设置',
|
||||
'shell.wip': '开发中',
|
||||
'shell.wipHint': '该页面正在开发中,稍后开放',
|
||||
'shell.comfy_ok': '{name} 已连接',
|
||||
'shell.comfy_fail': '{name} 连接失败',
|
||||
'shell.comfy_testing': '{name} 测试中…',
|
||||
'shell.comfyUnset': 'ComfyUI · 未配置',
|
||||
'shell.comfyManage': '管理 ComfyUI 连接',
|
||||
'shell.mobileNotice': '请用桌面浏览器(宽 768px 以上)打开影工坊,手机端暂不适配。',
|
||||
'shell.scopeHint': '切换项目',
|
||||
'shell.scopeAll': '全部项目',
|
||||
'shell.scopeAllList': '全部项目列表',
|
||||
'shell.scopeRecent': '最近项目',
|
||||
'shell.scopeLoadFailed': '项目列表加载失败',
|
||||
'shell.scopeEmpty': '还没有项目,去创建',
|
||||
'shell.scopeStage': '已确认 {n}/5',
|
||||
'shell.queueRunning': '{n} 个生成中',
|
||||
'shell.queueQueued': '{n} 个排队',
|
||||
'shell.queueIdle': '队列空闲',
|
||||
'shell.queueActive': '{n} 个进行中',
|
||||
'shell.trayTitle': '任务托盘',
|
||||
'shell.trayRecent': '最近任务 · 点击查看全部任务',
|
||||
'shell.trayEmpty': '还没有生成任务',
|
||||
'shell.trayAll': '查看全部任务',
|
||||
'shell.searchTitle': '搜索页面和项目',
|
||||
'shell.searchPlaceholder': '搜索页面、项目',
|
||||
'shell.searchShortcut': '搜索(Ctrl/⌘ + K)',
|
||||
'shell.searchEmpty': '没有找到与「{q}」相关的页面或项目',
|
||||
'shell.searchGroupPages': '页面',
|
||||
'shell.searchGroupProjects': '项目 · 打开创作工作台',
|
||||
'shell.searchHintMove': '选择',
|
||||
'shell.searchHintOpen': '打开',
|
||||
'shell.searchHintClose': '关闭',
|
||||
'shell.reduceMotion': '减少动态',
|
||||
'shell.reduceHint': '关闭位移、缩放和光晕动画,只保留短暂淡入淡出',
|
||||
'shell.reduceSystem': '已跟随系统的「减少动态效果」设置',
|
||||
'shell.tabAria': '主导航(窄屏)',
|
||||
'shell.tabProjects': '项目',
|
||||
'shell.tabCreate': '创作',
|
||||
'shell.tabGenerate': '生成',
|
||||
'shell.tabMore': '更多',
|
||||
'shell.morePages': '更多页面',
|
||||
'shell.moreTools': '工具',
|
||||
'home.title': '项目总览',
|
||||
'home.subtitle': '管理创作项目,从一个创意或一份现成剧本开始。',
|
||||
'home.projectCount': '{n} 个项目 · {m} 个进行中',
|
||||
@@ -1455,13 +1506,11 @@ export const zh: Dict = {
|
||||
'studio.genParams': '生成参数',
|
||||
'studio.modeTextToImage': '文生图',
|
||||
'studio.modeTextToVideo': '文生视频',
|
||||
'studio.chipIdle': '{name} · 队列空闲',
|
||||
'studio.chipBusy': '{name} · 队列 {n} 个任务',
|
||||
'studio.video.resultsTitle': '成片预览',
|
||||
'studio.image.historyTitle': '生图历史',
|
||||
'studio.video.historyTitle': '生视频历史',
|
||||
'studio.historyCount': '共 {n} 次',
|
||||
'studio.lastDone': '最近完成 · {time}',
|
||||
'studio.doneAt': '完成于 {time}',
|
||||
'studio.kindImageText': '图片',
|
||||
'studio.kindVideoText': '视频',
|
||||
'studio.loading': '正在加载…',
|
||||
@@ -1579,6 +1628,13 @@ export const zh: Dict = {
|
||||
'studio.deleteBtn': '删除',
|
||||
'studio.delTitle': '删除任务记录',
|
||||
'studio.delConfirm': '删除后该任务的结果文件会一并移除,历史不可恢复。确定删除?',
|
||||
'studio.delTitleJob': '删除任务「{name}」',
|
||||
'studio.delConfirmMulti': '该任务共生成 {n} 个结果,删除将移除整条任务记录及其全部 {n} 个结果文件,历史不可恢复。确定删除?',
|
||||
'studio.deleteFailedToast': '删除失败:{detail}',
|
||||
'studio.deletedTakeNoFilesToast': '已删除「{name}」这条记录',
|
||||
'studio.deletedTakeToast': '已删除「{name}」这条记录及其结果文件',
|
||||
'studio.deletedToast': '已删除该任务及其结果文件',
|
||||
'studio.wfDeletedTag': '工作流已删除',
|
||||
'studio.wfEmptyTitle': '还没有{kind}工作流',
|
||||
'studio.wfEmptyBody': '在 ComfyUI 里启用开发者模式,用「导出(API 格式)」得到 JSON,再到「ComfyUI 连接」页导入并分类为{kind}。',
|
||||
'studio.wfEmptyGo': '去「ComfyUI 连接」导入',
|
||||
@@ -1597,9 +1653,12 @@ export const zh: Dict = {
|
||||
'tasks.subtitle': '汇总独立生图、独立生视频与创作流程内的图片 / 视频任务',
|
||||
'tasks.filterAll': '全部',
|
||||
'tasks.filterImage': '图片',
|
||||
'tasks.filterFailed': '失败',
|
||||
'tasks.filterActive': '进行中',
|
||||
'tasks.filterVideo': '视频',
|
||||
'tasks.count': '{n} 个任务 · {m} 个进行中',
|
||||
'tasks.empty': '还没有生成任务。到「生图」「生视频」提交第一个任务。',
|
||||
'tasks.emptyFiltered': '没有符合筛选的任务。',
|
||||
'tasks.goGenerate': '去生图',
|
||||
'tasks.sourceStudioImage': '独立生图',
|
||||
'tasks.sourceStudioVideo': '独立生视频',
|
||||
@@ -1613,8 +1672,10 @@ export const zh: Dict = {
|
||||
'tasks.retry': '重试',
|
||||
'tasks.cancel': '取消',
|
||||
'tasks.delete': '删除',
|
||||
'tasks.download': '下载',
|
||||
'tasks.delTitle': '删除任务',
|
||||
'tasks.delConfirm': '删除后该任务的结果文件会一并移除。确定删除?',
|
||||
'tasks.searchPh': '搜索任务…',
|
||||
'tasks.outputs': '{n} 个结果',
|
||||
|
||||
'agentsPage.title': '智能体',
|
||||
@@ -1844,6 +1905,8 @@ export const zh: Dict = {
|
||||
'jobst.dryRunDetail': '占位结果:当前开启了 Dry-run,没有调用 ComfyUI,不是真实生成',
|
||||
'jobst.noOutput': '无产出',
|
||||
'jobst.noOutputDetail': '任务结束但没有任何结果文件,不算生成完成',
|
||||
'jobst.missing': '文件缺失',
|
||||
'jobst.missingDetail': '生成成功,但结果文件已不在服务器上',
|
||||
'jobst.queued': '排队中',
|
||||
'jobst.queuedDetail': '在工作台队列中等待提交到 ComfyUI',
|
||||
'jobst.connecting': '连接中',
|
||||
@@ -1872,4 +1935,163 @@ 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': '工作流测试',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': '原值 {v}',
|
||||
'desk.title': '生成台',
|
||||
'desk.advanced': '高级参数 {n} 项 · 不改就沿用工作流原值',
|
||||
'desk.chipResolution': '分辨率 {v}',
|
||||
'desk.chipSeconds': '{v} 秒',
|
||||
'desk.chipSeed': '种子 {v}',
|
||||
'desk.closeInspector': '关闭检视区',
|
||||
'desk.countActive': '{n} 个进行中',
|
||||
'desk.countTakes': '{n} 条记录',
|
||||
'desk.ctxAsset': '{kind}「{name}」',
|
||||
'desk.ctxShot': '镜头 {no}',
|
||||
'desk.ctxBack': '返回{stage}',
|
||||
'desk.ctxDismiss': '关闭来源提示',
|
||||
'desk.ctxFrom': '来自《{project}》',
|
||||
'desk.ctxNoBackfill': '结果只留在生成台记录,不会自动回填到{stage}',
|
||||
'desk.dayDate': '{m}月{d}日',
|
||||
'desk.download': '下载',
|
||||
'desk.dropNoSlot': '{n} 个文件没有可放的槽位:当前工作流没有接这类输入',
|
||||
'desk.dryRun': '演练模式',
|
||||
'desk.durMin': '{m} 分 {s} 秒',
|
||||
'desk.durSec': '{s} 秒',
|
||||
'desk.elapsed': '用时',
|
||||
'desk.emptyFailed': '没有失败的记录',
|
||||
'desk.emptyFeed': '还没有记录。在下方写提示词,点「生成」。',
|
||||
'desk.filterAria': '筛选记录',
|
||||
'desk.filterAll': '全部',
|
||||
'desk.filterImage': '图片',
|
||||
'desk.filterVideo': '视频',
|
||||
'desk.filterFailed': '失败',
|
||||
'desk.generate': '生成',
|
||||
'desk.genHint': '生成(Ctrl/⌘ + Enter)',
|
||||
'desk.inputs': '输入',
|
||||
'desk.inspector': '检视区',
|
||||
'desk.keyClose': '关闭',
|
||||
'desk.keyInspector': '显示 / 收起',
|
||||
'desk.keySelect': '选择',
|
||||
'desk.keyUse': '使用',
|
||||
'desk.kvCreated': '提交时间',
|
||||
'desk.kvName': '名称',
|
||||
'desk.kvPromptId': 'ComfyUI 任务号',
|
||||
'desk.kvUsage': '用途',
|
||||
'desk.loadEarlier': '显示更早的 {n} 条',
|
||||
'desk.missingSub': '可用相同参数重做,或移除这条记录',
|
||||
'desk.missingTitle': '结果文件已不在服务器上',
|
||||
'desk.needWorkflow': '还缺:工作流',
|
||||
'desk.noConn': '未连接',
|
||||
'desk.noPromptInput': '这个工作流没有提示词输入,按工作流原值生成。',
|
||||
'desk.notGenerated': '未生成',
|
||||
'desk.noWorkflows': '还没有可用的生图 / 生视频工作流',
|
||||
'desk.openInDesk': '在生成台调参',
|
||||
'desk.openInDeskHint': '带上提示词打开生成台,自选工作流和参数;结果留在生成台记录',
|
||||
'desk.openInspector': '检视区',
|
||||
'desk.origValue': '工作流原值',
|
||||
'desk.outputN': '第 {n} 个结果',
|
||||
'desk.outputs': '全部结果',
|
||||
'desk.paramError': '参数有误:{msg}',
|
||||
'desk.paramsAll': '全部参数',
|
||||
'desk.paramsChanged': '参数 · 已改 {n} 项',
|
||||
'desk.paramsCommon': '常用参数',
|
||||
'desk.pickTake': '在记录流里选一条查看结果',
|
||||
'desk.pickWorkflow': '选择工作流',
|
||||
'desk.promptOnly': '仅提示词',
|
||||
'desk.promptPh': '描述想要的画面…(Ctrl/⌘ + Enter 生成,/ 快速聚焦)',
|
||||
'desk.restoreAll': '全部恢复工作流原值',
|
||||
'desk.redo': '用相同参数重做',
|
||||
'desk.removeRecord': '移除记录',
|
||||
'desk.restoreOrig': '恢复原值',
|
||||
'desk.reuse': '复用',
|
||||
'desk.reuseToComposer': '复用到输入台',
|
||||
'desk.seedRandom': '随机',
|
||||
'desk.sendToSlot': '送入当前图片槽',
|
||||
'desk.sent': '已提交',
|
||||
'desk.sentToSlotToast': '已送入当前工作流的图片槽',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': '尺寸',
|
||||
'desk.stillNeed': '还缺:{what}',
|
||||
'desk.tabParams': '参数',
|
||||
'desk.tabResult': '结果',
|
||||
'desk.today': '今天',
|
||||
'desk.took': '用时 {d}',
|
||||
'desk.wfBroken': '不可用',
|
||||
'desk.wfCurrent': '当前',
|
||||
'desk.wfDefaultConn': '跟随默认连接',
|
||||
'desk.wfImport': '导入 / 管理工作流',
|
||||
'desk.wfLastFailed': '上次失败',
|
||||
'desk.wfNoMatch': '没有匹配的工作流',
|
||||
'desk.wfOk': '可用',
|
||||
'desk.wfSearchPh': '搜索工作流,或输入用途:图生视频、参考图…',
|
||||
'desk.wfTesting': '试运行中',
|
||||
'desk.wfUntested': '未试运行',
|
||||
'desk.workflow': '工作流',
|
||||
'desk.yesterday': '昨天',
|
||||
'wfd.stepParse': '解析 JSON',
|
||||
'wfd.stepNodes': '识别节点',
|
||||
'wfd.stepModels': '核对模型',
|
||||
'wfd.stepSlots': '建立输入槽',
|
||||
'wfd.noOutput': '工作流没有任何输出节点,无法导入',
|
||||
'wfd.nodesAutoOk': '自动判定用途:{usage}',
|
||||
'wfd.nodesUserOk': '用途已确认:{usage}',
|
||||
'wfd.modelsOk': '「{name}」已装齐所需节点与模型',
|
||||
'wfd.slotsOk': '已建立 {n} 个输入槽(含 {m} 个媒体槽)',
|
||||
'wfd.checksAria': '导入检查结果',
|
||||
'wfd.showDetails': '展开错误详情',
|
||||
'wfd.hideDetails': '收起错误详情',
|
||||
'wfd.unreachableSuggest': '无法核查不会阻止保存;修好连接后点「重新检查」复查。',
|
||||
'wfd.nodesPickSuggest': '请在下方选择用途并完成输出映射后再保存。',
|
||||
'wfd.showRaw': '查看原始 JSON',
|
||||
'wfd.hideRaw': '收起原始 JSON',
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 导出尾帧 (HEL-669): decode a frame just before the end of a video in the
|
||||
* browser and save it as a PNG at the video's native resolution.
|
||||
*
|
||||
* Frames are taken from a hidden, muted <video> of the same URL so the
|
||||
* player the user is watching never jumps. Media is served same-origin by
|
||||
* /api/file, so the canvas stays untainted.
|
||||
*
|
||||
* The pure helpers at the top touch no DOM and import nothing, so
|
||||
* scripts/check-export-frame.mjs can run them under plain Node.
|
||||
*/
|
||||
|
||||
export type FrameErrorCode =
|
||||
| "timeout"
|
||||
| "not-found"
|
||||
| "forbidden"
|
||||
| "network"
|
||||
| "decode"
|
||||
| "unsupported"
|
||||
| "cross-origin"
|
||||
| "no-picture"
|
||||
| "unseekable"
|
||||
| "black"
|
||||
| "empty";
|
||||
|
||||
export class FrameExportError extends Error {
|
||||
code: FrameErrorCode;
|
||||
|
||||
constructor(code: FrameErrorCode, detail?: string) {
|
||||
super(detail ? `${code}: ${detail}` : code);
|
||||
this.name = "FrameExportError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds before the end to try, nearest first. The exact end is often not
|
||||
* decodable, so the first try is ~1 frame back; later tries step past a
|
||||
* black fade-out tail.
|
||||
*/
|
||||
export const TAIL_OFFSETS = [0.05, 0.12, 0.25, 0.5, 1];
|
||||
|
||||
export function tailSeekTimes(duration: number): number[] {
|
||||
if (!Number.isFinite(duration) || duration <= 0) return [0];
|
||||
const times: number[] = [];
|
||||
for (const off of TAIL_OFFSETS) {
|
||||
const t = Math.max(0, Math.round((duration - off) * 1000) / 1000);
|
||||
if (!times.includes(t)) times.push(t);
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
/** File name of the video behind a media URL (`/api/file?path=…` or plain). */
|
||||
export function videoBaseName(src: string): string {
|
||||
let name = "";
|
||||
try {
|
||||
const url = new URL(src, "http://local.invalid/");
|
||||
const path =
|
||||
url.searchParams.get("path") ?? decodeURIComponent(url.pathname);
|
||||
name = path.split(/[\\/]/).pop() ?? "";
|
||||
} catch {
|
||||
name = "";
|
||||
}
|
||||
name = name.replace(/\.[a-z0-9]{1,5}$/i, "");
|
||||
name = name.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, "_").trim();
|
||||
return name || "video";
|
||||
}
|
||||
|
||||
/** `{视频名}_{tag}_{秒}s.png` — names the source video and the frame time. */
|
||||
export function frameFileName(
|
||||
src: string,
|
||||
seconds: number,
|
||||
tag: string,
|
||||
): string {
|
||||
const safeTag = tag.replace(/[\\/:*?"<>|\s]+/g, "-");
|
||||
return `${videoBaseName(src)}_${safeTag}_${seconds.toFixed(3)}s.png`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify sampled RGBA pixels: "empty" when nothing was painted (the
|
||||
* decoder handed back no picture), "black" for an all-dark frame, else null.
|
||||
* A bright highlight anywhere keeps a dark scene from counting as black.
|
||||
*/
|
||||
export function blankKind(rgba: ArrayLike<number>): "empty" | "black" | null {
|
||||
let opaque = 0;
|
||||
let sum = 0;
|
||||
let max = 0;
|
||||
for (let i = 0; i + 3 < rgba.length; i += 4) {
|
||||
if (rgba[i + 3] < 8) continue;
|
||||
opaque++;
|
||||
const luma = 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
|
||||
sum += luma;
|
||||
if (luma > max) max = luma;
|
||||
}
|
||||
if (opaque === 0) return "empty";
|
||||
// 16 = limited-range video black when a decoder skips range expansion.
|
||||
if (max < 40 && sum / opaque < 20) return "black";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** HTTP status of a failed media fetch → error code (null = not decisive). */
|
||||
export function statusErrorCode(status: number): FrameErrorCode | null {
|
||||
if (status === 404 || status === 410) return "not-found";
|
||||
if (status === 401 || status === 403) return "forbidden";
|
||||
if (status >= 400) return "network";
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- browser side -------------------------------------------------------
|
||||
|
||||
export interface LastFrame {
|
||||
blob: Blob;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Media time of the exported frame, in seconds. */
|
||||
time: number;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface CaptureOptions {
|
||||
/** Word placed in the file name, e.g. 尾帧. */
|
||||
fileTag: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const SAMPLE = 64;
|
||||
|
||||
function isCrossOrigin(src: string): boolean {
|
||||
try {
|
||||
const url = new URL(src, location.href);
|
||||
if (url.protocol === "blob:" || url.protocol === "data:") return false;
|
||||
return url.origin !== location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe the URL so a failed load can say 不存在 / 无权限 / 跨域 precisely. */
|
||||
async function classifyLoadFailure(
|
||||
video: HTMLVideoElement,
|
||||
src: string,
|
||||
crossOrigin: boolean,
|
||||
): Promise<FrameExportError> {
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), 5000);
|
||||
try {
|
||||
const res = await fetch(src, {
|
||||
headers: { Range: "bytes=0-0" },
|
||||
mode: crossOrigin ? "cors" : "same-origin",
|
||||
signal: ctl.signal,
|
||||
});
|
||||
ctl.abort();
|
||||
const code = statusErrorCode(res.status);
|
||||
if (code) return new FrameExportError(code, `HTTP ${res.status}`);
|
||||
} catch {
|
||||
if (crossOrigin) return new FrameExportError("cross-origin");
|
||||
return new FrameExportError("network");
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const mediaCode = video.error?.code;
|
||||
if (mediaCode === MediaError.MEDIA_ERR_NETWORK) {
|
||||
return new FrameExportError("network", video.error?.message);
|
||||
}
|
||||
if (mediaCode === MediaError.MEDIA_ERR_DECODE) {
|
||||
return new FrameExportError("decode", video.error?.message);
|
||||
}
|
||||
return new FrameExportError("unsupported", video.error?.message);
|
||||
}
|
||||
|
||||
export async function captureLastFrame(
|
||||
src: string,
|
||||
{ fileTag, timeoutMs = 30000 }: CaptureOptions,
|
||||
): Promise<LastFrame> {
|
||||
const crossOrigin = isCrossOrigin(src);
|
||||
const video = document.createElement("video");
|
||||
if (crossOrigin) video.crossOrigin = "anonymous";
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = "auto";
|
||||
video.setAttribute("aria-hidden", "true");
|
||||
// Kept in the DOM (some engines never paint a detached video) but unseen.
|
||||
Object.assign(video.style, {
|
||||
position: "fixed",
|
||||
left: "0",
|
||||
top: "0",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
opacity: "0",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
document.body.appendChild(video);
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
/** Resolve on `event`; reject on a media error or the shared deadline. */
|
||||
const until = (event: string) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const done = (fn: () => void) => {
|
||||
clearTimeout(timer);
|
||||
video.removeEventListener(event, onEvent);
|
||||
video.removeEventListener("error", onError);
|
||||
fn();
|
||||
};
|
||||
const onEvent = () => done(resolve);
|
||||
const onError = () =>
|
||||
done(() => {
|
||||
classifyLoadFailure(video, src, crossOrigin).then(reject, reject);
|
||||
});
|
||||
const timer = setTimeout(
|
||||
() => done(() => reject(new FrameExportError("timeout"))),
|
||||
Math.max(0, deadline - Date.now()),
|
||||
);
|
||||
video.addEventListener(event, onEvent);
|
||||
video.addEventListener("error", onError);
|
||||
});
|
||||
|
||||
const seek = async (time: number) => {
|
||||
const settled = until("seeked");
|
||||
video.currentTime = time;
|
||||
await settled;
|
||||
};
|
||||
|
||||
/** Wait until the seeked frame has actually been decoded and presented. */
|
||||
const presented = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
const fallback = setTimeout(resolve, 250);
|
||||
const finish = () => {
|
||||
clearTimeout(fallback);
|
||||
resolve();
|
||||
};
|
||||
if (typeof video.requestVideoFrameCallback === "function") {
|
||||
video.requestVideoFrameCallback(() => finish());
|
||||
} else {
|
||||
requestAnimationFrame(() => requestAnimationFrame(finish));
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const metadata = until("loadedmetadata");
|
||||
video.src = src;
|
||||
video.load();
|
||||
await metadata;
|
||||
|
||||
if (!video.videoWidth || !video.videoHeight) {
|
||||
throw new FrameExportError("no-picture");
|
||||
}
|
||||
|
||||
// MediaRecorder WebM reports Infinity until the end has been read. When
|
||||
// the server cannot seek either, the end is unreachable — fail rather
|
||||
// than hand back the first frame.
|
||||
let duration = video.duration;
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
await seek(1e101);
|
||||
duration = video.duration;
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
throw new FrameExportError("unseekable", "duration unknown");
|
||||
}
|
||||
}
|
||||
|
||||
const width = video.videoWidth;
|
||||
const height = video.videoHeight;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const sample = document.createElement("canvas");
|
||||
sample.width = SAMPLE;
|
||||
sample.height = SAMPLE;
|
||||
const sampleCtx = sample.getContext("2d", { willReadFrequently: true });
|
||||
if (!ctx || !sampleCtx)
|
||||
throw new FrameExportError("empty", "no 2d context");
|
||||
|
||||
let lastBlank: "empty" | "black" = "empty";
|
||||
for (const time of tailSeekTimes(duration)) {
|
||||
await seek(time);
|
||||
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||
// iOS Safari only decodes after a play(); muted inline play is allowed.
|
||||
await video.play().catch(() => {});
|
||||
video.pause();
|
||||
await seek(time);
|
||||
}
|
||||
if (Math.abs(video.currentTime - time) > 0.5) {
|
||||
throw new FrameExportError(
|
||||
"unseekable",
|
||||
`asked ${time}s, landed at ${video.currentTime}s`,
|
||||
);
|
||||
}
|
||||
await presented();
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
sampleCtx.clearRect(0, 0, SAMPLE, SAMPLE);
|
||||
sampleCtx.drawImage(canvas, 0, 0, SAMPLE, SAMPLE);
|
||||
let pixels: Uint8ClampedArray;
|
||||
try {
|
||||
pixels = sampleCtx.getImageData(0, 0, SAMPLE, SAMPLE).data;
|
||||
} catch (err) {
|
||||
throw new FrameExportError("cross-origin", String(err));
|
||||
}
|
||||
const blank = blankKind(pixels);
|
||||
if (blank) {
|
||||
lastBlank = blank;
|
||||
continue;
|
||||
}
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
try {
|
||||
canvas.toBlob(resolve, "image/png");
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
if (!blob) throw new FrameExportError("empty", "toBlob returned nothing");
|
||||
const shown = video.currentTime;
|
||||
return {
|
||||
blob,
|
||||
width,
|
||||
height,
|
||||
time: shown,
|
||||
fileName: frameFileName(src, shown, fileTag),
|
||||
};
|
||||
}
|
||||
throw new FrameExportError(lastBlank);
|
||||
} finally {
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
video.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.rel = "noopener";
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
import { toast } from "$lib/toast";
|
||||
import {
|
||||
captureLastFrame,
|
||||
downloadBlob,
|
||||
FrameExportError,
|
||||
type FrameErrorCode,
|
||||
} from "./exportFrame";
|
||||
|
||||
const ERROR_KEYS: Record<FrameErrorCode, string> = {
|
||||
timeout: "lastFrame.errTimeout",
|
||||
"not-found": "lastFrame.errNotFound",
|
||||
forbidden: "lastFrame.errForbidden",
|
||||
network: "lastFrame.errNetwork",
|
||||
decode: "lastFrame.errDecode",
|
||||
unsupported: "lastFrame.errUnsupported",
|
||||
"cross-origin": "lastFrame.errCrossOrigin",
|
||||
"no-picture": "lastFrame.errNoPicture",
|
||||
unseekable: "lastFrame.errUnseekable",
|
||||
black: "lastFrame.errBlack",
|
||||
empty: "lastFrame.errEmpty",
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出尾帧 for one video URL: capture, download the PNG and report the
|
||||
* outcome as a toast. Never throws. Resolves true when a file was saved.
|
||||
*/
|
||||
export async function exportLastFrame(src: string): Promise<boolean> {
|
||||
try {
|
||||
const frame = await captureLastFrame(src, {
|
||||
fileTag: t("lastFrame.fileTag"),
|
||||
});
|
||||
downloadBlob(frame.blob, frame.fileName);
|
||||
toast.success(
|
||||
t("lastFrame.done", {
|
||||
name: frame.fileName,
|
||||
size: `${frame.width}×${frame.height}`,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof FrameExportError) {
|
||||
toast.error(t(ERROR_KEYS[err.code]), 6000);
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(t("lastFrame.errUnknown", { msg }), 6000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { Job } from '$lib/comfy/types';
|
||||
|
||||
/**
|
||||
* Output files a done job claims but the asset endpoint could not serve
|
||||
* (spec-v2 §3.4 红/琥珀界线): the backend task status stays the single
|
||||
* verdict for red — a FAILED job is red no matter what. Amber means the
|
||||
* job SUCCEEDED and only its product file is gone (cleaned up, moved,
|
||||
* storage hiccup). We learn that the only way the frontend can: the
|
||||
* media element itself failed to load. A later successful load clears
|
||||
* the mark.
|
||||
*/
|
||||
const missing = new SvelteSet<string>();
|
||||
|
||||
/** Mark one output path as unreachable (img/video onerror). */
|
||||
export function markOutputMissing(path: string): void {
|
||||
if (path) missing.add(path);
|
||||
}
|
||||
|
||||
/** Clear the mark when the same path loads fine after all. */
|
||||
export function clearOutputMissing(path: string): void {
|
||||
missing.delete(path);
|
||||
}
|
||||
|
||||
/** Paths of this job's outputs that are currently unreachable. */
|
||||
export function missingOutputsOf(job: Job | null | undefined): string[] {
|
||||
if (!job || job.status !== 'done' || !(job.output_paths?.length ?? 0))
|
||||
return [];
|
||||
return job.output_paths.filter((p) => missing.has(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* The take reads as 文件缺失 when it succeeded but at least one claimed
|
||||
* product file cannot be fetched.
|
||||
*/
|
||||
export function isFileMissing(job: Job | null | undefined): boolean {
|
||||
return missingOutputsOf(job).length > 0;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { storageGet, storageSet } from './storage';
|
||||
|
||||
/**
|
||||
* 减少动态效果 (HEL-649 §五). The OS setting always wins (app.css also reacts
|
||||
* to `prefers-reduced-motion` directly, so it cannot be switched off here);
|
||||
* otherwise the top-bar switch decides. The effective value is mirrored to
|
||||
* `<html data-reduce="1">`, which is what every reduced-motion rule keys on.
|
||||
*/
|
||||
const STORAGE_KEY = 'aigc-reduce-motion';
|
||||
const QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
export const reduceMotion = $state({ system: false, manual: false });
|
||||
|
||||
let stop: (() => void) | null = null;
|
||||
|
||||
function apply() {
|
||||
document.documentElement.dataset.reduce =
|
||||
reduceMotion.system || reduceMotion.manual ? '1' : '0';
|
||||
}
|
||||
|
||||
/** Call once on the client (root layout). Returns the teardown. */
|
||||
export function initReduceMotion(): () => void {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
stop?.();
|
||||
const mq = window.matchMedia(QUERY);
|
||||
reduceMotion.system = mq.matches;
|
||||
reduceMotion.manual = storageGet(STORAGE_KEY) === '1';
|
||||
apply();
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
reduceMotion.system = e.matches;
|
||||
apply();
|
||||
};
|
||||
mq.addEventListener('change', onChange);
|
||||
stop = () => mq.removeEventListener('change', onChange);
|
||||
return () => {
|
||||
stop?.();
|
||||
stop = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function setReduceMotion(on: boolean) {
|
||||
reduceMotion.manual = on;
|
||||
storageSet(STORAGE_KEY, on ? '1' : '0');
|
||||
apply();
|
||||
}
|
||||
@@ -305,13 +305,13 @@
|
||||
z-index: 2;
|
||||
}
|
||||
.keyframe.cam {
|
||||
background: #e0b34d;
|
||||
background: var(--accent-lo);
|
||||
}
|
||||
.keyframe.obj {
|
||||
background: #4ade80;
|
||||
background: var(--ok);
|
||||
}
|
||||
.keyframe.obj.selected {
|
||||
background: #f87171;
|
||||
background: var(--accent);
|
||||
}
|
||||
.keyframe:hover {
|
||||
filter: brightness(1.15);
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
export type ToastKind = 'success' | 'error' | 'info';
|
||||
|
||||
/** Inline action on a toast (§四.5: 动作金色文字,如「撤销」). */
|
||||
export type ToastAction = {
|
||||
label: string;
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
export type Toast = {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: ToastKind;
|
||||
/** Auto-dismiss delay in ms. 0 = sticky until dismissed. */
|
||||
duration: number;
|
||||
action?: ToastAction;
|
||||
};
|
||||
|
||||
type Listener = (toasts: Toast[]) => void;
|
||||
@@ -57,9 +64,10 @@ export function showToast(
|
||||
message: string,
|
||||
kind: ToastKind = 'success',
|
||||
durationMs: number = DEFAULT_DURATION,
|
||||
action?: ToastAction,
|
||||
) {
|
||||
const id = ++seq;
|
||||
toasts = [...toasts, { id, message, kind, duration: durationMs }];
|
||||
toasts = [...toasts, { id, message, kind, duration: durationMs, action }];
|
||||
emit();
|
||||
if (durationMs > 0) schedule(id, durationMs);
|
||||
return id;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<script lang="ts">
|
||||
import './app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import ToastHost from '$lib/components/ToastHost.svelte';
|
||||
import { initReduceMotion } from '$lib/reduceMotion.svelte';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
onMount(() => initReduceMotion());
|
||||
</script>
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -245,11 +245,11 @@
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function jobBar(job: Job): { value: number; tone: 'accent' | 'success' | 'error' } {
|
||||
function jobBar(job: Job): { value: number; tone: 'run' | 'success' | 'error' } {
|
||||
if (job.status === 'done') return { value: 100, tone: 'success' };
|
||||
if (job.status === 'failed') return { value: 100, tone: 'error' };
|
||||
if (job.status === 'running') return { value: progressFor(job.id)?.progress ?? 3, tone: 'accent' };
|
||||
return { value: 0, tone: 'accent' };
|
||||
if (job.status === 'running') return { value: progressFor(job.id)?.progress ?? 3, tone: 'run' };
|
||||
return { value: 0, tone: 'run' };
|
||||
}
|
||||
|
||||
function jobStatusLabel(status: string): string {
|
||||
@@ -304,10 +304,10 @@
|
||||
|
||||
<AppShell active="projects" title={t('home.title')} subtitle={t('home.subtitle')}>
|
||||
{#snippet actions()}
|
||||
<span class="chip">{t('home.projectCount', { n: allProjects.length, m: inProgressCount })}</span
|
||||
<span class="status-chip">{t('home.projectCount', { n: allProjects.length, m: inProgressCount })}</span
|
||||
>
|
||||
<button
|
||||
class="entry-btn"
|
||||
class="a-btn"
|
||||
class:primary={entryMode === 'none'}
|
||||
class:secondary={entryMode !== 'none'}
|
||||
onclick={openIdea}
|
||||
@@ -321,22 +321,22 @@
|
||||
<section class="main-col">
|
||||
{#if entryMode === 'none'}
|
||||
<div class="entries">
|
||||
<article class="entry-card">
|
||||
<span class="entry-icon"><Icon name="sparkle" size={18} /></span>
|
||||
<h2>{t('home.entryIdeaTitle')}</h2>
|
||||
<p>{t('home.entryIdeaBody')}</p>
|
||||
<button type="button" class="entry-btn secondary full" onclick={openIdea}>
|
||||
{t('home.startIdea')}
|
||||
</button>
|
||||
</article>
|
||||
<article class="entry-card">
|
||||
<span class="entry-icon"><Icon name="script" size={18} /></span>
|
||||
<h2>{t('home.entryScriptTitle')}</h2>
|
||||
<p>{t('home.entryScriptBody')}</p>
|
||||
<button type="button" class="entry-btn secondary full" onclick={openScript}>
|
||||
{t('home.importScript')}
|
||||
</button>
|
||||
</article>
|
||||
<article class="entry-card">
|
||||
<span class="entry-icon"><Icon name="sparkle" size={18} /></span>
|
||||
<h2>{t('home.entryIdeaTitle')}</h2>
|
||||
<p>{t('home.entryIdeaBody')}</p>
|
||||
<button type="button" class="a-btn secondary full" onclick={openIdea}>
|
||||
{t('home.startIdea')}
|
||||
</button>
|
||||
</article>
|
||||
<article class="entry-card">
|
||||
<span class="entry-icon"><Icon name="script" size={18} /></span>
|
||||
<h2>{t('home.entryScriptTitle')}</h2>
|
||||
<p>{t('home.entryScriptBody')}</p>
|
||||
<button type="button" class="a-btn secondary full" onclick={openScript}>
|
||||
{t('home.importScript')}
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
{:else if entryMode === 'idea'}
|
||||
<form class="entry-form" onsubmit={submitIdea}>
|
||||
@@ -351,43 +351,43 @@
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('projects.titleField')}<em>*</em></span>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.titleField')}<em>*</em></span>
|
||||
<input
|
||||
class="f-input"
|
||||
class="field-input"
|
||||
class:invalid={showErrors && ideaErrors.title}
|
||||
bind:value={ideaForm.title}
|
||||
placeholder={t('projects.titlePlaceholder')}
|
||||
/>
|
||||
{#if showErrors && ideaErrors.title}<span class="f-error">{ideaErrors.title}</span>{/if}
|
||||
{#if showErrors && ideaErrors.title}<span class="field-error">{ideaErrors.title}</span>{/if}
|
||||
</label>
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('projects.ideaField')}</span>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.ideaField')}</span>
|
||||
<textarea
|
||||
class="f-input textarea"
|
||||
class="field-textarea"
|
||||
bind:value={ideaForm.idea}
|
||||
placeholder={t('home.ideaPlaceholder')}
|
||||
rows={4}
|
||||
></textarea>
|
||||
<span class="f-hint">{t('projects.ideaHint')}</span>
|
||||
<span class="field-hint">{t('projects.ideaHint')}</span>
|
||||
</label>
|
||||
<div class="f-grid">
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('projects.genre')}</span>
|
||||
<select class="f-input" bind:value={ideaForm.genre}>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.genre')}</span>
|
||||
<select class="field-input" bind:value={ideaForm.genre}>
|
||||
{#each GENRES as v (v)}<option value={v}>{t('genres.' + genreKey(v))}</option
|
||||
>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('projects.tone')}</span>
|
||||
<select class="f-input" bind:value={ideaForm.tone}>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.tone')}</span>
|
||||
<select class="field-input" bind:value={ideaForm.tone}>
|
||||
{#each TONES as v (v)}<option value={v}>{t('tones.' + toneKey(v))}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('projects.duration')}</span>
|
||||
<select class="f-input" bind:value={ideaForm.duration}>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.duration')}</span>
|
||||
<select class="field-input" bind:value={ideaForm.duration}>
|
||||
{#each DURATIONS as v (v)}<option value={v}
|
||||
>{t('durations.' + durationKey(v))}</option
|
||||
>{/each}
|
||||
@@ -414,29 +414,29 @@
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('projects.titleField')}<em>*</em></span>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.titleField')}<em>*</em></span>
|
||||
<input
|
||||
class="f-input"
|
||||
class="field-input"
|
||||
class:invalid={showErrors && scriptErrors.title}
|
||||
bind:value={scriptForm.title}
|
||||
placeholder={t('projects.titlePlaceholder')}
|
||||
/>
|
||||
{#if showErrors && scriptErrors.title}<span class="f-error">{scriptErrors.title}</span
|
||||
{#if showErrors && scriptErrors.title}<span class="field-error">{scriptErrors.title}</span
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('home.scriptField')}<em>*</em></span>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('home.scriptField')}<em>*</em></span>
|
||||
<textarea
|
||||
class="f-input textarea"
|
||||
class="field-textarea"
|
||||
class:invalid={showErrors && scriptErrors.script}
|
||||
bind:value={scriptForm.script}
|
||||
placeholder={t('home.scriptPlaceholder')}
|
||||
rows={9}
|
||||
></textarea>
|
||||
{#if showErrors && scriptErrors.script}<span class="f-error">{scriptErrors.script}</span
|
||||
{#if showErrors && scriptErrors.script}<span class="field-error">{scriptErrors.script}</span
|
||||
>{/if}
|
||||
<span class="f-hint">{t('home.scriptHint')}</span>
|
||||
<span class="field-hint">{t('home.scriptHint')}</span>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<Button variant="ghost" onclick={closeEntry}>{t('projects.cancel')}</Button>
|
||||
@@ -491,12 +491,12 @@
|
||||
</section>
|
||||
|
||||
<aside class="tasks">
|
||||
<div class="tasks-head">
|
||||
<h2>{t('home.tasksTitle')}</h2>
|
||||
<span class="chip" class:amber={activeJobCount > 0}>
|
||||
{t('home.activeJobs', { n: activeJobCount })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tasks-head">
|
||||
<h2>{t('home.tasksTitle')}</h2>
|
||||
<span class="status-chip" class:run-chip={activeJobCount > 0}>
|
||||
{t('home.activeJobs', { n: activeJobCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if $jobsQuery.isLoading}
|
||||
<div class="tasks-loading">{t('common.loading')}…</div>
|
||||
@@ -513,7 +513,7 @@
|
||||
{@const bar = jobBar(job)}
|
||||
<li class="job-row" class:failed={job.status === 'failed'}>
|
||||
<div class="job-top">
|
||||
<span class="tag {tag.cls}">{tag.label}</span>
|
||||
<span class="a-tag">{tag.label}</span>
|
||||
<span class="job-name" title={jobTitle(job)}>{jobTitle(job)}</span>
|
||||
<span class="job-side">
|
||||
{#if job.status === 'failed' && job.error !== 'cancelled'}
|
||||
@@ -572,56 +572,7 @@
|
||||
}
|
||||
|
||||
/* ---------------- topbar chip + buttons ---------------- */
|
||||
.chip {
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-2);
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip.amber {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.entry-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 34px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 150ms,
|
||||
transform 100ms;
|
||||
}
|
||||
.entry-btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
.entry-btn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.entry-btn.secondary {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
.entry-btn.secondary:hover {
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.entry-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.entry-btn.full {
|
||||
.a-btn.full {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
@@ -630,57 +581,57 @@
|
||||
.entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
.entry-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px 20px;
|
||||
gap: var(--sp-3);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 18px var(--sp-7) var(--sp-7);
|
||||
min-height: 168px;
|
||||
}
|
||||
.entry-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.entry-card h2 {
|
||||
margin: 4px 0 0;
|
||||
margin: var(--sp-1) 0 0;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.entry-card p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-size: var(--fs-body-sm);
|
||||
line-height: 1.65;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.entry-form {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px 20px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 18px var(--sp-7) var(--sp-7);
|
||||
}
|
||||
.form-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
.form-head h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -691,89 +642,41 @@
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: var(--surface-3);
|
||||
background: var(--bg-4);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.f-field {
|
||||
display: block;
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
.f-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.f-label em {
|
||||
color: var(--error);
|
||||
.field-label em {
|
||||
color: var(--err);
|
||||
font-style: normal;
|
||||
}
|
||||
.f-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 7px;
|
||||
padding: 9px 11px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.f-input.textarea {
|
||||
resize: vertical;
|
||||
min-height: 88px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.f-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
}
|
||||
.f-input.invalid {
|
||||
border-color: rgba(229, 72, 77, 0.65);
|
||||
}
|
||||
.f-hint {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.f-error {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.f-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
gap: var(--sp-5);
|
||||
}
|
||||
.f-grid .f-field {
|
||||
.f-grid .field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-6);
|
||||
padding-top: var(--sp-5);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
/* ---------------- project cards ---------------- */
|
||||
.projects-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
gap: var(--sp-5);
|
||||
}
|
||||
.projects-head h2 {
|
||||
margin: 0;
|
||||
@@ -788,12 +691,12 @@
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
.skel-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.skel-card :global(.skeleton) {
|
||||
@@ -803,77 +706,82 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding: 14px 16px;
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
}
|
||||
.panel-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 36px 20px;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 10px;
|
||||
gap: var(--sp-4);
|
||||
padding: 36px var(--sp-7);
|
||||
border: 1.5px dashed var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.panel-empty p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-size: var(--fs-body-sm);
|
||||
line-height: 1.65;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.panel-empty.error p {
|
||||
color: var(--error);
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
/* ---------------- right task area ---------------- */
|
||||
.tasks {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px 14px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
padding: var(--sp-6) var(--sp-7) var(--sp-5);
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: var(--sp-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.tasks-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.tasks-head h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-size: var(--fs-section);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.run-chip {
|
||||
background: var(--run-soft);
|
||||
border-color: color-mix(in srgb, var(--run) 35%, transparent);
|
||||
color: var(--run);
|
||||
}
|
||||
.tasks-loading {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
padding: 12px 0;
|
||||
font-size: var(--fs-aux);
|
||||
padding: var(--sp-4) 0;
|
||||
}
|
||||
.tasks-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 22px 10px 16px;
|
||||
gap: var(--sp-3);
|
||||
padding: 22px var(--sp-4) var(--sp-6);
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.tasks-empty p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-size: var(--fs-aux);
|
||||
line-height: 1.65;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.tasks-empty-sub {
|
||||
font-size: 11px;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
@@ -888,8 +796,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: var(--sp-5) 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.job-row:last-child {
|
||||
border-bottom: none;
|
||||
@@ -897,29 +805,13 @@
|
||||
.job-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.tag {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-local {
|
||||
background: var(--info-dim);
|
||||
color: var(--info);
|
||||
}
|
||||
.tag-cloud {
|
||||
background: var(--cloud-dim);
|
||||
color: var(--cloud);
|
||||
}
|
||||
.job-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12.5px;
|
||||
font-size: var(--fs-body-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
@@ -929,7 +821,7 @@
|
||||
.job-side {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--sp-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.job-meta {
|
||||
@@ -944,19 +836,19 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.done-text {
|
||||
color: var(--success);
|
||||
color: var(--ok);
|
||||
}
|
||||
.running-text {
|
||||
color: var(--accent);
|
||||
color: var(--run);
|
||||
}
|
||||
.queued-text {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.failed-text {
|
||||
color: var(--error);
|
||||
color: var(--err);
|
||||
max-width: 120px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -966,8 +858,8 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
background: var(--run);
|
||||
box-shadow: 0 0 10px var(--run-soft);
|
||||
}
|
||||
.dot.breathe {
|
||||
animation: breathe 2s ease-in-out infinite;
|
||||
@@ -981,21 +873,25 @@
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
.retry-link {
|
||||
/* 次级文字操作「重试」走 ghost(§1.2):默认 --text-2,悬停 --text。 */
|
||||
.retry-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.retry-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.tasks-note {
|
||||
margin: 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-3);
|
||||
@@ -1005,13 +901,13 @@
|
||||
@media (max-width: 1439px) {
|
||||
.home {
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
padding: 20px 22px 36px;
|
||||
padding: var(--sp-6) 22px 36px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
@media (max-width: 1179px) {
|
||||
.home {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 16px 16px 32px;
|
||||
padding: var(--sp-5) var(--sp-5) 32px;
|
||||
}
|
||||
.tasks {
|
||||
position: static;
|
||||
@@ -1023,4 +919,9 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.home {
|
||||
padding: var(--sp-4) var(--sp-4) var(--sp-8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1133,11 +1133,11 @@
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #edeef0;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar.dark {
|
||||
color: #16130a;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.avatar.lg {
|
||||
width: 42px;
|
||||
@@ -1185,17 +1185,20 @@
|
||||
padding: 10px 4px;
|
||||
margin: 0;
|
||||
}
|
||||
/* Skill 行(§3.6):启用 = --run 描边 + 软底;停用 = --text-3 描边。 */
|
||||
.skill-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: var(--r-md);
|
||||
border: 1px solid color-mix(in srgb, var(--run) 45%, transparent);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.skill-row.disabled {
|
||||
opacity: 0.55;
|
||||
border-color: color-mix(in srgb, var(--text-3) 45%, transparent);
|
||||
background: var(--bg-2);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.skill-row-main {
|
||||
display: flex;
|
||||
@@ -1218,7 +1221,7 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.skill-name:hover {
|
||||
color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
.skill-tags {
|
||||
display: flex;
|
||||
@@ -1294,8 +1297,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-tag.busy {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
}
|
||||
.chat-head-actions {
|
||||
display: flex;
|
||||
@@ -1390,13 +1393,15 @@
|
||||
gap: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.link-btn:hover {
|
||||
color: var(--text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.versions {
|
||||
@@ -1486,7 +1491,7 @@
|
||||
color: var(--text-2);
|
||||
}
|
||||
.f-label em {
|
||||
color: var(--accent);
|
||||
color: var(--err);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
@@ -1618,7 +1623,7 @@
|
||||
background: var(--success);
|
||||
}
|
||||
.dot.amber {
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
}
|
||||
.dot.amber.breathing {
|
||||
animation: breathe 2s ease-in-out infinite;
|
||||
@@ -1660,7 +1665,7 @@
|
||||
border-radius: 12px 0 0 12px;
|
||||
transform: translateX(calc(100% + 18px));
|
||||
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow: -18px 0 48px rgba(0, 0, 0, 0.45);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
.right.open {
|
||||
transform: translateX(0);
|
||||
@@ -1669,7 +1674,7 @@
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: var(--overlay);
|
||||
z-index: 55;
|
||||
}
|
||||
.session-select {
|
||||
@@ -1677,7 +1682,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
@media (max-width: 1179px) {
|
||||
.model-pick .model-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+769
-150
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user