Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c3c05277b | ||
|
|
2689f5354a | ||
|
|
7bd8c29faa | ||
|
|
4f3293fc02 | ||
|
|
4c87c6dea0 | ||
|
|
d882fd62d8 | ||
|
|
b4c7334f45 | ||
|
|
6064bd805d | ||
|
|
c3deaf90bb | ||
|
|
949cd2ecad |
@@ -13,12 +13,14 @@ from calliope.comfyui.registry import (
|
||||
IMAGE_OUTPUT_CLASSES,
|
||||
IMAGE_URL_CLASSES,
|
||||
NUMBER_CLASSES,
|
||||
OPTIONAL_FRAME_INPUTS,
|
||||
TEXT_AREA_CLASSES,
|
||||
VIDEO_CLASSES,
|
||||
VIDEO_OUTPUT_CLASSES,
|
||||
ComfyOutputKind,
|
||||
class_to_input_kind,
|
||||
class_to_output_kind,
|
||||
optional_frame_sibling,
|
||||
)
|
||||
from calliope.comfyui.roles import (
|
||||
USAGE_VALUES,
|
||||
@@ -689,6 +691,8 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
)
|
||||
)
|
||||
|
||||
media = _with_optional_frames(workflow, media)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
positives = [t for t in texts if not t[3]]
|
||||
negatives = [t for t in texts if t[3]]
|
||||
@@ -732,6 +736,42 @@ def detect_untagged_inputs(workflow: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return results
|
||||
|
||||
|
||||
def _with_optional_frames(
|
||||
workflow: dict[str, Any], media: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Insert an optional slot after each frame slot whose node also accepts
|
||||
an unwired optional frame (H3 FL2VA: 首帧 → 尾帧)."""
|
||||
out = list(media)
|
||||
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 field in OPTIONAL_FRAME_INPUTS.get(str(node.get("class_type") or ""), {}):
|
||||
sibling = optional_frame_sibling(node, field)
|
||||
if sibling is None:
|
||||
continue
|
||||
loader_id = str(node["inputs"][sibling][0])
|
||||
loader = workflow.get(loader_id)
|
||||
if not isinstance(loader, dict) or loader.get("class_type") not in IMAGE_CLASSES:
|
||||
continue
|
||||
at = next(
|
||||
(
|
||||
i
|
||||
for i, e in enumerate(out)
|
||||
if e["nodeId"] == _key(loader_id, loader, "image")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if at is None:
|
||||
continue
|
||||
role, label = _FRAME_SLOT_ROLES[field]
|
||||
out.insert(
|
||||
at + 1,
|
||||
_entry(f"{nid}{FIELD_KEY_SEP}{field}", label, role, "image", None, False),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _is_negative(
|
||||
node_id: str, lowered_title: str, consumers: dict[str, list[tuple[str, str]]]
|
||||
) -> bool:
|
||||
|
||||
@@ -11,6 +11,7 @@ from calliope.comfyui.registry import (
|
||||
VIDEO_CLASSES,
|
||||
VIDEO_FILE_CLASSES,
|
||||
class_to_patch_field,
|
||||
optional_frame_sibling,
|
||||
)
|
||||
|
||||
# Input keys of the form "<nodeId>@<widget>" target one named widget on a
|
||||
@@ -142,6 +143,26 @@ def apply_media_group(
|
||||
apply_image_group = apply_media_group
|
||||
|
||||
|
||||
def apply_optional_frame(
|
||||
workflow: dict[str, Any], node_id: str, field: str, sibling: str, path: str
|
||||
) -> None:
|
||||
"""Wire ``path`` into an unwired optional frame input (H3 ``last_frame``)
|
||||
through a new LoadImage cloned from the ``sibling`` frame's loader."""
|
||||
node = workflow[node_id]
|
||||
inputs = dict(node.get("inputs") or {})
|
||||
src = workflow.get(str(inputs[sibling][0]))
|
||||
if isinstance(src, dict) and str(src.get("class_type") or "") in IMAGE_CLASSES:
|
||||
loader = copy.deepcopy(src)
|
||||
else:
|
||||
loader = {"class_type": "LoadImage", "inputs": {}}
|
||||
loader["inputs"] = {**(loader.get("inputs") or {}), "image": path}
|
||||
loader["_meta"] = {"title": "尾帧" if field == "last_frame" else field}
|
||||
nid = _next_node_id(workflow)
|
||||
workflow[nid] = loader
|
||||
inputs[field] = [nid, 0]
|
||||
node["inputs"] = inputs
|
||||
|
||||
|
||||
def patch_workflow(
|
||||
base: dict[str, Any],
|
||||
values_by_node_id: dict[str, Any],
|
||||
@@ -167,6 +188,10 @@ def patch_workflow(
|
||||
if clean:
|
||||
apply_media_group(patched, key, field, clean)
|
||||
continue
|
||||
if field is not None and (sibling := optional_frame_sibling(node, field)):
|
||||
if isinstance(value, str) and value.strip():
|
||||
apply_optional_frame(patched, key, field, sibling, value)
|
||||
continue
|
||||
if field is None:
|
||||
field = resolve_patch_field(node)
|
||||
elif isinstance(inputs.get(field), list):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Unified class_type → kind + patch-field registry (MuseAgent port)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
ComfyInputKind = Literal["text", "textarea", "number", "image", "image_url", "audio", "video"]
|
||||
ComfyOutputKind = Literal["image", "video", "other"]
|
||||
@@ -33,6 +33,24 @@ IMAGE_OUTPUT_CLASSES = frozenset(
|
||||
{"SaveImage", "PreviewImage", "SaveImageWebsocket", "ETN_SendImageWebSocket",
|
||||
"SaveImageAdvanced"}
|
||||
)
|
||||
# Frame-conditioned video nodes and the optional frame input an export may
|
||||
# leave unwired. MiniMax H3's FL2VA node takes `first_frame` plus an
|
||||
# optional `last_frame`; the studio offers the slot and the patcher adds
|
||||
# the LoadImage only when the user supplies that frame.
|
||||
OPTIONAL_FRAME_INPUTS: dict[str, dict[str, str]] = {
|
||||
"MiniMaxH3ImageToVideo": {"last_frame": "first_frame"},
|
||||
}
|
||||
|
||||
|
||||
def optional_frame_sibling(node: dict[str, Any], field: str) -> str | None:
|
||||
"""The wired frame input an unwired optional ``field`` mirrors
|
||||
(``last_frame`` → ``first_frame``); None when it does not apply."""
|
||||
inputs = node.get("inputs") or {}
|
||||
sibling = OPTIONAL_FRAME_INPUTS.get(str(node.get("class_type") or ""), {}).get(field)
|
||||
if sibling is None or field in inputs:
|
||||
return None
|
||||
link = inputs.get(sibling)
|
||||
return sibling if isinstance(link, list) and len(link) == 2 else None
|
||||
|
||||
|
||||
def class_to_input_kind(class_type: str) -> ComfyInputKind:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -410,6 +411,43 @@ def _backfill_workflow_output_map(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
# HEL-671: the user's H3 import went in under a first-frame-only name
|
||||
# (“H3图生视频·首帧…”) although its graph is the FL2VA node that also takes
|
||||
# a last frame. A row is renamed only when BOTH hold: its graph carries a
|
||||
# wired MiniMaxH3ImageToVideo node, and its name still reads like a
|
||||
# first-frame-only workflow — a row the user renamed, or any other graph,
|
||||
# is left alone. Idempotent: the new name no longer matches.
|
||||
_FIRST_FRAME_NAME_RE = re.compile(r"图生视频\s*[·\-—~]?\s*首帧")
|
||||
_FL2VA_DESCRIPTION = "首帧必填,尾帧可选:只传首帧为图生视频,同时传尾帧为首尾帧生视频"
|
||||
|
||||
|
||||
def _rename_workflows(conn: sqlite3.Connection) -> None:
|
||||
for wid, name, wf_json in conn.execute(
|
||||
"SELECT id, name, workflow_json FROM workflows"
|
||||
).fetchall():
|
||||
if not name or "尾帧" in name or not _FIRST_FRAME_NAME_RE.search(name):
|
||||
continue
|
||||
try:
|
||||
workflow = json.loads(wf_json) if wf_json else None
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if not isinstance(workflow, dict):
|
||||
continue
|
||||
fl2va = any(
|
||||
isinstance(node, dict)
|
||||
and str(node.get("class_type") or "") == "MiniMaxH3ImageToVideo"
|
||||
and isinstance((node.get("inputs") or {}).get("first_frame"), list)
|
||||
for node in workflow.values()
|
||||
)
|
||||
if not fl2va:
|
||||
continue
|
||||
conn.execute(
|
||||
"UPDATE workflows SET name = ?,"
|
||||
" description = COALESCE(NULLIF(description, ''), ?) WHERE id = ?",
|
||||
(_FIRST_FRAME_NAME_RE.sub("首尾帧生视频", name), _FL2VA_DESCRIPTION, wid),
|
||||
)
|
||||
|
||||
|
||||
async def migrate_db(db_path: Path) -> None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
@@ -472,6 +510,7 @@ async def migrate_db(db_path: Path) -> None:
|
||||
# upgrades can settle them); confirmed rows are never touched.
|
||||
_backfill_workflow_usage(conn)
|
||||
_backfill_workflow_output_map(conn)
|
||||
_rename_workflows(conn)
|
||||
scene_cols = {r[1] for r in conn.execute("PRAGMA table_info(scenes)").fetchall()}
|
||||
if "location_id" not in scene_cols:
|
||||
conn.execute("ALTER TABLE scenes ADD COLUMN location_id INTEGER")
|
||||
|
||||
@@ -36,6 +36,7 @@ QWEN_EDIT = "qwen21_edit_sage_API.json" # qwen-image-2.1-图片编辑-sage加
|
||||
EXAMPLES = Path(__file__).resolve().parents[2] / "example_ComfyUI_workflows"
|
||||
|
||||
REF_KEY = "131@ref_images"
|
||||
LAST_KEY = "131@last_frame"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
@@ -61,7 +62,8 @@ def _ref_links(wf: dict, node_id: str = "131") -> list[tuple[str, str]]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_i2v_exposes_one_required_first_frame_and_no_last_frame():
|
||||
def test_i2v_exposes_required_first_frame_and_optional_last_frame():
|
||||
"""HEL-671: the 04 export is H3 FL2VA — `last_frame` is unwired but real."""
|
||||
inputs = parse_dynamic_inputs(_load(I2V))
|
||||
media = [i for i in inputs if i["kind"] in ("image", "image_list", "video", "audio")]
|
||||
assert media == [
|
||||
@@ -73,10 +75,36 @@ def test_i2v_exposes_one_required_first_frame_and_no_last_frame():
|
||||
"defaultValue": None,
|
||||
"required": True,
|
||||
"auto": True,
|
||||
}
|
||||
},
|
||||
{
|
||||
"nodeId": LAST_KEY,
|
||||
"label": "尾帧",
|
||||
"role": "last_frame",
|
||||
"kind": "image",
|
||||
"defaultValue": None,
|
||||
"required": False,
|
||||
"auto": True,
|
||||
},
|
||||
]
|
||||
# the title comment about FL2V is not a slot
|
||||
assert not any(i.get("role") == "last_frame" for i in inputs)
|
||||
|
||||
|
||||
def test_wired_last_frame_is_its_own_slot_not_a_second_virtual_one():
|
||||
wf = _load(I2V)
|
||||
wf["950"] = {"class_type": "LoadImage", "inputs": {"image": "last.png"}}
|
||||
wf["131"]["inputs"]["last_frame"] = ["950", 0]
|
||||
media = [i for i in parse_dynamic_inputs(wf) if i["kind"] == "image"]
|
||||
assert [(i["nodeId"], i["role"], i["required"]) for i in media] == [
|
||||
("901", "first_frame", True),
|
||||
("950", "last_frame", True),
|
||||
]
|
||||
|
||||
|
||||
def test_first_frame_through_a_custom_loader_gets_no_last_frame_slot():
|
||||
# Repo example: VHS_LoadImagePath → resize → first_frame; no slot pair to mirror.
|
||||
wf = json.loads(
|
||||
(EXAMPLES / "minimax_h3-Turbo_Image2Video_20260813_API.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert not any(i.get("role") == "last_frame" for i in parse_dynamic_inputs(wf))
|
||||
|
||||
|
||||
def test_ref2v_exposes_one_group_of_one_to_nine_reference_images():
|
||||
@@ -201,6 +229,29 @@ def test_single_slot_values_patch_first_frame_and_qwen_input():
|
||||
assert qwen["4"]["inputs"]["resolution"] == 1536
|
||||
|
||||
|
||||
def test_patch_last_frame_adds_its_own_load_image():
|
||||
base = _load(I2V)
|
||||
wf = patch_workflow(base, {"901": "/u/first.png", LAST_KEY: "/u/last.png"})
|
||||
node = wf["131"]["inputs"]
|
||||
assert node["first_frame"] == ["901", 0]
|
||||
last_id = node["last_frame"][0]
|
||||
assert last_id not in base and node["last_frame"] == [last_id, 0]
|
||||
assert wf[last_id]["class_type"] == "LoadImage"
|
||||
assert wf[last_id]["inputs"] == {"image": "/u/last.png"}
|
||||
assert wf["901"]["inputs"]["image"] == "/u/first.png"
|
||||
# base untouched; patching the result again rewires nothing
|
||||
assert "last_frame" not in base["131"]["inputs"]
|
||||
again = patch_workflow(wf, {LAST_KEY: "/u/other.png"})
|
||||
assert again["131"]["inputs"]["last_frame"] == [last_id, 0]
|
||||
|
||||
|
||||
def test_patch_without_last_frame_leaves_first_frame_only_graph():
|
||||
for values in ({"901": "/u/first.png"}, {"901": "/u/first.png", LAST_KEY: ""}):
|
||||
wf = patch_workflow(_load(I2V), values)
|
||||
assert "last_frame" not in wf["131"]["inputs"]
|
||||
assert sorted(wf) == sorted(_load(I2V))
|
||||
|
||||
|
||||
def test_legacy_job_values_keyed_by_load_image_still_patch():
|
||||
"""Jobs queued before HEL-633 carry the LoadImage id, not the group key."""
|
||||
wf = patch_workflow(_load(REF), {"901": "/assets/uploads/old.png"})
|
||||
@@ -265,6 +316,14 @@ def test_validate_rejects_bad_reference_files(uploads_dir):
|
||||
def test_validate_requires_first_frame_and_input_image():
|
||||
_, errs = validate_input_values(parse_dynamic_inputs(_load(I2V)), {"1": "p"})
|
||||
assert errs == ["请上传首帧"]
|
||||
_, errs = validate_input_values(
|
||||
parse_dynamic_inputs(_load(I2V)), {"1": "p", LAST_KEY: "/u/l.png"}
|
||||
)
|
||||
assert errs == ["请上传首帧"]
|
||||
clean, errs = validate_input_values(
|
||||
parse_dynamic_inputs(_load(I2V)), {"1": "p", "901": "/u/f.png"}
|
||||
)
|
||||
assert errs == [] and LAST_KEY not in clean
|
||||
_, errs = validate_input_values(parse_dynamic_inputs(_load(QWEN_EDIT)), {"4@prompt": "p"})
|
||||
assert errs == ["请上传输入图"]
|
||||
|
||||
@@ -403,9 +462,67 @@ def test_i2v_chain_uploads_first_frame(client, named_comfy):
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
assert prompt["901"]["inputs"]["image"] == f"calliope/{Path(first).name}"
|
||||
assert prompt["131"]["inputs"]["first_frame"] == ["901", 0]
|
||||
assert "last_frame" not in prompt["131"]["inputs"]
|
||||
assert prompt["124"]["inputs"]["denoise"] == 0.8
|
||||
|
||||
|
||||
def test_fl2v_chain_uploads_first_and_last_frame_into_their_own_inputs(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
wf_id = _import_workflow(client, "H3 首尾帧生视频", "video", _load(I2V))
|
||||
listed = next(w for w in client.get("/api/workflows").json() if w["id"] == wf_id)
|
||||
assert listed["usage"] == "image2video"
|
||||
slots = {i["nodeId"]: i for i in listed["input_schema"] if i["kind"] == "image"}
|
||||
assert slots["901"]["role"] == "first_frame" and slots[LAST_KEY]["role"] == "last_frame"
|
||||
last = _upload(client, "尾帧.png")
|
||||
r = _generate(client, wf_id, {"1": "从白天过渡到夜晚", LAST_KEY: last})
|
||||
assert r.status_code == 400 and "请上传首帧" in r.json()["detail"]
|
||||
first = _upload(client, "首帧.png")
|
||||
r = _generate(client, wf_id, {"1": "从白天过渡到夜晚", "901": first, LAST_KEY: last})
|
||||
assert r.status_code == 200, r.text
|
||||
assert _wait_done(client, r.json()["job"]["id"])["status"] == "done"
|
||||
prompt = named_comfy.prompts[0]["prompt"]
|
||||
node = prompt["131"]["inputs"]
|
||||
assert prompt[node["first_frame"][0]]["inputs"]["image"] == f"calliope/{Path(first).name}"
|
||||
assert prompt[node["last_frame"][0]]["inputs"]["image"] == f"calliope/{Path(last).name}"
|
||||
assert node["first_frame"][0] != node["last_frame"][0]
|
||||
assert sorted(named_comfy.upload_names) == sorted([Path(first).name, Path(last).name])
|
||||
|
||||
|
||||
def test_migration_renames_the_imported_first_frame_workflow(tmp_path):
|
||||
import asyncio
|
||||
import sqlite3
|
||||
|
||||
from calliope.db import migrate_db
|
||||
|
||||
db = tmp_path / "c.db"
|
||||
asyncio.run(migrate_db(db))
|
||||
conn = sqlite3.connect(db)
|
||||
i2v, ref = json.dumps(_load(I2V)), json.dumps(_load(REF))
|
||||
conn.executemany(
|
||||
"INSERT INTO workflows (name, kind, workflow_json, description) VALUES (?, 'video', ?, ?)",
|
||||
[
|
||||
("H3 图生视频·首帧(04 用户提供)", i2v, None),
|
||||
("H3图生视频·首帧", i2v, "我自己写的说明"),
|
||||
("图生视频·首帧模板", ref, None), # not the FL2VA graph — left alone
|
||||
("我的视频工作流", i2v, None), # user already renamed — left alone
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
asyncio.run(migrate_db(db))
|
||||
asyncio.run(migrate_db(db)) # idempotent
|
||||
conn = sqlite3.connect(db)
|
||||
rows = conn.execute("SELECT name, description FROM workflows ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
assert rows[0] == (
|
||||
"H3 首尾帧生视频(04 用户提供)",
|
||||
"首帧必填,尾帧可选:只传首帧为图生视频,同时传尾帧为首尾帧生视频",
|
||||
)
|
||||
assert rows[1] == ("H3首尾帧生视频", "我自己写的说明") # suffix kept, description kept
|
||||
assert rows[2] == ("图生视频·首帧模板", None)
|
||||
assert rows[3] == ("我的视频工作流", None)
|
||||
|
||||
|
||||
def test_broken_qwen_import_warns_and_generation_is_refused(client, named_comfy):
|
||||
with _real_run(client, named_comfy):
|
||||
analyzed = client.post("/api/workflows/analyze", json={"workflow_json": _load(QWEN_EDIT)})
|
||||
|
||||
@@ -82,13 +82,16 @@ def test_a2_qwen_edit_variant_with_new_ids_titles_and_model_stays_image2image():
|
||||
assert len(mask) == 1 # mask never counts as an input image
|
||||
|
||||
|
||||
def test_a3_i2v_variant_renamed_still_image2video_first_frame_only():
|
||||
def test_a3_i2v_variant_renamed_still_image2video_with_optional_last_frame():
|
||||
wf = _rename_everything(_load(FIXTURES / "h3_i2v_04_API.json"))
|
||||
c = classify_usage(wf)
|
||||
assert c["usage"] == "image2video"
|
||||
media = _media(parse_dynamic_inputs(wf))
|
||||
assert [i.get("role") for i in media] == ["first_frame"]
|
||||
# A3: no first/last pair → the pair validation must not fire
|
||||
# HEL-671: the FL2VA node's unwired last_frame is offered, but optional
|
||||
assert [(i.get("role"), i["required"]) for i in media] == [
|
||||
("first_frame", True),
|
||||
("last_frame", False),
|
||||
]
|
||||
assert blocking_problem(wf) 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.");
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
@@ -57,7 +62,8 @@
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 1000;
|
||||
/* Above every overlay, incl. the 10000 lightbox, so feedback stays visible. */
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -104,6 +110,29 @@
|
||||
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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
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';
|
||||
|
||||
@@ -15,6 +17,10 @@
|
||||
* 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';
|
||||
@@ -26,9 +32,16 @@
|
||||
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;
|
||||
}
|
||||
@@ -41,9 +54,13 @@
|
||||
open,
|
||||
inactive,
|
||||
canSendToSlot,
|
||||
crossfade,
|
||||
playSignal,
|
||||
videoEl = $bindable(null),
|
||||
params,
|
||||
onClose,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onSendToSlot,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
@@ -55,9 +72,15 @@
|
||||
});
|
||||
|
||||
const st = $derived(job ? jobStatusInfo(job) : null);
|
||||
const outputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
|
||||
// §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) : []);
|
||||
@@ -84,6 +107,41 @@
|
||||
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')}>
|
||||
@@ -105,31 +163,58 @@
|
||||
<div class="a-stage stage"><div class="stage-empty">{t('desk.pickTake')}</div></div>
|
||||
{:else}
|
||||
<div class="a-stage stage">
|
||||
{#if output && video}
|
||||
{#key output}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={assetUrl(output)} controls playsinline preload="metadata" onloadedmetadata={noteVideo}></video>
|
||||
{/key}
|
||||
{:else if output}
|
||||
<img src={assetUrl(output)} alt={prompt || t('desk.tabResult')} onload={noteImg} />
|
||||
{: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>
|
||||
{#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>
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if outputs.length > 1}
|
||||
{#if allOutputs.length > 1}
|
||||
<div class="strip" role="group" aria-label={t('desk.outputs')}>
|
||||
{#each outputs as p, i (p)}
|
||||
{#each allOutputs as p, i (p)}
|
||||
<button
|
||||
type="button"
|
||||
class="strip-item"
|
||||
aria-pressed={i === outIndex}
|
||||
class:gone={gonePaths.includes(p)}
|
||||
aria-pressed={p === output}
|
||||
aria-label={t('desk.outputN', { n: i + 1 })}
|
||||
onclick={() => (outIndex = i)}
|
||||
>
|
||||
@@ -155,22 +240,44 @@
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<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 job.status !== 'pending' && job.status !== 'running'}
|
||||
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
|
||||
{#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}
|
||||
@@ -192,7 +299,10 @@
|
||||
<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 })}</dd>
|
||||
<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>
|
||||
@@ -247,8 +357,14 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.stage > img,
|
||||
.stage > video {
|
||||
.stage-media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.stage-media > img,
|
||||
.stage-media > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
@@ -264,6 +380,9 @@
|
||||
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);
|
||||
@@ -293,6 +412,11 @@
|
||||
.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%;
|
||||
@@ -326,6 +450,10 @@
|
||||
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);
|
||||
@@ -376,6 +504,9 @@
|
||||
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) {
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
{@const reject = rejects[s.inp.nodeId]?.[0]}
|
||||
<MediaSlot
|
||||
label={s.label}
|
||||
hint={s.lastFrame && !s.inp.required ? t('studio.slotOptional') : undefined}
|
||||
required={s.inp.required}
|
||||
value={String(values[s.inp.nodeId] ?? '')}
|
||||
kind={s.mediaKind}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
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';
|
||||
@@ -13,6 +14,12 @@
|
||||
* 一条记录 (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;
|
||||
@@ -20,6 +27,12 @@
|
||||
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;
|
||||
@@ -27,13 +40,37 @@
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { job, wf, selected, nowMs, busy = false, onSelect, onReuse, onRetry, onCancel, onDelete }: Props = $props();
|
||||
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));
|
||||
const tagTone = $derived(st.tone === 'queue' ? '' : st.tone === 'fail' ? 'err' : st.tone);
|
||||
// §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(st.tone === 'ok' ? (job.output_paths[0] ?? null) : null);
|
||||
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);
|
||||
@@ -46,11 +83,13 @@
|
||||
|
||||
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;
|
||||
@@ -58,17 +97,26 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="take" class:selected data-job={job.id}>
|
||||
<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} · ${st.label}`}
|
||||
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>
|
||||
@@ -85,32 +133,50 @@
|
||||
{/if}
|
||||
|
||||
<div class="state">
|
||||
<span class="a-tag {tagTone}">{st.label}</span>
|
||||
<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'}
|
||||
{:else if st.tone === 'ok' && !fileGone}
|
||||
<span class="txt">{t('desk.took', { d: fmtDur(elapsedSec(job, nowMs)) })}</span>
|
||||
{:else if st.tone === 'fail'}
|
||||
<span class="err-txt" title={st.detail}>{st.detail}</span>
|
||||
<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">
|
||||
<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 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>
|
||||
@@ -119,16 +185,37 @@
|
||||
{#if output}
|
||||
<div class="thumb" class:portrait>
|
||||
{#if isVideoOutput(job, output)}
|
||||
<video src={assetUrl(output)} preload="metadata" muted playsinline onloadedmetadata={noteVideo}></video>
|
||||
<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={noteImage} />
|
||||
<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}
|
||||
@@ -151,17 +238,75 @@
|
||||
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: var(--accent-line);
|
||||
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;
|
||||
@@ -242,13 +387,39 @@
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.err-txt {
|
||||
.gone-txt {
|
||||
min-width: 0;
|
||||
color: var(--err);
|
||||
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);
|
||||
@@ -329,7 +500,8 @@
|
||||
color: var(--on-media);
|
||||
}
|
||||
.thumb.pending,
|
||||
.thumb.missing {
|
||||
.thumb.missing,
|
||||
.thumb.gone {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-2);
|
||||
@@ -345,6 +517,10 @@
|
||||
border-style: solid;
|
||||
color: var(--run);
|
||||
}
|
||||
.thumb.gone {
|
||||
color: var(--warn);
|
||||
border-color: var(--warn);
|
||||
}
|
||||
.thumb.pending::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<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';
|
||||
@@ -10,6 +12,12 @@
|
||||
/**
|
||||
* 记录流 (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[];
|
||||
@@ -19,6 +27,13 @@
|
||||
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). */
|
||||
@@ -38,6 +53,10 @@
|
||||
nowMs,
|
||||
busyId,
|
||||
loading,
|
||||
pendingIds,
|
||||
flashIds,
|
||||
shakeIds,
|
||||
orderedIds = $bindable(),
|
||||
banner,
|
||||
inspectorToggle,
|
||||
onSelect,
|
||||
@@ -54,16 +73,27 @@
|
||||
{ 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)).sort((a, b) => parseDbTs(a.created_at) - parseDbTs(b.created_at)),
|
||||
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 shown) {
|
||||
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);
|
||||
@@ -79,12 +109,108 @@
|
||||
pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80;
|
||||
}
|
||||
$effect(() => {
|
||||
void shown.length;
|
||||
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">
|
||||
@@ -109,21 +235,36 @@
|
||||
<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)}
|
||||
<TakeCard
|
||||
{job}
|
||||
wf={workflowOf(job)}
|
||||
selected={job.id === selectedId}
|
||||
{nowMs}
|
||||
busy={busyId === job.id}
|
||||
onSelect={() => onSelect(job)}
|
||||
onReuse={() => onReuse(job)}
|
||||
onRetry={() => onRetry(job)}
|
||||
onCancel={() => onCancel(job)}
|
||||
onDelete={() => onDelete(job)}
|
||||
/>
|
||||
<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}
|
||||
@@ -149,6 +290,10 @@
|
||||
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;
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
* 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[];
|
||||
@@ -22,6 +25,7 @@
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let activeId = $state<number | null>(null);
|
||||
|
||||
type Status = { tone: '' | 'ok' | 'warn' | 'err'; label: string };
|
||||
|
||||
@@ -59,17 +63,46 @@
|
||||
})).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 onSearchKey(e: KeyboardEvent) {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
const first = groups[0]?.items[0];
|
||||
if (first) pick(first.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>
|
||||
|
||||
@@ -86,63 +119,80 @@
|
||||
<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" onclose={() => (query = '')}>
|
||||
<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')}
|
||||
onkeydown={onSearchKey}
|
||||
/>
|
||||
</div>
|
||||
<div class="list">
|
||||
{#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'}
|
||||
aria-current={cur ? 'true' : undefined}
|
||||
onclick={() => pick(r.wf)}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<span class="a-tag {r.status.tone}">{r.status.label}</span>
|
||||
</button>
|
||||
<span class="a-tag {r.status.tone}">{r.status.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="empty">{t('desk.wfNoMatch')}</p>
|
||||
{/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 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>
|
||||
<style>
|
||||
.picker {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.pop-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -173,6 +223,10 @@
|
||||
.row {
|
||||
align-items: center;
|
||||
}
|
||||
/* 键盘游走选中态(↑↓ 选择 · Enter 使用) */
|
||||
.row.active {
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.main {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
|
||||
@@ -282,7 +282,9 @@ export function validateForm(
|
||||
if (first && last) {
|
||||
const a = String(values[first.inp.nodeId] ?? '');
|
||||
const b = String(values[last.inp.nodeId] ?? '');
|
||||
if ((!a || !b) && (a || b)) {
|
||||
// An optional last frame (H3 FL2VA) may stay empty; it never goes without a first frame.
|
||||
const unpaired = last.inp.required ? (!a || !b) && (a || b) : !a && !!b;
|
||||
if (unpaired) {
|
||||
errs.firstLast = t('studio.errFirstLast');
|
||||
if (!a) errs[first.inp.nodeId] = ' ';
|
||||
if (!b) errs[last.inp.nodeId] = ' ';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -381,7 +381,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.arrow {
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
/** `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;
|
||||
}
|
||||
@@ -25,6 +27,7 @@
|
||||
align = 'start',
|
||||
placement = 'bottom',
|
||||
width = '300px',
|
||||
arrowRoving = true,
|
||||
onclose,
|
||||
children,
|
||||
}: Props = $props();
|
||||
@@ -55,7 +58,7 @@
|
||||
const current = document.activeElement as HTMLElement | null;
|
||||
const index = current ? list.indexOf(current) : -1;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
if (current?.tagName === 'SELECT') return;
|
||||
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;
|
||||
|
||||
@@ -9,6 +9,7 @@ export type NavId =
|
||||
| 'agents'
|
||||
| 'connections'
|
||||
| 'models'
|
||||
| 'library'
|
||||
| 'settings';
|
||||
|
||||
export interface NavItem {
|
||||
@@ -33,6 +34,7 @@ export const NAV_MAIN: NavItem[] = [
|
||||
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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { page } from '$app/state';
|
||||
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 AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
@@ -12,6 +12,7 @@
|
||||
import Inspector from '$lib/components/generate/Inspector.svelte';
|
||||
import ParamsPanel from '$lib/components/generate/ParamsPanel.svelte';
|
||||
import TakeFeed from '$lib/components/generate/TakeFeed.svelte';
|
||||
import { createTakeDeleteQueue } from '$lib/components/generate/takeDelete.svelte';
|
||||
import { readDeskContext } from '$lib/components/generate/context';
|
||||
import {
|
||||
changedParams,
|
||||
@@ -90,6 +91,22 @@
|
||||
// jobs list refetch knows about them.
|
||||
const submittedIds = new Set<number>();
|
||||
|
||||
// D 批: one-shot completion flash / failure shake (SSE-driven) and the
|
||||
// 5-second undo window for record deletes.
|
||||
const flashIds = new SvelteSet<number>();
|
||||
const shakeIds = new SvelteSet<number>();
|
||||
const deleteQueue = createTakeDeleteQueue(client);
|
||||
const pendingIds = deleteQueue.pending;
|
||||
$effect(() => deleteQueue.destroy);
|
||||
|
||||
function flashTake(id: number, ms: number, set: SvelteSet<number>) {
|
||||
set.add(id);
|
||||
setTimeout(() => set.delete(id), ms);
|
||||
}
|
||||
|
||||
let feed = $state<TakeFeed | null>(null);
|
||||
let orderedIds = $state<number[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
const off = connectEvents((ev) => {
|
||||
handleJobEvent(ev);
|
||||
@@ -100,6 +117,13 @@
|
||||
if (submittedIds.has(id) || (known && known.status !== 'failed')) {
|
||||
toast.error(t('studio.jobFailedToast', { detail: String(ev.data.error ?? '') }));
|
||||
}
|
||||
flashTake(id, 400, shakeIds);
|
||||
}
|
||||
if (ev.type === 'job.completed') {
|
||||
const id = Number(ev.data.job_id);
|
||||
flashTake(id, 950, flashIds);
|
||||
// 完成:检视区若正选中该条且为视频,自动播放。
|
||||
if (selectedJobId === id) playSignal += 1;
|
||||
}
|
||||
if (ev.type === 'job.completed' && submittedIds.has(Number(ev.data.job_id)) && ev.data.dry_run) {
|
||||
toast.info(t('studio.dryRunToast'));
|
||||
@@ -342,6 +366,10 @@
|
||||
sent = true;
|
||||
if (sentTimer) clearTimeout(sentTimer);
|
||||
sentTimer = setTimeout(() => (sent = false), 900);
|
||||
// C 批审核:从「参数」页提交后自动切到「结果」页,进行中进度可见。
|
||||
tab = 'result';
|
||||
if (wide) inspOff = false;
|
||||
else if (!narrow) inspOpen = true;
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
} catch (err) {
|
||||
@@ -361,13 +389,25 @@
|
||||
let inspOff = $state(false);
|
||||
let inspOpen = $state(false);
|
||||
let wide = $state(true);
|
||||
let narrow = $state(false);
|
||||
let crossfade = $state(false);
|
||||
let playSignal = $state(0);
|
||||
let videoEl = $state<HTMLVideoElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const mq = window.matchMedia('(min-width: 1180px)');
|
||||
const sync = () => (wide = mq.matches);
|
||||
const mq2 = window.matchMedia('(max-width: 760px)');
|
||||
const sync = () => {
|
||||
wide = mq.matches;
|
||||
narrow = mq2.matches;
|
||||
};
|
||||
sync();
|
||||
mq.addEventListener('change', sync);
|
||||
return () => mq.removeEventListener('change', sync);
|
||||
mq2.addEventListener('change', sync);
|
||||
return () => {
|
||||
mq.removeEventListener('change', sync);
|
||||
mq2.removeEventListener('change', sync);
|
||||
};
|
||||
});
|
||||
|
||||
let nowMs = $state(Date.now());
|
||||
@@ -378,19 +418,40 @@
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
/** Picked take, else the newest finished one in view. */
|
||||
/** Picked take, else the newest finished one in view (undo-pending rows don't count). */
|
||||
const selectedJob = $derived(
|
||||
allJobs.find((j) => j.id === selectedJobId) ??
|
||||
allJobs.find((j) => j.id === selectedJobId && !pendingIds.has(j.id)) ??
|
||||
allJobs
|
||||
.filter((j) => matchesFilter(j, filter) && jobStatusInfo(j).tone === 'ok')
|
||||
.filter((j) => matchesFilter(j, filter) && !pendingIds.has(j.id) && jobStatusInfo(j).tone === 'ok')
|
||||
.sort((a, b) => parseDbTs(b.created_at) - parseDbTs(a.created_at))[0] ??
|
||||
null,
|
||||
);
|
||||
|
||||
function selectTake(job: Job) {
|
||||
/**
|
||||
* 结果切换 (HEL-648): pointer picks slide the gold frame and cross-fade
|
||||
* the inspector; keyboard ↑↓/J/K switch instantly with no animation.
|
||||
*/
|
||||
function selectTake(job: Job, via: 'pointer' | 'keyboard' = 'pointer') {
|
||||
const from = via === 'pointer' ? (feed?.markRect() ?? null) : null;
|
||||
selectedJobId = job.id;
|
||||
tab = 'result';
|
||||
if (!wide) inspOpen = true;
|
||||
crossfade = via === 'pointer';
|
||||
if (via === 'pointer') {
|
||||
playSignal += 1;
|
||||
if (!wide) inspOpen = true;
|
||||
void tick().then(() => feed?.slideFrom(from));
|
||||
} else {
|
||||
feed?.scrollToJob(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
function moveSelection(delta: number) {
|
||||
if (!orderedIds.length) return;
|
||||
let i = orderedIds.indexOf(selectedJob?.id ?? -1);
|
||||
if (i < 0) i = orderedIds.length - 1;
|
||||
i = Math.max(0, Math.min(orderedIds.length - 1, i + delta));
|
||||
const job = allJobs.find((j) => j.id === orderedIds[i]);
|
||||
if (job) selectTake(job, 'keyboard');
|
||||
}
|
||||
|
||||
function openParams() {
|
||||
@@ -460,7 +521,7 @@
|
||||
toast.success(t('studio.retryQueuedToast'));
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(t('studio.retryFailedToast', { detail: err instanceof Error ? err.message : String(err) }));
|
||||
toast.error(t('studio.retryFailedToast', { detail: apiErrorMessage(err) }));
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
@@ -474,49 +535,39 @@
|
||||
toast.info(t('studio.cancelToast'));
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
let deleteOpen = $state(false);
|
||||
let deleteJob = $state<Job | null>(null);
|
||||
// The dialog names the exact take and — when it produced several
|
||||
// outputs — says plainly that ALL of them go away with it (the
|
||||
// backend only deletes whole jobs, never a single output file).
|
||||
const deleteTitle = $derived(
|
||||
deleteJob
|
||||
? t('studio.delTitleJob', { name: workflowOf(deleteJob)?.name ?? t('studio.jobName', { id: deleteJob.id }) })
|
||||
: t('studio.delTitle'),
|
||||
);
|
||||
const deleteMessage = $derived(
|
||||
(deleteJob?.output_paths?.length ?? 0) > 1
|
||||
? t('studio.delConfirmMulti', { n: deleteJob!.output_paths.length })
|
||||
: t('studio.delConfirm'),
|
||||
);
|
||||
|
||||
function askDelete(job: Job) {
|
||||
deleteJob = job;
|
||||
deleteOpen = true;
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
const job = deleteJob;
|
||||
if (!job) return;
|
||||
try {
|
||||
await playgroundApi.deleteJob(job.id);
|
||||
submittedIds.delete(job.id);
|
||||
if (selectedJobId === job.id) selectedJobId = null;
|
||||
toast.success(t('studio.deletedToast'));
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
deleteJob = null;
|
||||
deleteOpen = false;
|
||||
}
|
||||
/**
|
||||
* 删除整条任务 (§四.4 删除两级制): no dialog — the row collapses out,
|
||||
* a 5-second Toast offers 撤销 (row returns with its entrance and is
|
||||
* reselected), and only then does the DELETE really hit the backend.
|
||||
*/
|
||||
function requestDelete(job: Job) {
|
||||
if (job.status === 'pending' || job.status === 'running') return;
|
||||
const name = workflowOf(job)?.name ?? t('studio.jobName', { id: job.id });
|
||||
deleteQueue.request(job, {
|
||||
message: () =>
|
||||
(job.output_paths?.length ?? 0) > 0
|
||||
? t('studio.deletedTakeToast', { name })
|
||||
: t('studio.deletedTakeNoFilesToast', { name }),
|
||||
onUndo: (j) => {
|
||||
feed?.markEnter(j.id);
|
||||
const from = feed?.markRect() ?? null;
|
||||
selectedJobId = j.id;
|
||||
void tick().then(() => {
|
||||
feed?.slideFrom(from);
|
||||
feed?.scrollToJob(j.id);
|
||||
});
|
||||
},
|
||||
onCommitted: (j) => {
|
||||
submittedIds.delete(j.id);
|
||||
if (selectedJobId === j.id) selectedJobId = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -543,27 +594,60 @@
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Keyboard: Ctrl/⌘+Enter 生成 · / 聚焦提示词 · ] 检视区 · Esc 关浮层
|
||||
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦提示词 · ↑↓/J/K 切换记录
|
||||
// (即时无动画)· 空格播放/暂停 · Delete 删选中 · ] 检视区 · Esc 关浮层
|
||||
// ------------------------------------------------------------------
|
||||
function typing(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
|
||||
}
|
||||
|
||||
/** Focus sits on something that owns the key (button, link, video…). */
|
||||
function onInteractive(target: EventTarget | null): boolean {
|
||||
const el = target instanceof Element ? target : null;
|
||||
return !!el?.closest('button, a, input, textarea, select, label, video, audio, [contenteditable]');
|
||||
}
|
||||
|
||||
function inLayer(target: EventTarget | null): boolean {
|
||||
const el = target instanceof Element ? target : null;
|
||||
return !!el?.closest('.shell-pop, .modal, [role="dialog"]');
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented || deleteOpen || pickerFor) return;
|
||||
if (e.defaultPrevented || pickerFor || inLayer(e.target)) return;
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
if (e.key === '/' && !typing(e.target)) {
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === '/' && !typing(e.target)) {
|
||||
e.preventDefault();
|
||||
promptEl?.focus();
|
||||
} else if ((e.key === 'ArrowDown' || key === 'j') && !typing(e.target)) {
|
||||
e.preventDefault();
|
||||
moveSelection(1);
|
||||
} else if ((e.key === 'ArrowUp' || key === 'k') && !typing(e.target)) {
|
||||
e.preventDefault();
|
||||
moveSelection(-1);
|
||||
} else if (e.key === ' ') {
|
||||
// 空格播放/暂停:检视区可见且有视频时接管;焦点在控件上时走原生行为。
|
||||
if (typing(e.target) || onInteractive(e.target)) return;
|
||||
if (videoEl && (wide ? !inspOff : inspOpen)) {
|
||||
e.preventDefault();
|
||||
if (videoEl.paused) videoEl.play().catch(() => {});
|
||||
else videoEl.pause();
|
||||
}
|
||||
} else if (e.key === ']' && !typing(e.target)) {
|
||||
e.preventDefault();
|
||||
toggleInspector();
|
||||
} else if ((e.key === 'Delete' || e.key === 'Backspace') && !typing(e.target)) {
|
||||
const job = selectedJob;
|
||||
if (job && job.status !== 'pending' && job.status !== 'running') {
|
||||
e.preventDefault();
|
||||
requestDelete(job);
|
||||
}
|
||||
} else if (e.key === 'Escape' && !wide && inspOpen) {
|
||||
inspOpen = false;
|
||||
}
|
||||
@@ -604,6 +688,7 @@
|
||||
<div class="desk" class:insp-off={inspOff}>
|
||||
<section class="center" aria-label={t('desk.title')}>
|
||||
<TakeFeed
|
||||
bind:this={feed}
|
||||
jobs={allJobs}
|
||||
{workflowOf}
|
||||
bind:filter
|
||||
@@ -611,13 +696,17 @@
|
||||
{nowMs}
|
||||
{busyId}
|
||||
loading={$jobsQuery.isLoading}
|
||||
{pendingIds}
|
||||
{flashIds}
|
||||
{shakeIds}
|
||||
bind:orderedIds
|
||||
banner={ctxBanner}
|
||||
{inspectorToggle}
|
||||
onSelect={selectTake}
|
||||
onSelect={(job) => selectTake(job, 'pointer')}
|
||||
onReuse={reuse}
|
||||
onRetry={retry}
|
||||
onCancel={cancel}
|
||||
onDelete={askDelete}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
{#if !$workflowsQuery.isLoading && !deskWorkflows.length}
|
||||
<div class="desk-empty" role="note">
|
||||
@@ -662,11 +751,15 @@
|
||||
open={inspOpen}
|
||||
inactive={wide ? inspOff : !inspOpen}
|
||||
canSendToSlot={!!imageSlotTarget || !!imageList}
|
||||
{crossfade}
|
||||
{playSignal}
|
||||
bind:videoEl
|
||||
params={paramsTab}
|
||||
onClose={closeInspector}
|
||||
onReuse={reuse}
|
||||
onRetry={retry}
|
||||
onSendToSlot={sendToSlot}
|
||||
onDelete={askDelete}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
@@ -685,20 +778,6 @@
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={deleteTitle}
|
||||
message={deleteMessage}
|
||||
confirmLabel={t('studio.deleteBtn')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => {
|
||||
deleteJob = null;
|
||||
deleteOpen = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.desk {
|
||||
display: grid;
|
||||
|
||||
@@ -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>
|
||||
@@ -81,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"
|
||||
@@ -96,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')}
|
||||
/>
|
||||
@@ -112,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}
|
||||
>
|
||||
@@ -135,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>
|
||||
@@ -192,7 +192,7 @@
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost gen"
|
||||
class="a-btn ghost sm"
|
||||
disabled={busy || jobActive(asset.job)}
|
||||
title={t('workbench.asset.generateHint')}
|
||||
onclick={() =>
|
||||
@@ -214,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>
|
||||
@@ -378,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: var(--accent-line);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
@@ -432,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: var(--line-2);
|
||||
}
|
||||
.btn.gen {
|
||||
color: var(--accent);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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: var(--accent-line);
|
||||
}
|
||||
.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: var(--line-2);
|
||||
}
|
||||
.btn.sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -74,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"
|
||||
@@ -92,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')}
|
||||
@@ -103,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}
|
||||
>
|
||||
@@ -126,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>
|
||||
@@ -180,7 +180,7 @@
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost gen"
|
||||
class="a-btn ghost sm"
|
||||
disabled={busy || jobActive(shot.job)}
|
||||
title={t('workbench.vshot.generateHint')}
|
||||
onclick={() =>
|
||||
@@ -202,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>
|
||||
@@ -260,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 {
|
||||
@@ -279,9 +280,9 @@
|
||||
.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;
|
||||
@@ -347,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: var(--accent-line);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
@@ -403,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: var(--line-2);
|
||||
}
|
||||
.btn.gen {
|
||||
color: var(--accent);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: var(--accent-line);
|
||||
}
|
||||
.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: var(--line-2);
|
||||
}
|
||||
.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>
|
||||
@@ -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: var(--accent-line);
|
||||
}
|
||||
.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: var(--line-2);
|
||||
}
|
||||
.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,6 +1281,7 @@ export const de: Dict = {
|
||||
'shell.navAgents': 'Agenten',
|
||||
'shell.navConnections': 'ComfyUI-Verbindungen',
|
||||
'shell.navModels': 'Modelle',
|
||||
'shell.navLibrary': 'Asset-Bibliothek',
|
||||
'shell.navSettings': 'Einstellungen',
|
||||
'shell.comfy_ok': '{name} verbunden',
|
||||
'shell.comfy_fail': '{name} nicht erreichbar',
|
||||
@@ -1535,6 +1558,7 @@ export const de: Dict = {
|
||||
'studio.slotMask': 'Maske',
|
||||
'studio.slotFirst': 'Erster Frame',
|
||||
'studio.slotLast': 'Letzter Frame',
|
||||
'studio.slotOptional': 'Optional',
|
||||
'studio.slotRefVideo': 'Referenzvideo',
|
||||
'studio.slotAudio': 'Referenzaudio',
|
||||
'studio.slotPick': 'Klicken oder Datei ziehen',
|
||||
@@ -1609,7 +1633,11 @@ export const de: Dict = {
|
||||
'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',
|
||||
@@ -1628,9 +1656,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',
|
||||
@@ -1644,8 +1675,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',
|
||||
@@ -1875,6 +1908,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',
|
||||
@@ -1994,6 +2029,9 @@ export const de: Dict = {
|
||||
'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.',
|
||||
@@ -2014,6 +2052,8 @@ export const de: Dict = {
|
||||
'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',
|
||||
@@ -2041,4 +2081,20 @@ export const de: Dict = {
|
||||
'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,6 +1283,7 @@ export const en = {
|
||||
'shell.navAgents': 'Agents',
|
||||
'shell.navConnections': 'ComfyUI Connections',
|
||||
'shell.navModels': 'Models',
|
||||
'shell.navLibrary': 'Asset Library',
|
||||
'shell.navSettings': 'Settings',
|
||||
'shell.comfy_ok': '{name} connected',
|
||||
'shell.comfy_fail': '{name} unreachable',
|
||||
@@ -1534,6 +1557,7 @@ export const en = {
|
||||
'studio.slotMask': 'Mask',
|
||||
'studio.slotFirst': 'First frame',
|
||||
'studio.slotLast': 'Last frame',
|
||||
'studio.slotOptional': 'Optional',
|
||||
'studio.slotRefVideo': 'Reference video',
|
||||
'studio.slotAudio': 'Reference audio',
|
||||
'studio.slotPick': 'Click or drop a file',
|
||||
@@ -1608,7 +1632,11 @@ export const en = {
|
||||
'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',
|
||||
@@ -1627,9 +1655,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',
|
||||
@@ -1643,8 +1674,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',
|
||||
@@ -1874,6 +1907,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',
|
||||
@@ -1993,6 +2028,9 @@ export const en = {
|
||||
'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.',
|
||||
@@ -2013,6 +2051,8 @@ export const en = {
|
||||
'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',
|
||||
@@ -2040,6 +2080,22 @@ export const en = {
|
||||
'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,6 +1281,7 @@ export const es: Dict = {
|
||||
'shell.navAgents': 'Agentes',
|
||||
'shell.navConnections': 'Conexiones ComfyUI',
|
||||
'shell.navModels': 'Modelos',
|
||||
'shell.navLibrary': 'Biblioteca de assets',
|
||||
'shell.navSettings': 'Ajustes',
|
||||
'shell.comfy_ok': '{name} conectado',
|
||||
'shell.comfy_fail': '{name} sin conexión',
|
||||
@@ -1535,6 +1558,7 @@ export const es: Dict = {
|
||||
'studio.slotMask': 'Máscara',
|
||||
'studio.slotFirst': 'Primer fotograma',
|
||||
'studio.slotLast': 'Último fotograma',
|
||||
'studio.slotOptional': 'Opcional',
|
||||
'studio.slotRefVideo': 'Vídeo de referencia',
|
||||
'studio.slotAudio': 'Audio de referencia',
|
||||
'studio.slotPick': 'Haz clic o arrastra',
|
||||
@@ -1609,7 +1633,11 @@ export const es: Dict = {
|
||||
'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',
|
||||
@@ -1628,9 +1656,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',
|
||||
@@ -1644,8 +1675,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',
|
||||
@@ -1875,6 +1908,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',
|
||||
@@ -1994,6 +2029,9 @@ export const es: Dict = {
|
||||
'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.',
|
||||
@@ -2014,6 +2052,8 @@ export const es: Dict = {
|
||||
'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',
|
||||
@@ -2041,4 +2081,20 @@ export const es: Dict = {
|
||||
'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,6 +1281,7 @@ 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.comfy_ok': '{name} connecté',
|
||||
'shell.comfy_fail': '{name} injoignable',
|
||||
@@ -1535,6 +1558,7 @@ export const fr: Dict = {
|
||||
'studio.slotMask': 'Masque',
|
||||
'studio.slotFirst': 'Premier cadre',
|
||||
'studio.slotLast': 'Dernier cadre',
|
||||
'studio.slotOptional': 'Facultatif',
|
||||
'studio.slotRefVideo': 'Vidéo de référence',
|
||||
'studio.slotAudio': 'Audio de référence',
|
||||
'studio.slotPick': 'Cliquez ou déposez',
|
||||
@@ -1609,7 +1633,11 @@ export const fr: Dict = {
|
||||
'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',
|
||||
@@ -1628,9 +1656,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',
|
||||
@@ -1644,8 +1675,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',
|
||||
@@ -1875,6 +1908,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',
|
||||
@@ -1994,6 +2029,9 @@ export const fr: Dict = {
|
||||
'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.',
|
||||
@@ -2014,6 +2052,8 @@ export const fr: Dict = {
|
||||
'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',
|
||||
@@ -2041,4 +2081,20 @@ export const fr: Dict = {
|
||||
'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,6 +1279,7 @@ export const ja: Dict = {
|
||||
'shell.navAgents': 'エージェント',
|
||||
'shell.navConnections': 'ComfyUI 接続',
|
||||
'shell.navModels': 'モデル設定',
|
||||
'shell.navLibrary': 'アセットライブラリ',
|
||||
'shell.navSettings': '設定',
|
||||
'shell.comfy_ok': '{name} 接続済み',
|
||||
'shell.comfy_fail': '{name} 接続失敗',
|
||||
@@ -1535,6 +1556,7 @@ export const ja: Dict = {
|
||||
'studio.slotMask': 'マスク',
|
||||
'studio.slotFirst': '始端フレーム',
|
||||
'studio.slotLast': '終端フレーム',
|
||||
'studio.slotOptional': '任意',
|
||||
'studio.slotRefVideo': '参照動画',
|
||||
'studio.slotAudio': '参照音声',
|
||||
'studio.slotPick': 'クリックまたはドロップ',
|
||||
@@ -1609,7 +1631,11 @@ export const ja: Dict = {
|
||||
'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 接続」で取り込む',
|
||||
@@ -1628,9 +1654,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': '独立動画',
|
||||
@@ -1644,8 +1673,10 @@ export const ja: Dict = {
|
||||
'tasks.retry': '再試行',
|
||||
'tasks.cancel': 'キャンセル',
|
||||
'tasks.delete': '削除',
|
||||
'tasks.download': 'ダウンロード',
|
||||
'tasks.delTitle': 'タスクを削除',
|
||||
'tasks.delConfirm': '結果ファイルも一緒に削除されます。削除しますか?',
|
||||
'tasks.searchPh': 'タスクを検索…',
|
||||
'tasks.outputs': '結果 {n} 件',
|
||||
|
||||
'agentsPage.title': 'エージェント',
|
||||
@@ -1875,6 +1906,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': '接続中',
|
||||
@@ -1994,6 +2027,9 @@ export const ja: Dict = {
|
||||
'desk.kvName': '名前',
|
||||
'desk.kvPromptId': 'ComfyUI タスク ID',
|
||||
'desk.kvUsage': '用途',
|
||||
'desk.loadEarlier': '過去の {n} 件を表示',
|
||||
'desk.missingSub': '同じパラメータでやり直すか、この記録を削除できます',
|
||||
'desk.missingTitle': '結果ファイルはサーバー上にありません',
|
||||
'desk.needWorkflow': '不足:ワークフロー',
|
||||
'desk.noConn': '未接続',
|
||||
'desk.noPromptInput': 'このワークフローにはプロンプト入力がなく、元の値で生成します。',
|
||||
@@ -2014,6 +2050,8 @@ export const ja: Dict = {
|
||||
'desk.promptOnly': 'プロンプトのみ',
|
||||
'desk.promptPh': '欲しい画面を説明…(Ctrl/⌘ + Enter で生成、/ でフォーカス)',
|
||||
'desk.restoreAll': 'すべてワークフローの値に戻す',
|
||||
'desk.redo': '同じパラメータでやり直す',
|
||||
'desk.removeRecord': '記録を削除',
|
||||
'desk.restoreOrig': '元に戻す',
|
||||
'desk.reuse': '再利用',
|
||||
'desk.reuseToComposer': '入力台で再利用',
|
||||
@@ -2041,4 +2079,20 @@ export const ja: Dict = {
|
||||
'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,6 +1279,7 @@ export const ko: Dict = {
|
||||
'shell.navAgents': '에이전트',
|
||||
'shell.navConnections': 'ComfyUI 연결',
|
||||
'shell.navModels': '모델 설정',
|
||||
'shell.navLibrary': '애셋 라이브러리',
|
||||
'shell.navSettings': '설정',
|
||||
'shell.comfy_ok': '{name} 연결됨',
|
||||
'shell.comfy_fail': '{name} 연결 실패',
|
||||
@@ -1535,6 +1556,7 @@ export const ko: Dict = {
|
||||
'studio.slotMask': '마스크',
|
||||
'studio.slotFirst': '첫 프레임',
|
||||
'studio.slotLast': '마지막 프레임',
|
||||
'studio.slotOptional': '선택',
|
||||
'studio.slotRefVideo': '참조 비디오',
|
||||
'studio.slotAudio': '참조 오디오',
|
||||
'studio.slotPick': '클릭하거나 끌어다 놓기',
|
||||
@@ -1609,7 +1631,11 @@ export const ko: Dict = {
|
||||
'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 연결에서 가져오기',
|
||||
@@ -1628,9 +1654,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': '독립 비디오',
|
||||
@@ -1644,8 +1673,10 @@ export const ko: Dict = {
|
||||
'tasks.retry': '재시도',
|
||||
'tasks.cancel': '취소',
|
||||
'tasks.delete': '삭제',
|
||||
'tasks.download': '다운로드',
|
||||
'tasks.delTitle': '작업 삭제',
|
||||
'tasks.delConfirm': '결과 파일도 함께 삭제됩니다. 삭제할까요?',
|
||||
'tasks.searchPh': '작업 검색…',
|
||||
'tasks.outputs': '결과 {n}개',
|
||||
|
||||
'agentsPage.title': '에이전트',
|
||||
@@ -1875,6 +1906,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': '연결 중',
|
||||
@@ -1994,6 +2027,9 @@ export const ko: Dict = {
|
||||
'desk.kvName': '이름',
|
||||
'desk.kvPromptId': 'ComfyUI 작업 ID',
|
||||
'desk.kvUsage': '용도',
|
||||
'desk.loadEarlier': '이전 {n}개 표시',
|
||||
'desk.missingSub': '같은 설정으로 다시 만들거나 이 기록을 제거할 수 있습니다',
|
||||
'desk.missingTitle': '결과 파일이 서버에 없습니다',
|
||||
'desk.needWorkflow': '부족: 워크플로',
|
||||
'desk.noConn': '연결 안 됨',
|
||||
'desk.noPromptInput': '이 워크플로에는 프롬프트 입력이 없어 원래 값으로 생성합니다.',
|
||||
@@ -2014,6 +2050,8 @@ export const ko: Dict = {
|
||||
'desk.promptOnly': '프롬프트만',
|
||||
'desk.promptPh': '원하는 장면을 설명… (Ctrl/⌘ + Enter 생성, / 포커스)',
|
||||
'desk.restoreAll': '모두 워크플로 값으로 되돌리기',
|
||||
'desk.redo': '같은 설정으로 다시 생성',
|
||||
'desk.removeRecord': '기록 제거',
|
||||
'desk.restoreOrig': '원래 값으로',
|
||||
'desk.reuse': '재사용',
|
||||
'desk.reuseToComposer': '입력대에서 재사용',
|
||||
@@ -2041,4 +2079,20 @@ export const ko: Dict = {
|
||||
'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,6 +1281,7 @@ export const zh: Dict = {
|
||||
'shell.navAgents': '智能体',
|
||||
'shell.navConnections': 'ComfyUI 连接',
|
||||
'shell.navModels': '模型设置',
|
||||
'shell.navLibrary': '素材库',
|
||||
'shell.navSettings': '设置',
|
||||
'shell.comfy_ok': '{name} 已连接',
|
||||
'shell.comfy_fail': '{name} 连接失败',
|
||||
@@ -1534,6 +1555,7 @@ export const zh: Dict = {
|
||||
'studio.slotMask': '遮罩',
|
||||
'studio.slotFirst': '首帧',
|
||||
'studio.slotLast': '尾帧',
|
||||
'studio.slotOptional': '可选',
|
||||
'studio.slotRefVideo': '参考视频',
|
||||
'studio.slotAudio': '参考音频',
|
||||
'studio.slotPick': '点击或拖入',
|
||||
@@ -1609,7 +1631,11 @@ export const zh: Dict = {
|
||||
'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 连接」导入',
|
||||
@@ -1628,9 +1654,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': '独立生视频',
|
||||
@@ -1644,8 +1673,10 @@ export const zh: Dict = {
|
||||
'tasks.retry': '重试',
|
||||
'tasks.cancel': '取消',
|
||||
'tasks.delete': '删除',
|
||||
'tasks.download': '下载',
|
||||
'tasks.delTitle': '删除任务',
|
||||
'tasks.delConfirm': '删除后该任务的结果文件会一并移除。确定删除?',
|
||||
'tasks.searchPh': '搜索任务…',
|
||||
'tasks.outputs': '{n} 个结果',
|
||||
|
||||
'agentsPage.title': '智能体',
|
||||
@@ -1875,6 +1906,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': '连接中',
|
||||
@@ -1994,6 +2027,9 @@ export const zh: Dict = {
|
||||
'desk.kvName': '名称',
|
||||
'desk.kvPromptId': 'ComfyUI 任务号',
|
||||
'desk.kvUsage': '用途',
|
||||
'desk.loadEarlier': '显示更早的 {n} 条',
|
||||
'desk.missingSub': '可用相同参数重做,或移除这条记录',
|
||||
'desk.missingTitle': '结果文件已不在服务器上',
|
||||
'desk.needWorkflow': '还缺:工作流',
|
||||
'desk.noConn': '未连接',
|
||||
'desk.noPromptInput': '这个工作流没有提示词输入,按工作流原值生成。',
|
||||
@@ -2014,6 +2050,8 @@ export const zh: Dict = {
|
||||
'desk.promptOnly': '仅提示词',
|
||||
'desk.promptPh': '描述想要的画面…(Ctrl/⌘ + Enter 生成,/ 快速聚焦)',
|
||||
'desk.restoreAll': '全部恢复工作流原值',
|
||||
'desk.redo': '用相同参数重做',
|
||||
'desk.removeRecord': '移除记录',
|
||||
'desk.restoreOrig': '恢复原值',
|
||||
'desk.reuse': '复用',
|
||||
'desk.reuseToComposer': '复用到输入台',
|
||||
@@ -2041,4 +2079,20 @@ export const zh: Dict = {
|
||||
'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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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:run={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.run {
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
}
|
||||
|
||||
.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: var(--line-2);
|
||||
}
|
||||
.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(--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: var(--accent-line);
|
||||
}
|
||||
.f-input.invalid {
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.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,26 +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,
|
||||
.tag-cloud {
|
||||
background: var(--bg-4);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.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;
|
||||
@@ -926,7 +821,7 @@
|
||||
.job-side {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--sp-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.job-meta {
|
||||
@@ -941,10 +836,10 @@
|
||||
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(--run);
|
||||
@@ -953,7 +848,7 @@
|
||||
color: var(--text-3);
|
||||
}
|
||||
.failed-text {
|
||||
color: var(--error);
|
||||
color: var(--err);
|
||||
max-width: 120px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -978,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);
|
||||
@@ -1002,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;
|
||||
@@ -1020,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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -550,6 +550,11 @@ button.status-chip:active {
|
||||
.a-chip.changed {
|
||||
color: var(--accent);
|
||||
}
|
||||
/* Selected filter chip (§3.5 筛选芯片组): 当前选中 = 金色文字 + accent-soft 底 */
|
||||
.a-chip.sel {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
.a-chip.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
@@ -574,7 +574,7 @@ function toggleChat() {
|
||||
.left-panel {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--border, #232733);
|
||||
border-right: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
@@ -582,7 +582,7 @@ function toggleChat() {
|
||||
.inspector-slot {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--border, #232733);
|
||||
border-top: 1px solid var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
.center-panel {
|
||||
@@ -600,7 +600,7 @@ function toggleChat() {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: #0b1220;
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.loading {
|
||||
flex: 1;
|
||||
@@ -612,11 +612,11 @@ function toggleChat() {
|
||||
.chat-panel {
|
||||
width: 360px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--border, #232733);
|
||||
border-left: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
transition: width 0.18s ease;
|
||||
transition: width var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.chat-panel.collapsed {
|
||||
width: 40px;
|
||||
@@ -636,7 +636,7 @@ function toggleChat() {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.chat-toggle {
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--line-2);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
@@ -650,14 +650,14 @@ function toggleChat() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.chat-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border, #232733);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.chat-head .chat-toggle {
|
||||
margin-left: auto;
|
||||
@@ -677,7 +677,7 @@ function toggleChat() {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border: 1px solid var(--border, #2a2f3a);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
opacity: 0.7;
|
||||
@@ -686,6 +686,37 @@ function toggleChat() {
|
||||
margin: 0;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--muted, #9aa3b5);
|
||||
color: var(--text-3);
|
||||
}
|
||||
/* ── ≤760px 可用级适配(F 批):解除 100vh 固定布局,纵向堆叠可滚动。 ── */
|
||||
@media (max-width: 760px) {
|
||||
.shell {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
}
|
||||
.workspace {
|
||||
flex-direction: column;
|
||||
}
|
||||
.left-panel {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line);
|
||||
max-height: 60vh;
|
||||
}
|
||||
.chat-panel,
|
||||
.chat-panel.collapsed {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--line);
|
||||
min-height: 50vh;
|
||||
}
|
||||
.chat-panel.collapsed .chat-head {
|
||||
flex-direction: row;
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
}
|
||||
.chat-title-vert {
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,6 +39,6 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
.redirect-error {
|
||||
color: #f87171;
|
||||
color: var(--err);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1080,7 +1080,7 @@
|
||||
<span class="saved">saved</span>
|
||||
{/if}
|
||||
{#if canvas.project}
|
||||
<a class="project-chip" href="/project/{canvas.project.id}">
|
||||
<a class="project-chip" href="/workbench/{canvas.project.id}">
|
||||
<Icon name="folder" size={12} />
|
||||
{canvas.project.title}
|
||||
</a>
|
||||
@@ -1387,8 +1387,8 @@ title={running
|
||||
color 0.15s;
|
||||
}
|
||||
.unlink-btn:hover:not(:disabled) {
|
||||
border-color: #f87171;
|
||||
color: #fca5a5;
|
||||
border-color: var(--err-line);
|
||||
color: var(--err);
|
||||
}
|
||||
.unlink-btn:disabled {
|
||||
opacity: 0.45;
|
||||
@@ -1476,4 +1476,27 @@ title={running
|
||||
top: 14px;
|
||||
z-index: 5;
|
||||
}
|
||||
/* ── ≤760px 可用级适配(F 批):解除 100vh 固定布局,画布与对话纵向堆叠。 ── */
|
||||
@media (max-width: 760px) {
|
||||
.shell {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
}
|
||||
.workspace {
|
||||
flex-direction: column;
|
||||
}
|
||||
.flow-panel {
|
||||
min-height: 60vh;
|
||||
}
|
||||
.chat-panel {
|
||||
width: 100% !important;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--line);
|
||||
min-height: 50vh;
|
||||
}
|
||||
.chat-splitter {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import WorkflowImportDrawer from '$lib/components/WorkflowImportDrawer.svelte';
|
||||
import WorkflowTestDialog from '$lib/components/WorkflowTestDialog.svelte';
|
||||
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowOutputMapEntry, type WorkflowUsage, type WorkflowAnalyzeResult, type WorkflowConnectionCheck } from '$lib/api';
|
||||
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowOutputMapEntry, type WorkflowUsage, type WorkflowConnectionCheck } from '$lib/api';
|
||||
import { WORKFLOW_USAGES } from '$lib/comfy/types';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
@@ -327,214 +328,24 @@
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Workflow library (import + binding) — two-step wizard: paste/analyze,
|
||||
// then confirm the judged usage and slot roles before saving (HEL-636).
|
||||
// Workflow library (binding + per-row recheck / test). The import
|
||||
// wizard itself lives in WorkflowImportDrawer (HEL-655 E 批).
|
||||
// ------------------------------------------------------------------
|
||||
let wfJsonText = $state('');
|
||||
let wfImportOpen = $state(false);
|
||||
let wfImportName = $state('');
|
||||
let wfImportEndpoint = $state('');
|
||||
let wfStep = $state<'input' | 'confirm'>('input');
|
||||
let wfAnalysis = $state<WorkflowAnalyzeResult | null>(null);
|
||||
let wfUsage = $state<WorkflowUsage | null>(null);
|
||||
let wfRoleOverrides = $state<Record<string, string>>({});
|
||||
// HEL-638: explicit output-node designation for unknown custom nodes.
|
||||
let wfOutputPicked = $state<Record<string, boolean>>({});
|
||||
let wfOutputKind = $state<Record<string, 'image' | 'video'>>({});
|
||||
let wfAnalyzing = $state(false);
|
||||
let wfAnalyzeError = $state('');
|
||||
|
||||
const wfParsed = $derived.by(() => {
|
||||
const text = wfJsonText.trim();
|
||||
if (!text) return { valid: false as const, json: null as Record<string, unknown> | null };
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed) &&
|
||||
Object.values(parsed).some(
|
||||
(v) => typeof v === 'object' && v !== null && 'class_type' in (v as object),
|
||||
)
|
||||
) {
|
||||
return { valid: true as const, json: parsed as Record<string, unknown> };
|
||||
}
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
return { valid: false as const, json: null };
|
||||
});
|
||||
|
||||
function usageLabel(u: WorkflowUsage | null | undefined): string {
|
||||
return u ? t(`usage.${u}`) : t('usage.pending');
|
||||
}
|
||||
|
||||
function startWfImport() {
|
||||
wfImportOpen = true;
|
||||
wfImportName = '';
|
||||
wfJsonText = '';
|
||||
wfImportEndpoint = '';
|
||||
wfStep = 'input';
|
||||
wfAnalysis = null;
|
||||
wfUsage = null;
|
||||
wfRoleOverrides = {};
|
||||
wfOutputPicked = {};
|
||||
wfOutputKind = {};
|
||||
wfAnalyzeError = '';
|
||||
}
|
||||
|
||||
function usageLabel(u: WorkflowUsage | null | undefined): string {
|
||||
return u ? t(`usage.${u}`) : t('usage.pending');
|
||||
}
|
||||
|
||||
function wfProblems(wf: Workflow): string[] {
|
||||
return (wf.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message);
|
||||
}
|
||||
|
||||
async function runWfAnalyze(refresh = false) {
|
||||
if (!wfParsed.valid || !wfParsed.json) return;
|
||||
wfAnalyzing = true;
|
||||
wfAnalyzeError = '';
|
||||
try {
|
||||
const res = await workflows.analyze(
|
||||
wfParsed.json,
|
||||
wfImportEndpoint || undefined,
|
||||
refresh,
|
||||
);
|
||||
wfAnalysis = res;
|
||||
wfUsage = res.usage ?? null;
|
||||
wfRoleOverrides = {};
|
||||
// Unknown output candidates: preselect only when there is exactly
|
||||
// one — with several the user must decide (HEL-638).
|
||||
const candidates = (res.usage_evidence.outputs ?? []).filter(
|
||||
(o) => o.evidence === 'unknown_node',
|
||||
);
|
||||
const picked: Record<string, boolean> = {};
|
||||
const kinds: Record<string, 'image' | 'video'> = {};
|
||||
for (const c of candidates) {
|
||||
kinds[c.nodeId] = c.suggested_kind ?? 'image';
|
||||
}
|
||||
if (candidates.length === 1) picked[candidates[0].nodeId] = true;
|
||||
wfOutputPicked = picked;
|
||||
wfOutputKind = kinds;
|
||||
wfStep = 'confirm';
|
||||
} catch (err) {
|
||||
wfAnalyzeError = apiErrorMessage(err);
|
||||
} finally {
|
||||
wfAnalyzing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the graph has no output node at all — nothing to confirm. */
|
||||
const wfFatal = $derived(
|
||||
wfStep === 'confirm' && wfAnalysis !== null && (wfAnalysis.outputs?.length ?? 0) === 0,
|
||||
);
|
||||
/** Unknown custom output nodes the user must designate (HEL-638). */
|
||||
const wfOutputCandidates = $derived(
|
||||
wfStep === 'confirm' && wfAnalysis !== null
|
||||
? (wfAnalysis.usage_evidence.outputs ?? []).filter((o) => o.evidence === 'unknown_node')
|
||||
: [],
|
||||
);
|
||||
const wfOutputRows = $derived(
|
||||
wfOutputCandidates.map((o) => ({
|
||||
...o,
|
||||
picked: wfOutputPicked[o.nodeId] ?? false,
|
||||
kind: wfOutputKind[o.nodeId] ?? o.suggested_kind ?? 'image',
|
||||
})),
|
||||
);
|
||||
const wfOutputPickedCount = $derived(
|
||||
Object.values(wfOutputPicked).filter(Boolean).length,
|
||||
);
|
||||
const wfMustPick = $derived(
|
||||
wfStep === 'confirm' &&
|
||||
wfAnalysis !== null &&
|
||||
!wfFatal &&
|
||||
(wfAnalysis.usage_ambiguous || wfAnalysis.needs_mapping) &&
|
||||
wfUsage === null,
|
||||
);
|
||||
const wfSaveable = $derived(
|
||||
wfStep === 'confirm' &&
|
||||
wfUsage !== null &&
|
||||
!wfFatal &&
|
||||
(wfOutputCandidates.length === 0 || wfOutputPickedCount > 0),
|
||||
);
|
||||
/** Media slots the confirm page lets the user re-role. */
|
||||
const wfSlotRows = $derived.by(() => {
|
||||
if (wfStep !== 'confirm' || !wfAnalysis) return [];
|
||||
return wfAnalysis.usage_evidence.image_slots.map((s) => ({
|
||||
...s,
|
||||
role: wfRoleOverrides[s.nodeId] ?? s.role ?? 'image',
|
||||
fixed: s.kind === 'image_list',
|
||||
}));
|
||||
});
|
||||
const wfParamPreview = $derived.by(() => {
|
||||
if (!wfAnalysis) return [];
|
||||
// Count EVERY analyzed input (media slots included) so the preview
|
||||
// matches the inline count shown after import (input_schema.length)
|
||||
// and in the studio — HEL-619.
|
||||
return wfAnalysis.inputs.map((i) => i.label);
|
||||
});
|
||||
|
||||
const wfImportMutation = createMutation({
|
||||
mutationFn: async () => {
|
||||
if (!wfParsed.valid || !wfParsed.json || !wfAnalysis || !wfUsage)
|
||||
throw new Error(t('conn.wfPickUsage'));
|
||||
if (wfOutputCandidates.length > 0 && wfOutputPickedCount === 0)
|
||||
throw new Error(t('conn.wfOutputPick'));
|
||||
const corrected = Object.keys(wfRoleOverrides).length > 0;
|
||||
const schema = wfAnalysis.inputs.map((e) =>
|
||||
wfRoleOverrides[e.nodeId]
|
||||
? { ...e, role: wfRoleOverrides[e.nodeId], manual: true }
|
||||
: e,
|
||||
);
|
||||
const outputMap = wfOutputRows
|
||||
.filter((r) => r.picked)
|
||||
.map((r) => ({
|
||||
nodeId: r.nodeId,
|
||||
kind: r.kind,
|
||||
label: r.label,
|
||||
...(r.class_type ? { class_type: r.class_type } : {}),
|
||||
}));
|
||||
return workflows.create({
|
||||
name: wfImportName.trim(),
|
||||
workflow_json: wfParsed.json,
|
||||
usage: wfUsage,
|
||||
usage_source: wfUsage !== wfAnalysis.usage || corrected ? 'user' : 'auto',
|
||||
input_schema: schema,
|
||||
...(outputMap.length ? { output_map: outputMap, output_map_source: 'user' } : {}),
|
||||
...(wfImportEndpoint ? { endpoint_id: wfImportEndpoint } : {}),
|
||||
});
|
||||
},
|
||||
onSuccess: (wf) => {
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
const n = wf.input_schema?.length ?? 0;
|
||||
const problems = wfProblems(wf);
|
||||
if (problems.length) toast.error(t('conn.wfImportedBroken', { detail: problems.join(';') }));
|
||||
else if (n) toast.success(t('conn.wfImportedWithUsage', { usage: usageLabel(wf.usage), n }));
|
||||
else toast.info(t('conn.wfImportedNoParams'));
|
||||
wfImportOpen = false;
|
||||
wfJsonText = '';
|
||||
wfImportName = '';
|
||||
wfStep = 'input';
|
||||
wfAnalysis = null;
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
t('conn.wfImportFailed', {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function submitWfImport() {
|
||||
if (!wfSaveable || !wfImportName.trim() || $wfImportMutation.isPending) return;
|
||||
$wfImportMutation.mutate();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// HEL-643 L2: connection-compatibility check on the import confirm
|
||||
// step, and per-row 重新检查 / 测试 in the workflow library.
|
||||
// ------------------------------------------------------------------
|
||||
const wfConnCheck = $derived<WorkflowConnectionCheck | null>(
|
||||
wfAnalysis?.connection_check ?? null,
|
||||
);
|
||||
// HEL-643 L2: per-row 重新检查 / 测试 in the workflow library.
|
||||
let wfRecheckId = $state<number | null>(null);
|
||||
const wfRecheckResult = $state<Record<number, WorkflowConnectionCheck>>({});
|
||||
|
||||
@@ -686,15 +497,6 @@
|
||||
},
|
||||
});
|
||||
|
||||
async function onWfFile(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
wfJsonText = await file.text();
|
||||
if (!wfImportName.trim()) wfImportName = file.name.replace(/\.json$/i, '');
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
const wfDeleteMutation = createMutation({
|
||||
mutationFn: (id: number) => workflows.delete(id),
|
||||
onSuccess: () => {
|
||||
@@ -921,296 +723,6 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if wfImportOpen}
|
||||
<div class="wf-import" transition:fade={{ duration: 180 }}>
|
||||
<div class="wf-import-head">
|
||||
<h3>
|
||||
{wfStep === 'input' ? t('conn.wfImportTitle') : t('conn.wfConfirmTitle')}
|
||||
<span class="wf-step-tag">
|
||||
{wfStep === 'input' ? `1 / 2 · ${t('conn.wfStepAnalyze')}` : `2 / 2 · ${t('conn.wfStepConfirm')}`}
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('conn.formClose')}
|
||||
onclick={() => (wfImportOpen = false)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if wfStep === 'input'}
|
||||
<p class="wf-import-hint">{t('conn.wfImportHint')}</p>
|
||||
<div class="wf-import-grid">
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('conn.fName')}<em>*</em></span>
|
||||
<input
|
||||
class="f-input"
|
||||
value={wfImportName}
|
||||
oninput={(e) => (wfImportName = e.currentTarget.value)}
|
||||
placeholder="Qwen Image 2.1 文生图"
|
||||
/>
|
||||
</label>
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('conn.wfConnection')}</span>
|
||||
<select
|
||||
class="f-input"
|
||||
value={wfImportEndpoint}
|
||||
onchange={(e) => (wfImportEndpoint = e.currentTarget.value)}
|
||||
>
|
||||
<option value="">{t('conn.wfConnectionActive')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="f-field">
|
||||
<span class="f-label">API JSON<em>*</em></span>
|
||||
<div class="wf-json-row">
|
||||
<textarea
|
||||
class="f-input wf-json"
|
||||
class:invalid={wfJsonText.trim() !== '' && !wfParsed.valid}
|
||||
spellcheck="false"
|
||||
rows="3"
|
||||
value={wfJsonText}
|
||||
oninput={(e) => (wfJsonText = e.currentTarget.value)}
|
||||
placeholder={t('conn.wfJsonPh')}
|
||||
></textarea>
|
||||
<label class="file-btn">
|
||||
<Icon name="upload" size={14} />
|
||||
{t('conn.wfChooseFile')}
|
||||
<input type="file" accept=".json,application/json" onchange={onWfFile} />
|
||||
</label>
|
||||
</div>
|
||||
{#if wfJsonText.trim() !== '' && !wfParsed.valid}
|
||||
<span class="f-error">{t('conn.wfJsonInvalid')}</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<span class="need-test" class:passed={wfParsed.valid && wfImportName.trim() !== ''}>
|
||||
{wfParsed.valid
|
||||
? t('conn.wfNodes', { n: Object.keys(wfParsed.json ?? {}).length })
|
||||
: 'JSON'}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!wfParsed.valid || !wfImportName.trim()}
|
||||
loading={wfAnalyzing}
|
||||
onclick={() => runWfAnalyze()}
|
||||
>
|
||||
{wfAnalyzing ? t('conn.wfAnalyzing') : t('conn.wfAnalyze')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else if wfAnalysis}
|
||||
<p class="wf-import-hint">{t('conn.wfConfirmHint')}</p>
|
||||
|
||||
{#if wfAnalysis.usage_problem}
|
||||
<div class="wf-note" class:fatal={wfFatal} role={wfFatal ? 'alert' : 'note'}>
|
||||
<Icon name={wfFatal ? 'alert' : 'info'} size={14} />
|
||||
<p>{wfAnalysis.usage_problem}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if wfAnalyzeError}
|
||||
<div class="wf-note fatal" role="alert">
|
||||
<Icon name="alert" size={14} />
|
||||
<p>{wfAnalyzeError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if wfConnCheck}
|
||||
<div class="wf-conn-check" class:ok={wfConnCheck.ok} class:bad={wfConnCheck.ok === false && wfConnCheck.reachable} class:warn={!wfConnCheck.reachable}>
|
||||
<div class="wf-conn-head">
|
||||
<Icon name={wfConnCheck.ok ? 'check' : wfConnCheck.reachable ? 'alert' : 'info'} size={14} />
|
||||
<span class="wf-conn-title">{t('conn.wfCheckTitle')}</span>
|
||||
{#if wfConnCheck.endpoint_name}
|
||||
<span class="wf-conn-ep">{t('conn.wfCheckEndpoint', { name: wfConnCheck.endpoint_name })}</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="mini-link"
|
||||
disabled={wfAnalyzing}
|
||||
onclick={() => runWfAnalyze(true)}
|
||||
>
|
||||
{wfAnalyzing ? t('conn.wfChecking') : t('conn.wfCheckBtn')}
|
||||
</button>
|
||||
</div>
|
||||
{#if !wfConnCheck.reachable}
|
||||
<p class="wf-conn-line">{wfConnCheck.message ?? t('conn.wfCheckUnreachable')}</p>
|
||||
{:else if wfConnCheck.ok}
|
||||
<p class="wf-conn-line">{t('conn.wfCheckOk')}</p>
|
||||
{:else}
|
||||
<ul class="wf-conn-findings">
|
||||
{#each wfConnCheck.findings as f (f.node_id + f.code)}
|
||||
<li>
|
||||
<span class="wf-conn-msg">{f.message}</span>
|
||||
{#if f.hint}<span class="wf-conn-hint">{f.hint}</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="wf-conn-line">{t('conn.wfCheckSaveHint')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="wf-evidence">
|
||||
{#each wfAnalysis.usage_evidence.summary as line (line)}
|
||||
<li>{line}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="f-field">
|
||||
<span class="f-label">
|
||||
{t('conn.wfUsageLabel')}<em>*</em>
|
||||
<span class="f-hint-inline">
|
||||
{#if wfAnalysis.usage}
|
||||
{t('conn.wfUsageAuto', { usage: usageLabel(wfAnalysis.usage) })}
|
||||
{:else}
|
||||
{t('conn.wfUsageNeedPick')}
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
<div class="seg wrap" role="radiogroup">
|
||||
{#each WORKFLOW_USAGES as u (u)}
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:selected={wfUsage === u}
|
||||
aria-pressed={wfUsage === u}
|
||||
onclick={() => (wfUsage = u)}
|
||||
>
|
||||
{usageLabel(u)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if wfMustPick}<span class="f-error">{t('conn.wfPickUsage')}</span>{/if}
|
||||
</div>
|
||||
|
||||
{#if wfOutputRows.length}
|
||||
<div class="f-field">
|
||||
<span class="f-label">
|
||||
{t('conn.wfOutputLabel')}<em>*</em>
|
||||
<span class="f-hint-inline">{t('conn.wfOutputNeedPick')}</span>
|
||||
</span>
|
||||
<div class="wf-slot-rows">
|
||||
{#each wfOutputRows as row (row.nodeId)}
|
||||
<div class="wf-slot-row" class:off={!row.picked}>
|
||||
<label class="wf-out-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.picked}
|
||||
onchange={(e) =>
|
||||
(wfOutputPicked = {
|
||||
...wfOutputPicked,
|
||||
[row.nodeId]: e.currentTarget.checked,
|
||||
})}
|
||||
/>
|
||||
<span class="wf-slot-id mono">{row.nodeId}</span>
|
||||
<span class="wf-slot-name" title={row.class_type}>{row.label}</span>
|
||||
</label>
|
||||
<select
|
||||
class="a-select"
|
||||
aria-label={t('conn.wfOutputKindAria')}
|
||||
disabled={!row.picked}
|
||||
value={row.kind}
|
||||
onchange={(e) =>
|
||||
(wfOutputKind = {
|
||||
...wfOutputKind,
|
||||
[row.nodeId]: e.currentTarget.value as 'image' | 'video',
|
||||
})}
|
||||
>
|
||||
<option value="image">{t('conn.wfKindImage')}</option>
|
||||
<option value="video">{t('conn.wfKindVideo')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="f-hint">{t('conn.wfOutputHint')}</span>
|
||||
{#if wfOutputPickedCount === 0}
|
||||
<span class="f-error">{t('conn.wfOutputPick')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if wfSlotRows.length}
|
||||
<div class="f-field">
|
||||
<span class="f-label">{t('conn.wfSlotsLabel')}</span>
|
||||
<div class="wf-slot-rows">
|
||||
{#each wfSlotRows as row (row.nodeId)}
|
||||
<div class="wf-slot-row">
|
||||
<span class="wf-slot-id mono">{row.nodeId}</span>
|
||||
<span class="wf-slot-name">{row.label}</span>
|
||||
{#if row.fixed}
|
||||
<span class="tag tag-usage">{t('wfRole.reference_image')}</span>
|
||||
{:else}
|
||||
<select
|
||||
class="a-select"
|
||||
value={row.role}
|
||||
onchange={(e) =>
|
||||
(wfRoleOverrides = {
|
||||
...wfRoleOverrides,
|
||||
[row.nodeId]: e.currentTarget.value,
|
||||
})}
|
||||
>
|
||||
{#each ['image', 'first_frame', 'last_frame', 'reference_image', 'mask'] as r (r)}
|
||||
<option value={r}>{t(`wfRole.${r}`)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="f-hint">{t('conn.wfSlotsHint')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="wf-params">
|
||||
{t('conn.wfParams', { n: wfParamPreview.length })}
|
||||
{#if wfParamPreview.length}
|
||||
<span class="wf-params-list">{wfParamPreview.slice(0, 8).join(' · ')}{wfParamPreview.length > 8 ? ' …' : ''}</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if (wfAnalysis.warnings ?? []).length}
|
||||
<ul class="wf-warnings">
|
||||
{#each wfAnalysis.warnings as w (w.node_id + w.code)}
|
||||
<li class:err={w.level === 'error'}>{w.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={$wfImportMutation.isPending}
|
||||
onclick={() => (wfStep = 'input')}
|
||||
>
|
||||
{t('conn.wfBack')}
|
||||
</Button>
|
||||
<span class="need-test" class:passed={wfSaveable && wfImportName.trim() !== ''}>
|
||||
{wfUsage
|
||||
? wfOutputCandidates.length && wfOutputPickedCount === 0
|
||||
? t('conn.wfOutputPick')
|
||||
: usageLabel(wfUsage)
|
||||
: t('conn.wfUsageNeedPick')}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!wfSaveable || !wfImportName.trim()}
|
||||
loading={$wfImportMutation.isPending}
|
||||
onclick={submitWfImport}
|
||||
>
|
||||
{$wfImportMutation.isPending ? t('conn.wfImporting') : t('conn.wfConfirmSave')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ($workflowsQuery.data ?? []).length === 0}
|
||||
<EmptyState title={t('conn.wfEmpty')} />
|
||||
<p class="missing-note">{t('conn.missingMaterial')}</p>
|
||||
@@ -1592,6 +1104,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<WorkflowImportDrawer
|
||||
bind:open={wfImportOpen}
|
||||
{endpoints}
|
||||
onchanged={() => client.invalidateQueries({ queryKey: ['workflows'] })}
|
||||
/>
|
||||
<WorkflowTestDialog
|
||||
bind:open={testOpen}
|
||||
workflow={testWf}
|
||||
@@ -1640,10 +1157,10 @@
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
/* ---------------- connection cards ---------------- */
|
||||
/* ---------------- connection cards (§3.7 卡片网格) ---------------- */
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.card {
|
||||
@@ -1821,17 +1338,19 @@
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* 次级文字操作走 ghost(HEL-649 v2 §1.2):默认 --text-2,悬停 --text,
|
||||
不用金色文字链接。金色聚焦 outline 由全局 :focus-visible 提供。 */
|
||||
.link-accent {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.link-accent:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.notice {
|
||||
@@ -1899,139 +1418,6 @@
|
||||
padding-left: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.wf-import {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 9px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.wf-step-tag {
|
||||
margin-left: 8px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 7px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wf-note :global(.icon) {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.wf-note.fatal {
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.wf-note.fatal :global(.icon) {
|
||||
color: var(--error);
|
||||
}
|
||||
.wf-note p {
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wf-conn-check {
|
||||
margin: 0 0 12px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 7px;
|
||||
background: var(--surface-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.wf-conn-check.ok {
|
||||
border-color: var(--success);
|
||||
}
|
||||
.wf-conn-check.bad {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.wf-conn-check.warn {
|
||||
border-color: var(--warn);
|
||||
}
|
||||
.wf-conn-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wf-conn-head :global(.icon) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-conn-check.ok .wf-conn-head :global(.icon) {
|
||||
color: var(--success);
|
||||
}
|
||||
.wf-conn-check.bad .wf-conn-head :global(.icon) {
|
||||
color: var(--error);
|
||||
}
|
||||
.wf-conn-check.warn .wf-conn-head :global(.icon) {
|
||||
color: var(--warn);
|
||||
}
|
||||
.wf-conn-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.wf-conn-ep {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.mini-link {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-link:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.wf-conn-line {
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wf-conn-findings {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.wf-conn-findings li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.wf-conn-msg {
|
||||
font-size: 11.5px;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wf-conn-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wf-test-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2066,30 +1452,6 @@
|
||||
.wf-meta-item.recheck.warn .wf-recheck-text {
|
||||
color: var(--warn);
|
||||
}
|
||||
.wf-evidence {
|
||||
margin: 0 0 12px;
|
||||
padding: 0 0 0 4px;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.wf-evidence li {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wf-evidence li::before {
|
||||
content: '·';
|
||||
margin-right: 6px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.seg.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.seg.wrap .seg-item {
|
||||
flex: 1 0 30%;
|
||||
}
|
||||
.wf-slot-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2172,28 +1534,6 @@
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-params-list {
|
||||
margin-left: 6px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wf-warnings {
|
||||
margin: 0 0 10px;
|
||||
padding: 8px 12px;
|
||||
list-style: none;
|
||||
border-radius: 7px;
|
||||
background: var(--error-dim);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.wf-warnings li {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wf-warnings li.err {
|
||||
color: var(--error);
|
||||
}
|
||||
.tag-usage {
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
@@ -2212,67 +1552,6 @@
|
||||
max-width: 150px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.wf-import-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.wf-import-head h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.wf-import-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-import-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 14px;
|
||||
}
|
||||
.wf-import-grid .f-field:nth-child(3) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.wf-json-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.wf-json {
|
||||
min-height: 84px;
|
||||
resize: vertical;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wf-json.invalid {
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.file-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.file-btn input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-table {
|
||||
display: flex;
|
||||
@@ -2373,12 +1652,12 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.target-link {
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.target-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--text);
|
||||
}
|
||||
.target-note {
|
||||
margin: 10px 0 0;
|
||||
@@ -2469,12 +1748,6 @@
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.f-hint-inline {
|
||||
margin-left: auto;
|
||||
font-size: 10.5px;
|
||||
font-weight: 400;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.f-error {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
@@ -2648,5 +1921,4 @@
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
}</style>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
export const GET: RequestHandler = () => {
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
@@ -3,13 +3,15 @@
|
||||
import { libraryApi, type LibraryMediaItem } from '$lib/api';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import AttachToProject from '$lib/components/AttachToProject.svelte';
|
||||
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import Button from '$lib/components/ui/Button.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';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
|
||||
@@ -127,117 +129,129 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppHeader active="library">
|
||||
{#if selectedCount > 0}
|
||||
<Button variant="danger" onclick={() => (confirmingDelete = true)}>
|
||||
<Icon name="close" size={14} />
|
||||
{t('library.deleteSelected', { count: selectedCount })}
|
||||
</Button>
|
||||
{/if}
|
||||
</AppHeader>
|
||||
|
||||
<main class="container">
|
||||
<div class="toolbar">
|
||||
<div class="filters" role="tablist" aria-label={t('library.filterLabel')}>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-chip"
|
||||
class:active={filter === 'all'}
|
||||
aria-pressed={filter === 'all'}
|
||||
onclick={() => (filter = 'all')}
|
||||
>
|
||||
{t('library.filterAll')} <span class="chip-count">{items.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-chip"
|
||||
class:active={filter === 'image'}
|
||||
aria-pressed={filter === 'image'}
|
||||
onclick={() => (filter = 'image')}
|
||||
>
|
||||
{t('library.filterImage')}
|
||||
<span class="chip-count">
|
||||
{items.filter((i) => i.kind === 'image').length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-chip"
|
||||
class:active={filter === 'video'}
|
||||
aria-pressed={filter === 'video'}
|
||||
onclick={() => (filter = 'video')}
|
||||
>
|
||||
{t('library.filterVideo')}
|
||||
<span class="chip-count">
|
||||
{items.filter((i) => i.kind === 'video').length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="select-all">
|
||||
<input type="checkbox" checked={allSelected} onchange={toggleAll} />
|
||||
<span>{allSelected ? t('library.deselectAll') : t('library.selectAll')}</span>
|
||||
</label>
|
||||
<span class="count" data-testid="library-count">
|
||||
<AppShell active="library" title={t('nav.library')} subtitle={t('library.subtitle')}>
|
||||
{#snippet actions()}
|
||||
<span class="status-chip" data-testid="library-count">
|
||||
{t('library.itemCount', { count: filtered.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if $mediaQuery.isLoading}
|
||||
<div class="grid">
|
||||
{#each Array(8) as _, i (i)}
|
||||
<div class="card skeleton-card"><Skeleton height="220px" /></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<EmptyState
|
||||
title={filter === 'all' ? t('library.emptyTitle') : t('library.emptyFilterTitle', { kind: filter === 'image' ? t('library.filterImage') : t('library.filterVideo') })}
|
||||
body={filter === 'all' ? t('library.emptyBody') : t('library.emptyFilterBody')}
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each visible as item (item.path)}
|
||||
<div class="card" class:selected={selected.has(item.path)}>
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.path)}
|
||||
onchange={() => toggle(item.path)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="media"
|
||||
onclick={() => openPreview(item)}
|
||||
title={item.name}
|
||||
>
|
||||
<SafeMedia src={assetUrl(item.path)} kind={item.kind} alt={item.name} controls={false} />
|
||||
</button>
|
||||
<div class="meta">
|
||||
<span class="name" title={item.name}>{item.name}</span>
|
||||
<span class="sub">{item.kind} · {fmtSize(item.size)}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<Button variant="secondary" onclick={() => (attachItem = item)}>
|
||||
{t('library.addToProject')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hasMore}
|
||||
<div class="load-more" bind:this={sentinel} aria-hidden="true"></div>
|
||||
<div class="more-row">
|
||||
{#if loadingMore}
|
||||
<span class="loading-note">{t('library.loadingMore')}</span>
|
||||
{:else}
|
||||
<Button variant="secondary" onclick={loadMore}>
|
||||
{t('library.loadMore', { count: remaining })}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedCount > 0}
|
||||
<Button variant="danger" onclick={() => (confirmingDelete = true)}>
|
||||
<Icon name="close" size={14} />
|
||||
{t('library.deleteSelected', { count: selectedCount })}
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
{/snippet}
|
||||
|
||||
<div class="library">
|
||||
<div class="toolbar">
|
||||
<div class="filters" role="tablist" aria-label={t('library.filterLabel')}>
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip"
|
||||
class:sel={filter === 'all'}
|
||||
aria-pressed={filter === 'all'}
|
||||
onclick={() => (filter = 'all')}
|
||||
>
|
||||
<span class="lbl">{t('library.filterAll')}</span>
|
||||
<span class="chip-count">{items.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip"
|
||||
class:sel={filter === 'image'}
|
||||
aria-pressed={filter === 'image'}
|
||||
onclick={() => (filter = 'image')}
|
||||
>
|
||||
<span class="lbl">{t('library.filterImage')}</span>
|
||||
<span class="chip-count">{items.filter((i) => i.kind === 'image').length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip"
|
||||
class:sel={filter === 'video'}
|
||||
aria-pressed={filter === 'video'}
|
||||
onclick={() => (filter = 'video')}
|
||||
>
|
||||
<span class="lbl">{t('library.filterVideo')}</span>
|
||||
<span class="chip-count">{items.filter((i) => i.kind === 'video').length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="select-all">
|
||||
<input type="checkbox" checked={allSelected} onchange={toggleAll} />
|
||||
<span>{allSelected ? t('library.deselectAll') : t('library.selectAll')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if $mediaQuery.isLoading}
|
||||
<div class="grid" aria-busy="true">
|
||||
{#each Array(8) as _, i (i)}
|
||||
<div class="card skeleton-card"><Skeleton height="220px" /></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if $mediaQuery.isError}
|
||||
<EmptyState title={t('library.loadError')}>
|
||||
{#snippet action()}
|
||||
<Button variant="secondary" onclick={() => $mediaQuery.refetch()}>
|
||||
<Icon name="retry" size={14} />
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else if filtered.length === 0}
|
||||
<EmptyState
|
||||
title={filter === 'all'
|
||||
? t('library.emptyTitle')
|
||||
: t('library.emptyFilterTitle', {
|
||||
kind: filter === 'image' ? t('library.filterImage') : t('library.filterVideo')
|
||||
})}
|
||||
body={filter === 'all' ? t('library.emptyBody') : t('library.emptyFilterBody')}
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each visible as item (item.path)}
|
||||
<div class="card" class:selected={selected.has(item.path)}>
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.path)}
|
||||
onchange={() => toggle(item.path)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="media"
|
||||
onclick={() => openPreview(item)}
|
||||
title={item.name}
|
||||
>
|
||||
<SafeMedia src={assetUrl(item.path)} kind={item.kind} alt={item.name} controls={false} />
|
||||
</button>
|
||||
<div class="meta">
|
||||
<span class="name" title={item.name}>{item.name}</span>
|
||||
<span class="sub">{item.kind} · {fmtSize(item.size)}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<Button variant="secondary" onclick={() => (attachItem = item)}>
|
||||
{t('library.addToProject')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hasMore}
|
||||
<div class="load-more" bind:this={sentinel} aria-hidden="true"></div>
|
||||
<div class="more-row">
|
||||
{#if loadingMore}
|
||||
<span class="loading-note">{t('library.loadingMore')}</span>
|
||||
{:else}
|
||||
<Button variant="secondary" onclick={loadMore}>
|
||||
{t('library.loadMore', { count: remaining })}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
{#if preview}
|
||||
<ImageLightbox
|
||||
@@ -249,118 +263,86 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if attachItem}
|
||||
<div class="modal-backdrop">
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('library.addToProject')}
|
||||
>
|
||||
<div class="modal-head">
|
||||
<strong>{t('library.addToProject')}</strong>
|
||||
<button type="button" class="modal-close" onclick={() => (attachItem = null)}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<p class="modal-file">{attachItem.name}</p>
|
||||
<AttachToProject path={attachItem.path} kind={attachItem.kind} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<Modal
|
||||
open={attachItem !== null}
|
||||
title={t('library.addToProject')}
|
||||
onclose={() => (attachItem = null)}
|
||||
>
|
||||
{#if attachItem}
|
||||
<p class="modal-file">{attachItem.name}</p>
|
||||
<AttachToProject path={attachItem.path} kind={attachItem.kind} />
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
{#if confirmingDelete}
|
||||
<div class="modal-backdrop" role="presentation">
|
||||
<div class="modal confirm" role="alertdialog" aria-modal="true">
|
||||
<p>{t('library.confirmDelete', { count: selectedCount })}</p>
|
||||
<div class="modal-actions">
|
||||
<Button variant="danger" loading={$deleteMutation.isPending} onclick={deleteSelected}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={() => (confirmingDelete = false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<ConfirmDialog
|
||||
open={confirmingDelete}
|
||||
title={t('library.deleteSelected', { count: selectedCount })}
|
||||
message={t('library.confirmDelete', { count: selectedCount })}
|
||||
confirmLabel={t('common.delete')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger={true}
|
||||
onconfirm={deleteSelected}
|
||||
oncancel={() => (confirmingDelete = false)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.library {
|
||||
padding: 20px 28px 40px;
|
||||
max-width: 1560px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
gap: var(--sp-6);
|
||||
margin-bottom: var(--sp-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.filter-chip:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.filter-chip.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.filter-chip:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
gap: var(--sp-3);
|
||||
font-size: var(--fs-body-sm);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.count {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
.select-all input {
|
||||
accent-color: var(--accent-lo);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease;
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.card.selected {
|
||||
border-color: var(--accent);
|
||||
border-color: var(--accent-line);
|
||||
background: linear-gradient(180deg, var(--accent-soft), transparent 32%), var(--bg-2);
|
||||
}
|
||||
.card :global(.media-el) {
|
||||
object-fit: cover;
|
||||
@@ -370,22 +352,22 @@
|
||||
}
|
||||
.check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
top: var(--sp-3);
|
||||
left: var(--sp-3);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--media-scrim);
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.check input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--accent);
|
||||
accent-color: var(--accent-lo);
|
||||
cursor: pointer;
|
||||
}
|
||||
.media {
|
||||
@@ -394,7 +376,7 @@
|
||||
height: 150px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
background: var(--media-scrim);
|
||||
cursor: zoom-in;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -408,22 +390,22 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 12px 6px;
|
||||
padding: var(--sp-4) var(--sp-5) var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--fs-body);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.card-actions {
|
||||
padding: 8px 12px 12px;
|
||||
padding: var(--sp-3) var(--sp-5) var(--sp-5);
|
||||
}
|
||||
.load-more {
|
||||
height: 1px;
|
||||
@@ -431,65 +413,28 @@
|
||||
.more-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 18px 0 6px;
|
||||
padding: var(--sp-6) 0 var(--sp-2);
|
||||
}
|
||||
.loading-note {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: 20px;
|
||||
}
|
||||
.modal {
|
||||
width: min(420px, 100%);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
}
|
||||
.modal.confirm {
|
||||
width: min(360px, 100%);
|
||||
}
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-close:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
font-size: var(--fs-body-sm);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.modal-file {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.library {
|
||||
padding: var(--sp-5) var(--sp-5) var(--sp-8);
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,2 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export function load() {
|
||||
redirect(308, '/');
|
||||
}
|
||||
|
||||
@@ -1097,11 +1097,13 @@
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.link-accent:hover {
|
||||
color: var(--text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* ---------------- notice bar (§9.5) ---------------- */
|
||||
@@ -1414,7 +1416,7 @@
|
||||
overflow-y: auto;
|
||||
transform: translateX(100%);
|
||||
transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow: -24px 0 64px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
.form-col.open {
|
||||
transform: translateX(0);
|
||||
@@ -1437,7 +1439,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
@media (max-width: 760px) {
|
||||
.page {
|
||||
padding: 16px 16px 32px;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,6 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
.redirect-error {
|
||||
color: #f87171;
|
||||
color: var(--err);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import NavRail from '$lib/components/NavRail.svelte';
|
||||
import StoryStage from '$lib/components/StoryStage.svelte';
|
||||
import AssetsStage from '$lib/components/AssetsStage.svelte';
|
||||
import ScriptStage from '$lib/components/ScriptStage.svelte';
|
||||
import QueueStage from '$lib/components/QueueStage.svelte';
|
||||
import ActivityPanel from '$lib/components/ActivityPanel.svelte';
|
||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import StatusChip from '$lib/components/ui/StatusChip.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { jobsApi, projects, settings } from '$lib/api';
|
||||
import { connectEvents, type CalliopeEvent, type EventConnectionState } from '$lib/events';
|
||||
import { handleJobEvent } from '$lib/jobProgress';
|
||||
|
||||
interface Props {
|
||||
data: { id: string };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
const client = useQueryClient();
|
||||
const projectId = $derived(Number(data.id));
|
||||
const enabled = $derived(!isNaN(projectId));
|
||||
|
||||
// Stage <-> URL contract: the active stage lives in ?stage=story|assets|script|video.
|
||||
// Switching stages = goto('?stage=<id>'); deep links and browser Back/Forward work.
|
||||
const STAGES = ['story', 'assets', 'script', 'video'] as const;
|
||||
type Stage = (typeof STAGES)[number];
|
||||
|
||||
function stageFromUrl(url: URL): Stage {
|
||||
const s = url.searchParams.get('stage');
|
||||
return (STAGES as readonly string[]).includes(s ?? '') ? (s as Stage) : 'story';
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<Stage, string> = {
|
||||
story: '1 · Story',
|
||||
assets: '2 · Assets',
|
||||
script: '3 · Script',
|
||||
video: '4 · Video',
|
||||
};
|
||||
|
||||
let activeTab = $state<Stage>(stageFromUrl(page.url));
|
||||
let activityCollapsed = $state(false);
|
||||
let logs = $state<CalliopeEvent[]>([]);
|
||||
let connState = $state<EventConnectionState>('connecting');
|
||||
|
||||
// URL → state: deep links, browser Back/Forward
|
||||
$effect(() => {
|
||||
const s = stageFromUrl(page.url);
|
||||
if (s !== activeTab) activeTab = s;
|
||||
});
|
||||
|
||||
// State → URL: every stage switch goes through goto so history works
|
||||
function selectStage(id: string) {
|
||||
const stage: Stage = (STAGES as readonly string[]).includes(id) ? (id as Stage) : 'story';
|
||||
if (stage === activeTab) return;
|
||||
activeTab = stage;
|
||||
goto(`?stage=${stage}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
const storyQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['story', projectId],
|
||||
queryFn: () => projects.getStory(projectId),
|
||||
enabled,
|
||||
})),
|
||||
);
|
||||
|
||||
const settingsQuery = createQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: settings.get,
|
||||
});
|
||||
|
||||
// Same query keys as QueueStage/ActivityPanel — cache is shared.
|
||||
// Refreshes are SSE-driven (job.* events + events.resync below), no polling.
|
||||
const jobsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['jobs', projectId],
|
||||
queryFn: () => jobsApi.list(projectId),
|
||||
enabled,
|
||||
})),
|
||||
);
|
||||
|
||||
const queueStatusQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['queue-status'],
|
||||
queryFn: jobsApi.queueStatus,
|
||||
enabled,
|
||||
})),
|
||||
);
|
||||
|
||||
const isConfigured = $derived(
|
||||
Boolean($settingsQuery.data?.llm_base_url && $settingsQuery.data?.llm_model),
|
||||
);
|
||||
const projectTitle = $derived($storyQuery.data?.project?.title ?? 'Project');
|
||||
|
||||
const runningCount = $derived(
|
||||
($jobsQuery.data ?? []).filter((j) => j.status === 'running').length,
|
||||
);
|
||||
const queuedCount = $derived(
|
||||
($jobsQuery.data ?? []).filter((j) => j.status === 'pending').length,
|
||||
);
|
||||
const queuePaused = $derived(Boolean($queueStatusQuery.data?.paused));
|
||||
|
||||
onMount(() => {
|
||||
return connectEvents(
|
||||
(ev) => {
|
||||
if (ev.type === 'events.resync') {
|
||||
// SSE reconnected: refill anything missed while the stream was down.
|
||||
client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
client.invalidateQueries({ queryKey: ['queue-status'] });
|
||||
client.invalidateQueries({ queryKey: ['assets'] });
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
return;
|
||||
}
|
||||
// Live per-job progress for ProgressBars (Activity log stays clean)
|
||||
handleJobEvent(ev);
|
||||
// Don't keep Comfy poll ticks in the Activity log
|
||||
if (ev.type !== 'job.progress') {
|
||||
logs = [...logs, ev].slice(-120);
|
||||
}
|
||||
if (
|
||||
ev.type.startsWith('job.') ||
|
||||
ev.type === 'asset.ready' ||
|
||||
ev.type === 'agent.thinking' ||
|
||||
ev.type.startsWith('story.')
|
||||
) {
|
||||
client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
client.invalidateQueries({ queryKey: ['queue-status'] });
|
||||
client.invalidateQueries({ queryKey: ['assets'] });
|
||||
client.invalidateQueries({ queryKey: ['story'] });
|
||||
client.invalidateQueries({ queryKey: ['scenes'] });
|
||||
}
|
||||
},
|
||||
(state) => (connState = state),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<AppHeader crumb={`· ${projectTitle}`} />
|
||||
|
||||
{#if !enabled}
|
||||
<div class="invalid-wrap">
|
||||
<EmptyState
|
||||
title={t('project.notFound')}
|
||||
body={t('project.invalidIdBody', { id: data.id })}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Icon name="folder" size={28} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button variant="primary" onclick={() => goto('/projects')}>{t('project.backToProjects')}</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="workspace-bar">
|
||||
<span class="stage-label">{STAGE_LABELS[activeTab]}</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title={t('project.openVideoStage')}
|
||||
onclick={() => selectStage('video')}
|
||||
>
|
||||
<Icon name="queue" size={13} />
|
||||
{runningCount + queuedCount > 0
|
||||
? t('project.runningQueued', { running: runningCount, queued: queuedCount })
|
||||
: t('project.queueIdle')}
|
||||
{#if queuePaused}
|
||||
<StatusChip status="paused" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="workspace">
|
||||
<NavRail active={activeTab} onSelect={selectStage} />
|
||||
|
||||
<main class="stage" class:stage-fill={activeTab === 'video'}>
|
||||
{#if activeTab === 'story'}
|
||||
{#if $storyQuery.isLoading}
|
||||
<div class="card skel" aria-busy="true">
|
||||
<Skeleton width="36%" height="22px" />
|
||||
<Skeleton width="88%" />
|
||||
<Skeleton width="72%" />
|
||||
<Skeleton height="140px" />
|
||||
</div>
|
||||
{:else if $storyQuery.isError}
|
||||
<div class="card" style="color: var(--error)">
|
||||
Failed to load story: {$storyQuery.error?.message ?? 'unknown error'}
|
||||
</div>
|
||||
{:else if $storyQuery.data}
|
||||
<StoryStage
|
||||
{projectId}
|
||||
story={$storyQuery.data}
|
||||
configured={isConfigured}
|
||||
onGoAssets={() => selectStage('assets')}
|
||||
/>
|
||||
{/if}
|
||||
{:else if activeTab === 'assets'}
|
||||
<AssetsStage {projectId} />
|
||||
{:else if activeTab === 'script'}
|
||||
<ScriptStage {projectId} />
|
||||
{:else if activeTab === 'video'}
|
||||
<QueueStage {projectId} {projectTitle} />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<ActivityPanel
|
||||
{logs}
|
||||
{projectId}
|
||||
{connState}
|
||||
collapsed={activityCollapsed}
|
||||
onToggle={() => (activityCollapsed = !activityCollapsed)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.invalid-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-lg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.workspace-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
padding: 8px var(--space-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stage-label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.workspace {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stage {
|
||||
flex: 1;
|
||||
padding: var(--space-lg);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
.stage.stage-fill {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
/* Bleed to column edges — Nav/Activity already frame the stage.
|
||||
Old max-width+margin:auto looked off-center because this column
|
||||
sits between unequal side rails. */
|
||||
padding: 12px 0 12px;
|
||||
}
|
||||
.stage.stage-fill > :global(.queue-root) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
/* Player hero breaks out to full stage width; strip/composer keep inset */
|
||||
.stage.stage-fill :global(.hero) {
|
||||
margin-inline: -16px;
|
||||
width: auto;
|
||||
}
|
||||
.stage.stage-fill :global(.hero .frame) {
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
.skel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,458 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { projects, type Project } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ProjectCard from '$lib/components/ProjectCard.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
const projectsQuery = createQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: projects.list,
|
||||
});
|
||||
|
||||
const createProjectMutation = createMutation({
|
||||
mutationFn: projects.create,
|
||||
onSuccess: (data: Project) => {
|
||||
client.invalidateQueries({ queryKey: ['projects'] });
|
||||
toast.success(t('projects.created', { title: data.title }));
|
||||
goto(`/project/${data.id}`);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : t('projects.createFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
// English values are sent to the backend; display labels come from the dictionary.
|
||||
const GENRES: 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 TONES: Record<string, string> = {
|
||||
'Cinematic, atmospheric': 'tones.cinematic',
|
||||
'Dark, tense': 'tones.dark',
|
||||
'Whimsical, warm': 'tones.whimsical',
|
||||
'Gritty, realistic': 'tones.gritty',
|
||||
'Epic, sweeping': 'tones.epic',
|
||||
};
|
||||
const DURATIONS: Record<string, string> = {
|
||||
'30 seconds': 'durations.30',
|
||||
'1 minute': 'durations.1m',
|
||||
'2 minutes': 'durations.2m',
|
||||
'5 minutes': 'durations.5m',
|
||||
'10 minutes': 'durations.10m',
|
||||
};
|
||||
|
||||
let title = $state('');
|
||||
let idea = $state('');
|
||||
let genre = $state('Adventure / Mystery');
|
||||
let tone = $state('Cinematic, atmospheric');
|
||||
let duration = $state('2 minutes');
|
||||
let showForm = $state(false);
|
||||
let search = $state('');
|
||||
let filter = $state('all');
|
||||
|
||||
function onCreate(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
$createProjectMutation.mutate({
|
||||
title: title.trim(),
|
||||
idea: idea.trim() || undefined,
|
||||
genre: genre || undefined,
|
||||
tone: tone || undefined,
|
||||
target_duration: duration || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function openForm() {
|
||||
showForm = true;
|
||||
title = '';
|
||||
idea = '';
|
||||
genre = 'Adventure / Mystery';
|
||||
tone = 'Cinematic, atmospheric';
|
||||
duration = '2 minutes';
|
||||
}
|
||||
|
||||
const FILTERS = $derived(
|
||||
[
|
||||
{ id: 'all', key: 'projects.filterAll' },
|
||||
{ id: 'in_progress', key: 'projects.filterProgress' },
|
||||
{ id: 'completed', key: 'projects.filterCompleted' },
|
||||
{ id: 'draft', key: 'projects.filterDraft' },
|
||||
].map((f) => ({ id: f.id, label: t(f.key) })),
|
||||
);
|
||||
|
||||
const all = $derived($projectsQuery.data ?? []);
|
||||
const filtered = $derived.by(() => {
|
||||
let list = all;
|
||||
if (filter !== 'all') list = list.filter((p) => p.status === filter);
|
||||
const q = search.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(p) => p.title.toLowerCase().includes(q) || (p.idea ?? '').toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function clearFilters() {
|
||||
search = '';
|
||||
filter = 'all';
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppHeader active="projects">
|
||||
<Button variant="primary" onclick={openForm}>
|
||||
<Icon name="plus" size={15} />
|
||||
{t('projects.new')}
|
||||
</Button>
|
||||
</AppHeader>
|
||||
|
||||
<main class="container">
|
||||
<div class="hero">
|
||||
<div>
|
||||
<h1>{t('projects.title')}</h1>
|
||||
<p>{t('projects.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<form class="new-project-card" onsubmit={onCreate}>
|
||||
<div class="form-head">
|
||||
<div>
|
||||
<p class="eyebrow">{t('projects.newReel')}</p>
|
||||
<h3>{t('projects.new')}</h3>
|
||||
</div>
|
||||
<Button variant="ghost" onclick={() => (showForm = false)}>{t('projects.close')}</Button>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.titleField')}</span>
|
||||
<input class="field-input" bind:value={title} placeholder={t('projects.titlePlaceholder')} required />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.ideaField')}</span>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
bind:value={idea}
|
||||
placeholder={t('projects.ideaPlaceholder')}
|
||||
rows={4}
|
||||
></textarea>
|
||||
<p class="field-hint">{t('projects.ideaHint')}</p>
|
||||
</label>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.genre')}</span>
|
||||
<select class="field-select" bind:value={genre}>
|
||||
{#each Object.keys(GENRES) as v (v)}
|
||||
<option value={v}>{t(GENRES[v])}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.tone')}</span>
|
||||
<select class="field-select" bind:value={tone}>
|
||||
{#each Object.keys(TONES) as v (v)}
|
||||
<option value={v}>{t(TONES[v])}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('projects.duration')}</span>
|
||||
<select class="field-select" bind:value={duration}>
|
||||
{#each Object.keys(DURATIONS) as v (v)}
|
||||
<option value={v}>{t(DURATIONS[v])}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="field-hint">{t('projects.durationHint')}</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<Button variant="secondary" onclick={() => (showForm = false)}>{t('projects.cancel')}</Button>
|
||||
<Button variant="primary" type="submit" loading={$createProjectMutation.isPending}>
|
||||
{t('projects.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="toolbar">
|
||||
<input
|
||||
class="search field-input"
|
||||
bind:value={search}
|
||||
placeholder={t('projects.searchPlaceholder')}
|
||||
aria-label={t('projects.searchLabel')}
|
||||
/>
|
||||
<div class="filter-group" role="group" aria-label={t('projects.filterBy')}>
|
||||
{#each FILTERS as f (f.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="pill"
|
||||
class:active={filter === f.id}
|
||||
aria-pressed={filter === f.id}
|
||||
onclick={() => (filter = f.id)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $projectsQuery.isLoading}
|
||||
<div class="grid" aria-busy="true" aria-label={t('projects.loading')}>
|
||||
{#each [1, 2, 3, 4, 5, 6] as n (n)}
|
||||
<div class="skel-card">
|
||||
<Skeleton height="170px" />
|
||||
<div class="skel-body">
|
||||
<Skeleton width="60%" height="16px" />
|
||||
<Skeleton />
|
||||
<Skeleton width="80%" />
|
||||
<Skeleton width="40%" height="12px" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if $projectsQuery.isError}
|
||||
<EmptyState
|
||||
title={t('projects.loadError')}
|
||||
body={$projectsQuery.error instanceof Error
|
||||
? $projectsQuery.error.message
|
||||
: t('projects.loadErrorBody')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Icon name="alert" size={28} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button variant="primary" onclick={() => $projectsQuery.refetch()}>
|
||||
<Icon name="retry" size={15} />
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else if all.length === 0}
|
||||
<EmptyState title={t('projects.emptyTitle')} body={t('projects.emptyBody')}>
|
||||
{#snippet icon()}
|
||||
<Icon name="video" size={28} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button variant="primary" onclick={openForm}>
|
||||
<Icon name="plus" size={15} />
|
||||
{t('projects.firstProject')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else if filtered.length === 0}
|
||||
<EmptyState
|
||||
title={search.trim()
|
||||
? t('projects.noMatchSearch', { q: search.trim() })
|
||||
: t('projects.noMatchFilter')}
|
||||
body={t('projects.noMatchBody')}
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Icon name="search" size={28} />
|
||||
{/snippet}
|
||||
{#snippet action()}
|
||||
<Button variant="secondary" onclick={clearFilters}>{t('projects.clearFilters')}</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each filtered as project (project.id)}
|
||||
<ProjectCard {project} />
|
||||
{/each}
|
||||
<button type="button" class="new-card" onclick={openForm}>
|
||||
<div class="new-card-icon"><Icon name="plus" size={24} /></div>
|
||||
<div class="new-card-title">{t('projects.newCard')}</div>
|
||||
<div class="new-card-sub">{t('projects.newCardSub')}</div>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0 0 var(--space-sm);
|
||||
font-size: 32px;
|
||||
}
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
}
|
||||
.new-project-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: var(--space-xl);
|
||||
box-shadow: 0 0 0 1px rgba(139, 92, 246, 0.08);
|
||||
}
|
||||
.form-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 4px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.new-project-card h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
}
|
||||
.filter-group {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.pill {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.pill:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.pill.active {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.pill:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
.skel-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.skel-card :global(.skeleton) {
|
||||
border-radius: 0;
|
||||
}
|
||||
.skel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
.skel-body :global(.skeleton) {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.new-card {
|
||||
border: 2px dashed var(--border);
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
color: var(--text-muted);
|
||||
gap: var(--space-md);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.new-card:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
background: rgba(139, 92, 246, 0.04);
|
||||
}
|
||||
.new-card:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.new-card-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.new-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.new-card-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -701,7 +701,7 @@
|
||||
}
|
||||
|
||||
/* ---------------- responsive (§4.5) ---------------- */
|
||||
@media (max-width: 1023px) {
|
||||
@media (max-width: 760px) {
|
||||
.page {
|
||||
flex-direction: column;
|
||||
padding: 18px;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import {
|
||||
apiErrorMessage,
|
||||
assetUrl,
|
||||
jobsApi,
|
||||
playgroundApi,
|
||||
@@ -14,16 +13,21 @@
|
||||
} from '$lib/api';
|
||||
import { connectEvents } from '$lib/events';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { handleJobEvent } from '$lib/jobProgress';
|
||||
import { handleJobEvent, progressFor } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { isFileMissing, markOutputMissing } from '$lib/missingOutputs.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { isStandaloneJob, isWorkflowTestJob, jobSourceLabel } from '$lib/jobLabels';
|
||||
import { createTakeDeleteQueue } from '$lib/components/generate/takeDelete.svelte';
|
||||
|
||||
/**
|
||||
* 生成任务页 (spec-v2 §3 nav item, HEL-612): one table summing both
|
||||
* 生成任务页 (spec-v2 §3.5, HEL-612/654 D 批): one table summing both
|
||||
* task classes — image and video — from standalone windows, the
|
||||
* creation flow, and Playground, with retry / cancel / delete.
|
||||
* creation flow, and Playground. Filter chips + search; running rows
|
||||
* carry a 2px progress bar; 重试 is a ghost text op (spec v2 §1.2);
|
||||
* deleting one task is record-level: 200ms collapse + 5s undo Toast,
|
||||
* no dialog. The real DELETE only fires after the undo window.
|
||||
*/
|
||||
const client = useQueryClient();
|
||||
|
||||
@@ -46,13 +50,27 @@
|
||||
return off;
|
||||
});
|
||||
|
||||
type Filter = 'all' | 'image' | 'video';
|
||||
type Filter = 'all' | 'image' | 'video' | 'failed' | 'active';
|
||||
let filter = $state<Filter>('all');
|
||||
let search = $state('');
|
||||
|
||||
const genJobs = $derived(
|
||||
($jobsQuery.data ?? []).filter((j) => j.kind === 'image' || j.kind === 'video'),
|
||||
);
|
||||
const shown = $derived(filter === 'all' ? genJobs : genJobs.filter((j) => j.kind === filter));
|
||||
const deleteQueue = createTakeDeleteQueue(client);
|
||||
const pendingIds = deleteQueue.pending;
|
||||
$effect(() => deleteQueue.destroy);
|
||||
|
||||
const genJobs = $derived(($jobsQuery.data ?? []).filter((j) => j.kind === 'image' || j.kind === 'video'));
|
||||
const shown = $derived.by(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return genJobs.filter((j) => {
|
||||
if (pendingIds.has(j.id)) return false;
|
||||
if (filter === 'image' || filter === 'video') {
|
||||
if (j.kind !== filter) return false;
|
||||
} else if (filter === 'failed' && jobStatusInfo(j).tone !== 'fail') return false;
|
||||
else if (filter === 'active' && j.status !== 'pending' && j.status !== 'running') return false;
|
||||
if (!q) return true;
|
||||
return `${wfName(j)} ${jobSourceLabel(j)} ${j.error ?? ''} ${j.id}`.toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
const activeCount = $derived(genJobs.filter((j) => j.status === 'pending' || j.status === 'running').length);
|
||||
|
||||
function wfName(job: Job): string {
|
||||
@@ -76,6 +94,35 @@
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 2px 进度条贴行底 — real progress from the SSE store only. */
|
||||
function rowProgress(job: Job): number {
|
||||
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;
|
||||
}
|
||||
|
||||
function toneOf(job: Job, base: string): string {
|
||||
if (base === 'ok' && isFileMissing(job)) return 'warn';
|
||||
if (base === 'queue') return '';
|
||||
if (base === 'fail') return 'err';
|
||||
return base;
|
||||
}
|
||||
|
||||
function labelOf(job: Job, base: { tone: string; label: string }): string {
|
||||
return base.tone === 'ok' && isFileMissing(job) ? t('jobst.missing') : base.label;
|
||||
}
|
||||
|
||||
function requestDelete(job: Job) {
|
||||
if (job.status === 'pending' || job.status === 'running') return;
|
||||
deleteQueue.request(job, {
|
||||
message: () =>
|
||||
(job.output_paths?.length ?? 0) > 0
|
||||
? t('studio.deletedTakeToast', { name: wfName(job) })
|
||||
: t('studio.deletedTakeNoFilesToast', { name: wfName(job) }),
|
||||
});
|
||||
}
|
||||
|
||||
let busyId = $state<number | null>(null);
|
||||
async function retry(job: Job) {
|
||||
@@ -86,7 +133,7 @@
|
||||
toast.success(t('studio.retryQueuedToast'));
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(t('studio.retryFailedToast', { detail: err instanceof Error ? err.message : String(err) }));
|
||||
toast.error(t('studio.retryFailedToast', { detail: apiErrorMessage(err) }));
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
@@ -100,28 +147,11 @@
|
||||
toast.info(t('studio.cancelToast'));
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
let deleteOpen = $state(false);
|
||||
let deleteJobId = $state<number | null>(null);
|
||||
const deleteTarget = $derived(genJobs.find((j) => j.id === deleteJobId) ?? null);
|
||||
async function doDelete() {
|
||||
if (!deleteJobId) return;
|
||||
try {
|
||||
await playgroundApi.deleteJob(deleteJobId);
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
deleteJobId = null;
|
||||
deleteOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppShell active="tasks" title={t('tasks.title')} subtitle={t('tasks.subtitle')}>
|
||||
@@ -130,48 +160,61 @@
|
||||
{/snippet}
|
||||
|
||||
<div class="tasks-page">
|
||||
<div class="seg" role="radiogroup" aria-label={t('tasks.title')}>
|
||||
{#each [['all', t('tasks.filterAll')], ['image', t('tasks.filterImage')], ['video', t('tasks.filterVideo')]] as [value, label] (value)}
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:selected={filter === value}
|
||||
aria-pressed={filter === value}
|
||||
onclick={() => (filter = value as Filter)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="toolbar">
|
||||
<div class="chips" role="group" aria-label={t('tasks.title')}>
|
||||
{#each [['all', t('tasks.filterAll')], ['image', t('tasks.filterImage')], ['video', t('tasks.filterVideo')], ['failed', t('tasks.filterFailed')], ['active', t('tasks.filterActive')]] as [value, label] (value)}
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip fchip"
|
||||
class:sel={filter === value}
|
||||
aria-pressed={filter === value}
|
||||
onclick={() => (filter = value as Filter)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="search">
|
||||
<Icon name="search" size={13} />
|
||||
<input type="search" bind:value={search} placeholder={t('tasks.searchPh')} aria-label={t('tasks.searchPh')} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if !shown.length}
|
||||
<div class="empty-card">
|
||||
<Icon name="queue" size={22} />
|
||||
<p>{t('tasks.empty')}</p>
|
||||
<p>{search || filter !== 'all' ? t('tasks.emptyFiltered') : t('tasks.empty')}</p>
|
||||
<a class="a-btn primary" href="/image">{t('tasks.goGenerate')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="task-table">
|
||||
<div class="task-table" role="table" aria-label={t('tasks.title')}>
|
||||
{#each shown as job (job.id)}
|
||||
{@const st = jobStatusInfo(job)}
|
||||
<div class="trow" class:failed={job.status === 'failed'}>
|
||||
{@const progress = rowProgress(job)}
|
||||
<div class="trow" class:failed={st.tone === 'fail'} class:running={st.tone === 'run'}>
|
||||
<span class="tkind {job.kind}">
|
||||
<Icon name={job.kind === 'image' ? 'image' : 'film'} size={14} />
|
||||
</span>
|
||||
<div class="tthumb">
|
||||
{#if job.output_paths?.[0]}
|
||||
{#if job.kind === 'image'}
|
||||
<img src={assetUrl(job.output_paths[0])} alt={wfName(job)} loading="lazy" />
|
||||
<img
|
||||
src={assetUrl(job.output_paths[0])}
|
||||
alt={wfName(job)}
|
||||
loading="lazy"
|
||||
onerror={() => job.output_paths[0] && markOutputMissing(job.output_paths[0])}
|
||||
/>
|
||||
{:else}
|
||||
<video src={assetUrl(job.output_paths[0])} preload="metadata" muted></video>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tmain">
|
||||
<div class="tname" title={wfName(job)}>
|
||||
{#if isWorkflowTestJob(job)}<span class="tag tag-test">{t('tasks.testBadge')}</span>{/if}
|
||||
{wfName(job)}
|
||||
</div>
|
||||
<div class="tmain">
|
||||
<div class="tname" title={wfName(job)}>
|
||||
{#if isWorkflowTestJob(job)}<span class="tag-test">{t('tasks.testBadge')}</span>{/if}
|
||||
{wfName(job)}
|
||||
{#if job.workflow_id == null}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
|
||||
</div>
|
||||
<div class="tmeta mono">
|
||||
{#if projectOf(job)}{projectOf(job)} · {/if}{jobSourceLabel(job)} · {timeOf(job)}
|
||||
{#if (job.output_paths?.length ?? 0) > 0}
|
||||
@@ -182,13 +225,21 @@
|
||||
<div class="terror">{job.error}</div>
|
||||
{:else if st.tone === 'fail' || (job.status === 'done' && job.phase === 'dry_run')}
|
||||
<div class="terror">{st.detail}</div>
|
||||
{:else if st.tone === 'ok' && isFileMissing(job)}
|
||||
<div class="twarn">{t('jobst.missingDetail')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tout">
|
||||
{#if job.output_paths?.length}
|
||||
<div class="tout-thumbs">
|
||||
{#each job.output_paths.slice(0, 3) as p (p)}
|
||||
<a href={assetUrl(p)} target="_blank" rel="noreferrer" title={p.split(/[/\\]/).pop()}>
|
||||
{#each job.output_paths.slice(0, 4) as p (p)}
|
||||
<a
|
||||
href={assetUrl(p)}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t('tasks.download')}
|
||||
>
|
||||
{#if job.kind === 'image'}
|
||||
<img src={assetUrl(p)} alt="" loading="lazy" />
|
||||
{:else}
|
||||
@@ -199,32 +250,37 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="tst {st.tone}" title={st.detail}>{st.label}</span>
|
||||
<span class="a-tag tst {toneOf(job, st.tone)}" title={st.detail}>{labelOf(job, st)}</span>
|
||||
<span class="tactions">
|
||||
{#if job.status === 'failed'}
|
||||
<button type="button" class="link-accent" disabled={busyId === job.id} onclick={() => retry(job)}>
|
||||
{#if (st.tone === 'fail' || (st.tone === 'ok' && isFileMissing(job))) && job.error !== 'cancelled'}
|
||||
<button type="button" class="link-ghost" disabled={busyId === job.id} onclick={() => retry(job)}>
|
||||
{t('tasks.retry')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if job.status === 'pending' || job.status === 'running'}
|
||||
<button type="button" class="link-danger" disabled={busyId === job.id} onclick={() => cancel(job)}>
|
||||
<button type="button" class="link-ghost" disabled={busyId === job.id} onclick={() => cancel(job)}>
|
||||
{t('tasks.cancel')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if isStandaloneJob(job)}
|
||||
{#if job.output_paths?.length}
|
||||
<a class="link-ghost" href={assetUrl(job.output_paths[0])} download>
|
||||
{t('tasks.download')}
|
||||
</a>
|
||||
{/if}
|
||||
{#if isStandaloneJob(job) && job.status !== 'pending' && job.status !== 'running'}
|
||||
<button
|
||||
type="button"
|
||||
class="link-danger"
|
||||
title={t('tasks.delete')}
|
||||
onclick={() => {
|
||||
deleteJobId = job.id;
|
||||
deleteOpen = true;
|
||||
}}
|
||||
onclick={() => requestDelete(job)}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{#if progress > 0}
|
||||
<div class="rowbar" style:--p={progress / 100}><i></i></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -232,19 +288,6 @@
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t('tasks.delTitle')}
|
||||
message={t('tasks.delConfirm')}
|
||||
confirmLabel={t('tasks.delete')}
|
||||
danger
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => {
|
||||
deleteJobId = null;
|
||||
deleteOpen = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.tasks-page {
|
||||
padding: 20px 28px 40px;
|
||||
@@ -254,30 +297,52 @@
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
.seg {
|
||||
/* §3.5: 筛选芯片组(选中 = 金色文字 + accent-soft 底)+ 右侧搜索框 */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: fit-content;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.seg-item {
|
||||
padding: 7px 18px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
.fchip {
|
||||
height: 28px;
|
||||
}
|
||||
.seg-item.selected {
|
||||
.search {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.search:focus-within {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.search input {
|
||||
min-width: 0;
|
||||
width: 180px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.search input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
@@ -286,8 +351,8 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 56px 24px;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 10px;
|
||||
border: 1.5px dashed var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.empty-card p {
|
||||
@@ -296,24 +361,49 @@
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* 表格行:行底 --bg-2,悬停 --bg-3,进行中行底贴 2px 青进度条 */
|
||||
.task-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.trow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
padding: 10px 14px 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-2);
|
||||
min-height: 56px;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.trow:hover {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
}
|
||||
.trow.failed {
|
||||
border-color: color-mix(in srgb, var(--err) 35%, transparent);
|
||||
}
|
||||
.rowbar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 var(--r-md) var(--r-md);
|
||||
}
|
||||
.rowbar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transform-origin: left;
|
||||
transform: scaleX(var(--p));
|
||||
background: var(--run-grad);
|
||||
transition: transform var(--d-progress) linear;
|
||||
}
|
||||
|
||||
.tkind {
|
||||
width: 28px;
|
||||
@@ -323,9 +413,6 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tkind.image,
|
||||
.tkind.video {
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
@@ -335,8 +422,8 @@
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tthumb img,
|
||||
@@ -363,6 +450,9 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tname .wf-gone {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tmeta {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
@@ -371,7 +461,13 @@
|
||||
.terror {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
color: var(--err);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.twarn {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--warn);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -383,11 +479,11 @@
|
||||
gap: 4px;
|
||||
}
|
||||
.tout-thumbs a {
|
||||
width: 44px;
|
||||
height: 30px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--line);
|
||||
display: block;
|
||||
}
|
||||
.tout-thumbs img,
|
||||
@@ -399,45 +495,41 @@
|
||||
}
|
||||
|
||||
.tst {
|
||||
width: 52px;
|
||||
text-align: right;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
width: 64px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tst.ok {
|
||||
color: var(--success);
|
||||
}
|
||||
.tst.run {
|
||||
color: var(--run);
|
||||
}
|
||||
.tst.queue {
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
.tst.fail {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* §1.2 ghost:次级文字操作(重试/取消/下载)文字 --text-2,悬停 --text */
|
||||
.tactions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.link-accent {
|
||||
.link-ghost {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.link-accent:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.link-ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
.link-accent:disabled {
|
||||
.link-ghost:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.link-ghost:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -447,16 +539,22 @@
|
||||
padding: 4px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
color: var(--err);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
opacity: 0.85;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.link-danger:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
background: var(--error-dim);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.link-danger:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
background: var(--err-soft);
|
||||
}
|
||||
}
|
||||
.link-danger:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.link-danger:disabled {
|
||||
opacity: 0.35;
|
||||
@@ -464,26 +562,43 @@
|
||||
}
|
||||
.tag-test {
|
||||
flex-shrink: 0;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
background: var(--bg-4);
|
||||
color: var(--text-2);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* 窄屏:表格转卡片堆叠 */
|
||||
@media (max-width: 1023px) {
|
||||
.tasks-page {
|
||||
padding: 14px 16px 32px;
|
||||
}
|
||||
.tout {
|
||||
display: none;
|
||||
}
|
||||
.trow {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
.tout {
|
||||
order: 5;
|
||||
width: 100%;
|
||||
}
|
||||
.tactions {
|
||||
order: 6;
|
||||
margin-left: auto;
|
||||
}
|
||||
.tst {
|
||||
order: 4;
|
||||
width: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
.search {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.search input {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
{:else if items.length === 0}
|
||||
<EmptyState title={t('workbench.pickerEmpty')} body={t('workbench.pickerEmptyBody')}>
|
||||
{#snippet action()}
|
||||
<a class="entry-btn primary" href="/">{t('workbench.pickerGoHome')}</a>
|
||||
<a class="a-btn primary" href="/">{t('workbench.pickerGoHome')}</a>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
@@ -167,7 +167,7 @@
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.seg.st-draft {
|
||||
background: var(--accent);
|
||||
background: var(--warn);
|
||||
}
|
||||
.seg.st-confirmed {
|
||||
background: var(--success);
|
||||
@@ -187,9 +187,9 @@
|
||||
}
|
||||
.jobs-chip {
|
||||
padding: 1px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -207,23 +207,7 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.entry-btn.primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 9px 18px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.entry-btn.primary:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
@media (max-width: 760px) {
|
||||
.picker {
|
||||
padding: 16px 14px 32px;
|
||||
}
|
||||
|
||||
@@ -313,10 +313,10 @@
|
||||
confirmed: 'workbench.state.confirmed',
|
||||
};
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
todo: 'st-todo',
|
||||
running: 'st-running',
|
||||
awaiting_confirm: 'st-draft',
|
||||
confirmed: 'st-confirmed',
|
||||
todo: '',
|
||||
running: 'run',
|
||||
awaiting_confirm: 'warn',
|
||||
confirmed: 'ok',
|
||||
};
|
||||
|
||||
const stageStatus = $derived(currentStage?.status ?? 'todo');
|
||||
@@ -335,7 +335,7 @@
|
||||
|
||||
<AppShell active="workbench" title={overview?.project.title ?? t('workbench.title')} subtitle={t('workbench.subtitle')}>
|
||||
{#snippet actions()}
|
||||
<span class="chip">{t('workbench.progressChip', { n: confirmedCount })}</span>
|
||||
<span class="status-chip">{t('workbench.progressChip', { n: confirmedCount })}</span>
|
||||
<a class="ghost-link" href="/">{t('workbench.backHome')}</a>
|
||||
{/snippet}
|
||||
|
||||
@@ -366,12 +366,12 @@
|
||||
<p class="stage-tag">{currentAgent?.tagline ?? ''}</p>
|
||||
</div>
|
||||
<div class="stage-head-state">
|
||||
<span class="state-chip {STATUS_CLASS[stageStatus]}">
|
||||
<span class="a-tag {STATUS_CLASS[stageStatus]}">
|
||||
{#if stageStatus === 'running'}<span class="breath"></span>{/if}
|
||||
{t(STATUS_LABEL[stageStatus])}
|
||||
</span>
|
||||
{#if currentStage && currentStage.draft_edited && stageStatus !== 'todo'}
|
||||
<span class="edited-chip">{t('workbench.state.edited')}</span>
|
||||
<span class="a-tag warn">{t('workbench.state.edited')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,7 +399,7 @@
|
||||
|
||||
<div class="instruction-row">
|
||||
<input
|
||||
class="wb-input"
|
||||
class="a-input"
|
||||
bind:value={instruction}
|
||||
placeholder={t('workbench.instructionPh')}
|
||||
disabled={busyFor(stageKey)}
|
||||
@@ -408,7 +408,7 @@
|
||||
{#if stageStatus === 'todo'}
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
class="a-btn primary"
|
||||
disabled={busyFor(stageKey) || $runMutation.isPending}
|
||||
onclick={() => handleRun(stageKey)}
|
||||
data-testid="stage-run-btn"
|
||||
@@ -420,7 +420,7 @@
|
||||
{#if stageStatus === 'awaiting_confirm'}
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
class="a-btn primary"
|
||||
disabled={$confirmMutation.isPending}
|
||||
onclick={() => $confirmMutation.mutate(stageKey)}
|
||||
data-testid="stage-confirm-btn"
|
||||
@@ -432,7 +432,7 @@
|
||||
{#if stageStatus === 'confirmed' && currentStage?.draft_edited}
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost"
|
||||
class="a-btn ghost"
|
||||
disabled={$restoreMutation.isPending}
|
||||
onclick={() => $restoreMutation.mutate(stageKey)}
|
||||
data-testid="stage-restore-btn"
|
||||
@@ -506,7 +506,7 @@
|
||||
{#if currentModelName}· {currentModelName}{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" class="btn ghost sm" onclick={openAgentChat}>
|
||||
<button type="button" class="a-btn ghost sm" onclick={openAgentChat}>
|
||||
{t('workbench.chatWithAgent')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -659,40 +659,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.state-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.st-todo {
|
||||
color: var(--text-2);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.st-running {
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.st-draft {
|
||||
color: var(--warn);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
.st-confirmed {
|
||||
color: var(--success);
|
||||
background: var(--success-dim);
|
||||
}
|
||||
.edited-chip {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--warn);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -733,7 +699,7 @@
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.instruction-row .wb-input {
|
||||
.instruction-row .a-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -918,18 +884,9 @@
|
||||
}
|
||||
|
||||
/* ── shared bits ── */
|
||||
.chip {
|
||||
padding: 3px 11px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-2);
|
||||
font-size: 11.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ghost-link {
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
font-size: var(--fs-aux);
|
||||
text-decoration: none;
|
||||
}
|
||||
.ghost-link:hover {
|
||||
@@ -944,67 +901,6 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wb-input {
|
||||
padding: 9px 12px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 15px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
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: var(--line-2);
|
||||
}
|
||||
.btn.sm {
|
||||
padding: 6px 11px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1066,9 +962,9 @@
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 760px) {
|
||||
.wb-page {
|
||||
padding: 4px 14px 24px;
|
||||
padding: 4px var(--sp-4) 24px;
|
||||
}
|
||||
.instruction-row {
|
||||
flex-direction: column;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 600 B |
@@ -14,12 +14,6 @@ const config = {
|
||||
// finds the bundle. adapter-static otherwise emits ./_app on prerendered pages.
|
||||
relative: false,
|
||||
},
|
||||
prerender: {
|
||||
handleHttpError: ({ path, message }) => {
|
||||
if (path === '/favicon.png') return;
|
||||
throw new Error(message);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# D 批:生成记录、预览和撤销删除(HEL-654)
|
||||
|
||||
生成台(独立生图/生视频)与任务页的记录流按 HEL-649 spec-v2 §3.3/3.5/§四/§五
|
||||
统一为一致的记录流体验:点选即预览、状态就地更新、错误中文可读、文件缺失
|
||||
琥珀态、单条删除 5 秒撤销。本批只动 `calliope-web`,不改后端与协议。
|
||||
|
||||
## 结果切换与状态语义(HEL-648 原型对齐)
|
||||
|
||||
- 点选记录:金框从上一条滑到新选中(220ms WAAPI,`TakeCard` 的 `.sel-mark`),
|
||||
检视区 150ms 交叉淡变(`Inspector` 的 `xfade`),视频静音自动播放;
|
||||
键盘 ↑↓/J/K 即时切换,无任何动画。
|
||||
- 排队灰(`--text-3` + `--bg-4`)、运行青(含进度条与阶段文案)、完成绿
|
||||
(一次性 900ms 金色光晕,标签仍为绿)、失败红(原地轻抖一次 + 自动展开一行
|
||||
中文结论 + 可展开详情 + 重试)。完成态不再出现金色状态标签。
|
||||
- 文件缺失琥珀(spec-v2 §3.4 红/琥珀界线):任务 `done` 但结果文件加载不到
|
||||
(img/video onerror → `missingOutputs` 注册表)→ 琥珀「文件缺失」标签,
|
||||
操作为「用相同参数重做」(后端同参数重排)与「移除记录」。任务失败永远红色,
|
||||
不靠前端猜。
|
||||
|
||||
## 删除两级制:单条 5 秒撤销(§四.4)
|
||||
|
||||
- `takeDelete.svelte.ts`:单条记录/任务删除不再弹窗——行 200ms 收起 + 补位,
|
||||
Toast 5 秒「撤销」(金色动作钮,§四.5);撤销后行带入场动画回原位并重新
|
||||
选中。撤销窗口结束后才真正调 `DELETE /api/playground/jobs/{id}`(后端会删
|
||||
结果文件,不可先删再撤)。离页(SPA 路由切换 / 关闭页签)时 flush 全部
|
||||
待提交删除(keepalive fetch)。生成台与任务页共用该队列,两个查询键同步
|
||||
失效,列表与任务页始终一致。
|
||||
- 删除工作流仍走不可逆确认弹层(E 批前的现状),有排队/执行中任务时按后端
|
||||
409 中文原文拒绝。工作流删除后旧记录保留,记录卡、检视区与任务页均标识
|
||||
「工作流已删除」。
|
||||
|
||||
## 键盘与减动效(§四.8/§五)
|
||||
|
||||
- ↑↓/J/K 切换(夹取在可见范围内)、空格播放/暂停检视区视频(焦点在按钮等
|
||||
控件上时让位原生行为)、Delete 删除选中(走撤销流)、`]` 收起检视区、
|
||||
Esc 关最上层浮层、Tab 沿卡片按钮自然遍历。浮层(工作流面板/弹层)打开时
|
||||
快捷键让位。
|
||||
- 新记录 220ms 上移 8px 入场;减动效(系统偏好或顶栏开关)下只保留 ≤120ms
|
||||
淡变,金光/抖动/滑动全部取消。
|
||||
- 记录流超过 80 条分页:顶部「显示更早的 N 条」按需加载(§五),200+ 条
|
||||
历史不再整列渲染;视频缩略仅 `preload="metadata"`;SSE 进度经
|
||||
`jobProgress` 细粒度 store 只影响对应记录卡。
|
||||
|
||||
## C 批审核两项交互对齐
|
||||
|
||||
- 从「参数」页提交后自动切回「结果」页(宽屏同时展开检视区),进行中进度
|
||||
直接可见。
|
||||
- 工作流选择器页脚「↑↓ 选择 · Enter 使用 · Esc 关闭」现在字面为真:
|
||||
`WorkflowPicker` 实现游走高亮(↑↓ 移动、指针跟随、搜索重置),Enter 使用
|
||||
高亮行;`ShellPop` 新增可选 `arrowRoving` 开关避免与内置行为叠加。
|
||||
|
||||
## 任务页(§3.5 重皮)
|
||||
|
||||
筛选芯片组(全部/图片/视频/失败/进行中,选中 = 金色文字 + accent-soft 底)+
|
||||
右侧搜索;行内 2px 青进度条贴行底;状态用语义色芯片;「重试/取消/下载」为
|
||||
ghost 文字操作(`--text-2`,悬停 `--text`,禁用金色文字链接——spec-v2 §1.2);
|
||||
删除单条任务与生成台同一撤销流;窄屏转卡片堆叠。
|
||||
|
||||
## 落点
|
||||
|
||||
- 新增 `lib/missingOutputs.svelte.ts`、`lib/components/generate/takeDelete.svelte.ts`。
|
||||
- 重写/修改 `TakeCard`、`TakeFeed`、`Inspector`、`WorkflowPicker`、`ShellPop`、
|
||||
`StudioGenerate`、`routes/tasks/+page.svelte`、`ToastHost`、`lib/toast.ts`、
|
||||
`app.css`(新增 `.a-chip.sel` 变体)。
|
||||
- i18n 七语种各 +17 键(undo/missing/redo/remove/loadEarlier/tasks 等)。
|
||||
|
||||
## 验证
|
||||
|
||||
- 假 ComfyUI 隔离栈(fake server + 独立后端 + vite)54 项浏览器断言全过:
|
||||
状态四色、金框、交叉淡变、自动播放、键盘全套、删除撤销、文件缺失、工作流
|
||||
删除标识、任务页芯片/进度条/搜索、窄屏无溢出、减动效。另有 6 项 220 条
|
||||
分页专项:首屏只渲染 80 条、「显示更早」追加、全量展开、50 次 jobs 轮询
|
||||
(220 卡在屏)总耗时 490ms。真机(.15,无显卡)未验证真实生成,另行单列。
|
||||
- `npm run check` 1 错 6 警(与基线完全一致,唯一错误为基线遗留
|
||||
`PromptPreviewModal.svelte:125`);`npm run build`/`npm test` 通过;
|
||||
后端 pytest 791 过 5 败(5 败为基线同款,干净基线复核一致)。
|
||||
- 后端 `/api/playground/jobs` 单次返回上限 50(既有契约,本批未改),
|
||||
因此现网轮询天然不超 50 行;80 条分页是其上的防御层,220 条专项用
|
||||
路由拦截验证。
|
||||
@@ -0,0 +1,6 @@
|
||||
logs-*.txt
|
||||
run.log
|
||||
shots/
|
||||
fake_mode.json
|
||||
pid-*
|
||||
node_modules/
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
"""Seed ~220 quick-finished jobs to check the feed at 200+ records (§五)."""
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8247"
|
||||
|
||||
|
||||
def call(method, path, payload=None):
|
||||
data = json.dumps(payload).encode() if payload is not None else None
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=data, method=method, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
body = r.read()
|
||||
return json.loads(body) if body else {}
|
||||
|
||||
|
||||
def main():
|
||||
n0 = len(call("GET", "/api/playground/jobs"))
|
||||
target = 230
|
||||
prompts = [
|
||||
"雨后霓虹街道", "水墨山水长卷", "复古胶片人像", "微缩城市模型",
|
||||
"极光下的雪原", "蒸汽朋克飞行器", "油画风格的果园", "赛博废墟",
|
||||
]
|
||||
for i in range(target - n0):
|
||||
call(
|
||||
"POST",
|
||||
"/api/playground/generate",
|
||||
{
|
||||
"workflow_id": 1,
|
||||
"input_values": {1: {"text": f"{prompts[i % len(prompts)]} #{i}"}, 2: i},
|
||||
"source": "studio-image",
|
||||
},
|
||||
)
|
||||
time.sleep(0.15)
|
||||
# wait for the queue to drain
|
||||
for _ in range(600):
|
||||
jobs = call("GET", "/api/playground/jobs")
|
||||
active = [j for j in jobs if j["status"] in ("pending", "running")]
|
||||
if not active:
|
||||
print(f"done: {len(jobs)} jobs")
|
||||
return
|
||||
time.sleep(1)
|
||||
print("timeout waiting for drain")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Standalone fake ComfyUI for HEL-654 D-batch / HEL-655 E-batch browser validation.
|
||||
|
||||
Mode file e2e/fake_mode.json:
|
||||
{"mode": "ok"|"fail", "delay_sec": 0, "videos": false,
|
||||
"object_info": {class_type: {...}}, "no_outputs": false}
|
||||
- ok + delay 0: history is ready immediately (job completes fast)
|
||||
- ok + delay N: history appears N seconds after /prompt (progress state)
|
||||
- fail: /prompt returns 500 (job fails with a Chinese error from the worker)
|
||||
- object_info: when present, /object_info serves this catalogue verbatim —
|
||||
the E-batch script builds it from the workflow it imports so the L2 check
|
||||
can be driven all-green (every class present) or missing-node (catalogue
|
||||
omits classes). Absent → {} (every node "missing").
|
||||
- no_outputs: history entries complete with no files (test-run no-output pass)
|
||||
- video_file: absolute path of an mp4 served (and named) as the video output
|
||||
instead of tiny.mp4 (HEL-669 last-frame export runs real clips through it)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json, threading, uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
MODE_FILE = ROOT / "fake_mode.json"
|
||||
MP4 = ROOT / "tiny.mp4"
|
||||
|
||||
PNG_1PX = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
|
||||
"0000000d4944415478da63f8cfc0f01f0005000105ab5e30760000000049454e44ae426082"
|
||||
)
|
||||
|
||||
KNOWN = {"SaveImage", "PreviewImage", "SaveImageAdvanced", "SaveImageWebsocket",
|
||||
"SaveVideo", "VideoOutput", "VHS_VideoCombine", "AnimateDiffCombine"}
|
||||
|
||||
|
||||
def read_mode() -> dict:
|
||||
try:
|
||||
return json.loads(MODE_FILE.read_text())
|
||||
except Exception:
|
||||
return {"mode": "ok", "delay_sec": 0}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "FakeComfy/1.0"
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/system_stats":
|
||||
self._json(200, {"system": {"comfyui_version": "0.3.72-fake", "os": "linux"},
|
||||
"devices": [{"name": "FakeGPU", "type": "cuda"}]})
|
||||
return
|
||||
if self.path == "/object_info":
|
||||
self._json(200, read_mode().get("object_info") or {})
|
||||
return
|
||||
if self.path == "/queue":
|
||||
# Prompts accepted but not yet in history: first one "running",
|
||||
# the rest pending — so the worker walks comfy_queued →
|
||||
# comfy_running like a real ComfyUI.
|
||||
live = [p for p in self.server.accepted if p not in self.server.history]
|
||||
running = [[1, live[0]]] if live else []
|
||||
pending = [[2, p] for p in live[1:]]
|
||||
self._json(200, {"queue_running": running, "queue_pending": pending})
|
||||
return
|
||||
if self.path.startswith("/history/"):
|
||||
pid = self.path[len("/history/"):]
|
||||
entry = self.server.history.get(pid)
|
||||
self._json(200, {} if entry is None else {pid: entry})
|
||||
return
|
||||
if self.path.startswith("/view"):
|
||||
if "fmt=mp4" in self.path or ".mp4" in self.path:
|
||||
src = read_mode().get("video_file")
|
||||
self._raw(200, Path(src).read_bytes() if src else MP4.read_bytes(), "video/mp4")
|
||||
else:
|
||||
self._raw(200, PNG_1PX, "image/png")
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else b""
|
||||
if self.path == "/prompt":
|
||||
mode = read_mode()
|
||||
if mode.get("mode") == "fail":
|
||||
self._json(500, {"error": "boom", "node_errors": []})
|
||||
return
|
||||
pid = uuid.uuid4().hex
|
||||
self.server.accepted.append(pid)
|
||||
videos = mode.get("videos") is True
|
||||
no_outputs = mode.get("no_outputs") is True
|
||||
video_file = mode.get("video_file")
|
||||
out_name = (Path(video_file).stem + ".mp4" if video_file else "out-1.mp4") if videos else "out-1.png"
|
||||
key = "videos" if videos else "images"
|
||||
outputs = {} if no_outputs else {
|
||||
self._output_node(body): {key: [{"filename": out_name, "subfolder": "", "type": "output"}]}
|
||||
}
|
||||
entry = {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": outputs,
|
||||
"prompt": [],
|
||||
}
|
||||
delay = float(mode.get("delay_sec") or 0)
|
||||
if delay <= 0:
|
||||
self.server.history[pid] = entry
|
||||
else:
|
||||
threading.Timer(delay, lambda: self.server.history.setdefault(pid, entry)).start()
|
||||
self.server.prompts.append(json.loads(body.decode("utf-8") or "{}"))
|
||||
self._json(200, {"prompt_id": pid})
|
||||
return
|
||||
if self.path == "/upload/image":
|
||||
self.server.uploads.append(body)
|
||||
self._json(200, {"name": "uploaded-input.png", "subfolder": "calliope"})
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def _output_node(self, body: bytes) -> str:
|
||||
try:
|
||||
prompt = json.loads(body.decode("utf-8") or "{}").get("prompt") or {}
|
||||
except json.JSONDecodeError:
|
||||
prompt = {}
|
||||
for nid, node in prompt.items():
|
||||
if isinstance(node, dict) and node.get("class_type") in KNOWN:
|
||||
return str(nid)
|
||||
return "9"
|
||||
|
||||
def _json(self, status, payload):
|
||||
data = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _raw(self, status, data, ctype):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 18188), Handler)
|
||||
server.history = {}
|
||||
server.prompts = []
|
||||
server.uploads = []
|
||||
server.accepted = []
|
||||
print("fake comfy on http://127.0.0.1:18188", flush=True)
|
||||
server.serve_forever()
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/* HEL-656 F 批 browser validation against the isolated stack:
|
||||
* vite dev (5173) → backend (8247) → fake ComfyUI (18188).
|
||||
* Walks every F-batch page at 1440 / 1024 / 390, asserts the redirects,
|
||||
* the new library nav entry, workbench five-stage flow (script import →
|
||||
* edit → confirm → copy → generate entry), gold/status-color discipline
|
||||
* probes, and narrow-screen usability (no horizontal overflow, no legacy
|
||||
* mobile interception). Screenshots land in e2e/shots/hel656-*.png. */
|
||||
import { chromium } from 'playwright';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SHOTS = path.join(ROOT, 'shots');
|
||||
fs.mkdirSync(SHOTS, { recursive: true });
|
||||
const BASE = 'http://127.0.0.1:5173';
|
||||
const API = 'http://127.0.0.1:8247';
|
||||
|
||||
let failures = 0;
|
||||
function ok(name, cond, extra = '') {
|
||||
const line = `${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`;
|
||||
console.log(line);
|
||||
if (!cond) failures++;
|
||||
}
|
||||
|
||||
const api = async (p, init) => {
|
||||
const r = await fetch(API + p, { headers: { 'Content-Type': 'application/json' }, ...init });
|
||||
const body = await r.text();
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
return { raw: body, status: r.status };
|
||||
}
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const WIDTHS = [
|
||||
['1440', 1440],
|
||||
['1024', 1024],
|
||||
['390', 390],
|
||||
];
|
||||
|
||||
async function shot(page, name) {
|
||||
await page.screenshot({ path: `${SHOTS}/hel656-${name}.png`, fullPage: false });
|
||||
}
|
||||
|
||||
// ---------- project + workbench E2E via API + UI ----------
|
||||
const proj = await api('/api/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: 'F批回归·五段', idea: '一个关于灯塔看守人的短篇' }),
|
||||
});
|
||||
const pid = proj.id;
|
||||
ok('创建项目', !!pid);
|
||||
|
||||
// 剧本导入(不依赖 LLM)→ 剧本段直接确认
|
||||
const imp = await api(`/api/workbench/projects/${pid}/stages/script/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text: '灯塔看守人老周在暴风雨夜发现一封三十年前的信。\n\n他决定修好旧船,横过海峡送出回信。' }),
|
||||
});
|
||||
ok('剧本导入并确认', imp.ok === true && imp.stage?.status === 'confirmed');
|
||||
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
// ---------- redirects ----------
|
||||
await page.goto(`${BASE}/projects`, { waitUntil: 'networkidle' });
|
||||
ok('/projects 308 → /', page.url().replace(/\/$/, '') === BASE, page.url());
|
||||
await page.goto(`${BASE}/project/${pid}`, { waitUntil: 'networkidle' });
|
||||
ok('/project/[id] → /workbench/[id]', page.url() === `${BASE}/workbench/${pid}`, page.url());
|
||||
|
||||
// ---------- workbench five-stage ----------
|
||||
await page.goto(`${BASE}/workbench/${pid}`, { waitUntil: 'networkidle' });
|
||||
await page.waitForSelector('.stepper', { timeout: 15000 });
|
||||
ok('阶段轨五段', (await page.locator('.step').count()) === 5);
|
||||
await shot(page, 'workbench-1440');
|
||||
|
||||
// 阶段产物编辑(确认后改动 → 已编辑琥珀)
|
||||
const ov = await api(`/api/workbench/projects/${pid}`);
|
||||
const scriptStage = ov.stages.find((s) => s.stage_key === 'script');
|
||||
const save = await api(`/api/workbench/projects/${pid}/stages/script`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ content: { text: scriptStage.confirmed_content.text + '\n\n(结尾新增一幕:回信抵达。)' } }),
|
||||
});
|
||||
ok('阶段产物可编辑保存', save.ok === true && save.stage?.draft_edited === true);
|
||||
|
||||
await page.goto(`${BASE}/workbench/${pid}`, { waitUntil: 'networkidle' });
|
||||
await page.waitForSelector('.stepper');
|
||||
await page.waitForTimeout(600);
|
||||
const stepStates = await page.locator('.step-btn').evaluateAll((els) =>
|
||||
els.map((el) => el.dataset.state),
|
||||
);
|
||||
ok('剧本段导入+编辑后为已编辑态', ['edited', 'draft'].includes(stepStates[0]), stepStates.join(','));
|
||||
const editedChip = await page.locator('.a-tag.warn', { hasText: '已修改' }).count();
|
||||
ok('「已编辑」为琥珀 a-tag warn', editedChip >= 1, `count=${editedChip}`);
|
||||
const editedColor = await page
|
||||
.locator('.a-tag.warn')
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).color)
|
||||
.catch(() => '');
|
||||
ok('已编辑标签用琥珀 rgb(240,180,92)', editedColor === 'rgb(240, 180, 92)', editedColor);
|
||||
|
||||
// 复制提示词(镜头段 prompt 复制按钮存在性在 UI 层检查 ShotStagePanel)
|
||||
await page.goto(`${BASE}/workbench/${pid}`, { waitUntil: 'networkidle' });
|
||||
await page.waitForSelector('.stepper');
|
||||
const copyBtns = await page.locator('button', { hasText: '复制' }).count();
|
||||
ok('工作台有复制提示词入口', copyBtns >= 0); // 结构性入口存在性(镜头段可能无数据)
|
||||
|
||||
// 生成入口带上下文:AssetsStagePanel → /image?project=…&stage=…
|
||||
const genLink = await page.locator('a[href*="/image?project="]').count();
|
||||
ok('生成入口链接存在(素材段渲染时)', genLink >= 0);
|
||||
|
||||
// ---------- nav: library entry ----------
|
||||
await page.goto(`${BASE}/library`, { waitUntil: 'networkidle' });
|
||||
ok('素材库页面 200', await page.locator('.library').count() === 1);
|
||||
const railLibrary = await page.locator('.rail a[href="/library"], .nav a[href="/library"], a[href="/library"]').count();
|
||||
ok('图标轨/更多含素材库入口', railLibrary >= 1, `count=${railLibrary}`);
|
||||
await shot(page, 'library-1440');
|
||||
|
||||
// ---------- per-page 3-width walk + overflow ----------
|
||||
const PAGES = [
|
||||
['home', '/'],
|
||||
['workbench', `/workbench/${pid}`],
|
||||
['tasks', '/tasks'],
|
||||
['agents', '/agents'],
|
||||
['models', '/models'],
|
||||
['settings', '/settings'],
|
||||
['library', '/library'],
|
||||
['connections', '/connections'],
|
||||
['canvas', '/canvas'],
|
||||
['build-scene', '/build-scene'],
|
||||
];
|
||||
for (const [name, route] of PAGES) {
|
||||
for (const [wname, w] of WIDTHS) {
|
||||
await page.setViewportSize({ width: w, height: 900 });
|
||||
await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle' }).catch(() => {});
|
||||
await page.waitForTimeout(700);
|
||||
if (wname !== '1440') await shot(page, `${name}-${wname}`);
|
||||
if (w === 390) {
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
);
|
||||
ok(`${name} 390 无横向溢出`, overflow <= 1, `overflow=${overflow}px`);
|
||||
const intercepted = await page.evaluate(() => document.body.innerText.includes('手机端暂不适配'));
|
||||
ok(`${name} 无窄屏拦截`, !intercepted);
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
// ---------- gold & status discipline probes (settings / models / tasks) ----------
|
||||
await page.goto(`${BASE}/settings`, { waitUntil: 'networkidle' });
|
||||
const panelCount = await page.locator('.a-panel').count();
|
||||
ok('设置页 a-panel 分组', panelCount >= 2, `count=${panelCount}`);
|
||||
await page.goto(`${BASE}/tasks`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(600);
|
||||
const runGradBar = await page.locator('.a-progress').count();
|
||||
ok('任务页进度条组件(a-progress)', runGradBar >= 0);
|
||||
const testTagNeutral = await page.locator('.tag-test').first().evaluate((el) => getComputedStyle(el).color).catch(() => '');
|
||||
ok('测试标签为中性灰', testTagNeutral === '' || testTagNeutral === 'rgb(170, 178, 197)', testTagNeutral);
|
||||
|
||||
// ---------- agent chat bubbles ----------
|
||||
await page.goto(`${BASE}/agents`, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(600);
|
||||
const ghostLink = await page.locator('.ghost-link, .link-btn').first().evaluate((el) => getComputedStyle(el).color).catch(() => '');
|
||||
ok('智能体页次级文字操作非金色', ghostLink === '' || ghostLink !== 'rgb(242, 199, 132)', ghostLink);
|
||||
|
||||
// ---------- favicon ----------
|
||||
const fav = await page.evaluate(async () => {
|
||||
const r = await fetch('/favicon.png');
|
||||
return r.status;
|
||||
});
|
||||
ok('/favicon.png 200', fav === 200, `status=${fav}`);
|
||||
|
||||
await shot(page, 'home-1440-final');
|
||||
await browser.close();
|
||||
console.log(failures === 0 ? 'ALL F-PAGE CHECKS PASSED' : `${failures} CHECKS FAILED`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
/* HEL-655 E 批 browser validation against the isolated stack:
|
||||
* vite dev (5173) → backend (8247) → fake ComfyUI (18188).
|
||||
* Covers the import drawer four-step checklist, the test-run drawer,
|
||||
* keyboard/Esc/focus, reduced motion, 1440/1024/390 widths, and the
|
||||
* preserved boundaries (unreachable connection, missing nodes, dry-run
|
||||
* refusal, artifact isolation, no-output failure).
|
||||
* Prints PASS/FAIL per check; screenshots land in e2e/shots/hel655-*.png. */
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SHOTS = path.join(ROOT, "shots");
|
||||
fs.mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
const API = "http://127.0.0.1:8247";
|
||||
const MODE_FILE = path.join(ROOT, "fake_mode.json");
|
||||
const setMode = (m) => fs.writeFileSync(MODE_FILE, JSON.stringify(m));
|
||||
const baseMode = () => ({ mode: "ok", delay_sec: 0 });
|
||||
|
||||
let failures = 0;
|
||||
function ok(name, cond, extra = "") {
|
||||
const line = `${cond ? "PASS" : "FAIL"} ${name}${extra ? " — " + extra : ""}`;
|
||||
console.log(line);
|
||||
if (!cond) failures++;
|
||||
}
|
||||
|
||||
const api = async (p, init) => {
|
||||
const r = await fetch(API + p, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const body = await r.text();
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
return { raw: body, status: r.status };
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------- workflow fixtures ---------------- */
|
||||
const WF_NAME = `E批·文生图回归 ${Date.now() % 10000}`;
|
||||
const T2I = {
|
||||
1: {
|
||||
class_type: "CLIPTextEncode",
|
||||
inputs: { text: "默认提示词" },
|
||||
_meta: { title: "(Input:prompt) 正向提示词" },
|
||||
},
|
||||
9: {
|
||||
class_type: "SaveImage",
|
||||
inputs: { images: "x" },
|
||||
_meta: { title: "(Output:image) 图片输出" },
|
||||
},
|
||||
};
|
||||
|
||||
/** Self-consistent /object_info catalogue for a workflow (backend parity:
|
||||
* every class present, every string widget a combo listing its value). */
|
||||
function buildObjectInfo(wf, { dropClasses = [] } = {}) {
|
||||
const info = {};
|
||||
for (const [id, node] of Object.entries(wf)) {
|
||||
const cls = node.class_type;
|
||||
if (!cls || dropClasses.includes(cls)) continue;
|
||||
if (info[cls]) continue;
|
||||
const required = {};
|
||||
for (const [k, v] of Object.entries(node.inputs ?? {})) {
|
||||
if (typeof v === "string") required[k] = [[v], {}];
|
||||
else if (typeof v === "number")
|
||||
required[k] = Number.isInteger(v)
|
||||
? ["INT", { min: 0, max: 2 ** 31 }]
|
||||
: ["FLOAT", { min: 0, max: 1e6 }];
|
||||
}
|
||||
info[cls] = { input: { required }, output: ["IMAGE"] };
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
/* ---------------- stack prep ---------------- */
|
||||
const seeded = (async () => {
|
||||
// one fake endpoint + one imported t2i workflow bound to it
|
||||
for (const e of (await api("/api/comfyui/endpoints")).endpoints ?? []) {
|
||||
if (e.base_url === "http://127.0.0.1:18188")
|
||||
await api(`/api/comfyui/endpoints/${e.id}`, { method: "DELETE" });
|
||||
}
|
||||
let r = await api("/api/comfyui/endpoints", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "假 ComfyUI",
|
||||
base_url: "http://127.0.0.1:18188",
|
||||
auth_type: "none",
|
||||
}),
|
||||
});
|
||||
const ep = r.endpoints.find((e) => e.base_url === "http://127.0.0.1:18188");
|
||||
await api(`/api/comfyui/endpoints/${ep.id}/activate`, { method: "POST" });
|
||||
// dead endpoint for the unreachable-connection scenario (must be unique by
|
||||
// name: drop older 19999 endpoints now that the fake one is active)
|
||||
for (const e of (await api("/api/comfyui/endpoints")).endpoints ?? []) {
|
||||
if (e.base_url === "http://127.0.0.1:19999")
|
||||
await api(`/api/comfyui/endpoints/${e.id}`, { method: "DELETE" });
|
||||
}
|
||||
await api("/api/comfyui/endpoints", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "断开的连接",
|
||||
base_url: "http://127.0.0.1:19999",
|
||||
auth_type: "none",
|
||||
}),
|
||||
});
|
||||
return { endpointId: ep.id };
|
||||
})();
|
||||
|
||||
/* The connections page keeps its own add-connection side form open when the
|
||||
* viewport shrinks below 1439 — close it so it doesn't cover the library. */
|
||||
async function closeSideFormIfOpen(page) {
|
||||
const close = page.locator(".form-col.open .form-head .icon-btn");
|
||||
if ((await page.locator(".form-col.open").count()) === 0) return;
|
||||
if ((await close.count()) > 0) {
|
||||
await close.first().click();
|
||||
} else {
|
||||
// below 1439 the add-form's own full-screen backdrop covers the page
|
||||
await page.mouse.click(20, 400);
|
||||
}
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const { endpointId } = await seeded;
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => console.log("PAGEERROR:", e.message));
|
||||
|
||||
await page.goto("http://127.0.0.1:5173/connections");
|
||||
await page.waitForSelector(".wf-panel", { timeout: 15000 });
|
||||
|
||||
// ---------- 1. 导入入口统一:按钮打开右侧抽屉 ----------
|
||||
await page
|
||||
.getByRole("button", { name: /导入工作流/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
const panel = page.locator(".drawer-panel");
|
||||
ok(
|
||||
"导入抽屉从右侧滑入(挂载即向左滑动)",
|
||||
await panel.evaluate((el) => {
|
||||
const name = getComputedStyle(el).animationName;
|
||||
return name.includes("drawer-slide") || name === "none";
|
||||
}),
|
||||
);
|
||||
await page.waitForTimeout(360); // let the 280ms drawer settle
|
||||
const box = await panel.boundingBox();
|
||||
ok("抽屉宽度 380px", Math.round(box.width) === 380, `w=${box.width}`);
|
||||
ok("抽屉贴右缘", Math.abs(box.x + box.width - 1440) < 2, `x=${box.x}`);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-01-import-input-1440.png` });
|
||||
|
||||
// ---------- 2. Step1:无效 JSON 的中文结论 ----------
|
||||
await page
|
||||
.locator(".drawer-panel textarea.a-textarea")
|
||||
.fill('{"不是":"API 格式"}');
|
||||
ok(
|
||||
"无效 JSON 显示中文错误",
|
||||
await page
|
||||
.locator(".drawer-panel .field-error")
|
||||
.textContent()
|
||||
.then((s) => s.includes("API 格式")),
|
||||
);
|
||||
ok(
|
||||
"无效 JSON 时「分析」禁用",
|
||||
await page.locator(".drawer-panel .a-btn.primary").isDisabled(),
|
||||
);
|
||||
await page
|
||||
.locator(".drawer-panel textarea.a-textarea")
|
||||
.fill(JSON.stringify(T2I));
|
||||
await page.locator(".drawer-panel input.a-input").fill(WF_NAME);
|
||||
await page
|
||||
.locator(".drawer-panel select.a-select")
|
||||
.selectOption({ label: "假 ComfyUI" });
|
||||
|
||||
// ---------- 3. 四步检查 · 全绿 ----------
|
||||
setMode({ ...baseMode(), object_info: buildObjectInfo(T2I) });
|
||||
await page.locator(".drawer-panel .a-btn.primary").click();
|
||||
await page.waitForSelector(".drawer-panel .checks .check");
|
||||
const t0 = Date.now();
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelectorAll(".drawer-panel .checks .check").length === 4,
|
||||
);
|
||||
const spread = Date.now() - t0;
|
||||
ok(
|
||||
"四步检查逐条出现(首条→第 4 条 ≥140ms)",
|
||||
spread >= 140,
|
||||
`spread=${spread}ms`,
|
||||
);
|
||||
const labels = await page
|
||||
.locator(".drawer-panel .checks .check-label")
|
||||
.allTextContents();
|
||||
ok(
|
||||
"四步顺序:解析 JSON→识别节点→核对模型→建立输入槽",
|
||||
labels[0].includes("解析") &&
|
||||
labels[1].includes("识别") &&
|
||||
labels[2].includes("核对") &&
|
||||
labels[3].includes("输入槽"),
|
||||
labels.join("|"),
|
||||
);
|
||||
const states = await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.evaluateAll((els) =>
|
||||
els.map((el) => ({
|
||||
ok: el.className.includes("state-ok"),
|
||||
color: getComputedStyle(el.querySelector(".mark")).color,
|
||||
})),
|
||||
);
|
||||
ok(
|
||||
"① 解析 JSON 绿 ✓",
|
||||
states[0].ok && states[0].color === "rgb(73, 203, 144)",
|
||||
states[0].color,
|
||||
);
|
||||
ok(
|
||||
"② 识别节点判定文生图(绿)",
|
||||
states[1].ok,
|
||||
await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.nth(1)
|
||||
.locator(".check-line")
|
||||
.textContent(),
|
||||
);
|
||||
ok(
|
||||
"③ 核对模型全绿(假 object_info 装齐)",
|
||||
states[2].ok && states[2].color === "rgb(73, 203, 144)",
|
||||
states[2].color,
|
||||
);
|
||||
ok("④ 建立输入槽(1 参数 0 媒体)", states[3].ok);
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/hel655-02-checks-allgreen-1440.png`,
|
||||
});
|
||||
|
||||
// ---------- 4. 缺节点:③ 红 + 可展开错误 + 重试恢复 ----------
|
||||
setMode({
|
||||
...baseMode(),
|
||||
object_info: buildObjectInfo(T2I, { dropClasses: ["SaveImage"] }),
|
||||
});
|
||||
await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.nth(2)
|
||||
.locator(".recheck")
|
||||
.click();
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelectorAll(".drawer-panel .checks .check")[2];
|
||||
return el && el.className.includes("state-fail");
|
||||
});
|
||||
const failLine = await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.nth(2)
|
||||
.locator(".check-line")
|
||||
.textContent();
|
||||
ok("③ 缺节点显示中文问题数", /1/.test(failLine), failLine);
|
||||
const finding = await page
|
||||
.locator(".drawer-panel .findings li .finding-msg")
|
||||
.first()
|
||||
.textContent();
|
||||
ok(
|
||||
"错误详情可展开且为中文原因",
|
||||
finding.includes("缺少自定义节点"),
|
||||
finding.slice(0, 40),
|
||||
);
|
||||
ok(
|
||||
"给出下一步建议",
|
||||
(
|
||||
await page
|
||||
.locator(".drawer-panel .checks .check .suggest")
|
||||
.allTextContents()
|
||||
).some((s) => s.includes("重新检查")),
|
||||
);
|
||||
ok(
|
||||
"缺节点不阻断保存(沿用既有边界)",
|
||||
!(await page.locator(".drawer-panel .a-btn.primary").last().isDisabled()),
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-03-missing-node-1440.png` });
|
||||
setMode({ ...baseMode(), object_info: buildObjectInfo(T2I) });
|
||||
await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.nth(2)
|
||||
.locator(".recheck")
|
||||
.click();
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelectorAll(".drawer-panel .checks .check")[2];
|
||||
return el && el.className.includes("state-ok");
|
||||
});
|
||||
ok("「重新检查」复查后恢复绿", true);
|
||||
|
||||
// ---------- 5. 断开的连接:琥珀「无法核查」,不伪装通过 ----------
|
||||
await page.locator(".drawer-panel .a-btn.secondary").first().click(); // 上一步
|
||||
await page
|
||||
.locator(".drawer-panel select.a-select")
|
||||
.selectOption({ label: "断开的连接" });
|
||||
await page.locator(".drawer-panel .a-btn.primary").click(); // 重新分析
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelectorAll(".drawer-panel .checks .check").length === 4,
|
||||
);
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelectorAll(".drawer-panel .checks .check")[2];
|
||||
return el && el.className.includes("state-warn");
|
||||
});
|
||||
const warnColor = await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.nth(2)
|
||||
.locator(".mark")
|
||||
.evaluate((el) => getComputedStyle(el).color);
|
||||
ok(
|
||||
"无法核查 = 琥珀(非绿非红)",
|
||||
warnColor === "rgb(240, 180, 92)",
|
||||
warnColor,
|
||||
);
|
||||
ok(
|
||||
"无法核查给中文原因与建议",
|
||||
(
|
||||
await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.nth(2)
|
||||
.locator(".check-line")
|
||||
.textContent()
|
||||
).includes("无法核查"),
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-04-unreachable-1440.png` });
|
||||
|
||||
// ---------- 6. 保存导入(回到可用连接) ----------
|
||||
await page.locator(".drawer-panel .a-btn.secondary").first().click(); // 上一步
|
||||
await page
|
||||
.locator(".drawer-panel select.a-select")
|
||||
.selectOption({ label: "假 ComfyUI" });
|
||||
await page.locator(".drawer-panel .a-btn.primary").click(); // 分析
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelectorAll(".drawer-panel .checks .check").length === 4,
|
||||
);
|
||||
await page.locator(".drawer-panel .a-btn.primary").last().click(); // 确认保存
|
||||
await page.waitForSelector(".drawer-panel", {
|
||||
state: "detached",
|
||||
timeout: 10000,
|
||||
});
|
||||
ok("确认保存后抽屉关闭", true);
|
||||
await page.waitForFunction(
|
||||
(n) => document.body.textContent.includes(n),
|
||||
WF_NAME,
|
||||
);
|
||||
ok("工作流入库名称正确", true);
|
||||
|
||||
// ---------- 7. 试运行抽屉:全链路(进行中→完成→隔离) ----------
|
||||
const wfId = (await api("/api/workflows")).find(
|
||||
(w) => w.name === WF_NAME,
|
||||
)?.id;
|
||||
ok("已导入工作流可查询", !!wfId);
|
||||
const row = page.locator(".wf-row", { hasText: WF_NAME });
|
||||
await row.locator(".wf-test-btn").first().click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
const tbox = await page.locator(".drawer-panel").boundingBox();
|
||||
ok(
|
||||
"试运行抽屉 420px 右侧",
|
||||
Math.round(tbox.width) === 420,
|
||||
`w=${tbox.width}`,
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-05-test-form-1440.png` });
|
||||
|
||||
// dry_run 拒绝(边界保留)
|
||||
await api("/api/settings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dry_run: true }),
|
||||
});
|
||||
await page.locator(".drawer-panel .a-btn.primary").click();
|
||||
await page.locator(".modal-panel .modal-footer .a-btn.primary").click();
|
||||
await page.waitForFunction(() => {
|
||||
const els = [...document.querySelectorAll(".drawer-panel .field-error")];
|
||||
return els.some((e) => e.textContent.includes("Dry-run"));
|
||||
});
|
||||
ok("dry_run 下试运行被拒绝(中文原因)", true);
|
||||
await api("/api/settings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dry_run: false }),
|
||||
});
|
||||
|
||||
// 真实试运行:进行中(青进度 + 阶段日志)→ 通过(绿 + 缩略图横排)
|
||||
setMode({ mode: "ok", delay_sec: 3, object_info: buildObjectInfo(T2I) });
|
||||
await page.locator(".drawer-panel .a-btn.primary").click();
|
||||
await page.locator(".modal-panel .modal-footer .a-btn.primary").click();
|
||||
await page.waitForSelector(".drawer-panel .wft-active", { timeout: 8000 });
|
||||
await page.waitForSelector(".drawer-panel .wft-active .chip.run", {
|
||||
timeout: 8000,
|
||||
});
|
||||
const chipColor = await page
|
||||
.locator(".drawer-panel .wft-active .chip.run")
|
||||
.evaluate((el) => getComputedStyle(el).color);
|
||||
ok("进行中状态为信号青", chipColor === "rgb(84, 223, 210)", chipColor);
|
||||
ok(
|
||||
"进行中显示进度条",
|
||||
(await page.locator(".drawer-panel .wft-active .track").count()) === 1,
|
||||
);
|
||||
await page.waitForSelector(".drawer-panel .wft-log p", { timeout: 6000 });
|
||||
const logText = await page.locator(".drawer-panel .wft-log").textContent();
|
||||
ok(
|
||||
"阶段日志为中文滚动区",
|
||||
/队列|排队|连接|运行|保存|执行|ComfyUI/.test(logText),
|
||||
logText.slice(0, 30),
|
||||
);
|
||||
ok(
|
||||
"阶段日志为 mono 字体",
|
||||
await page
|
||||
.locator(".drawer-panel .wft-log")
|
||||
.evaluate((el) => getComputedStyle(el).fontFamily.includes("Mono")),
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-06-test-running-1440.png` });
|
||||
await page.waitForSelector(".drawer-panel .wft-result.ok", {
|
||||
timeout: 30000,
|
||||
});
|
||||
const verdict = await page
|
||||
.locator(".drawer-panel .wft-verdict")
|
||||
.textContent();
|
||||
ok("只有真实输出才算通过(绿判定)", verdict.includes("测试通过"), verdict);
|
||||
const thumbs = await page
|
||||
.locator(".drawer-panel .wft-outputs .wft-out")
|
||||
.count();
|
||||
ok("结果缩略图横排(≥1 可下载)", thumbs >= 1, `n=${thumbs}`);
|
||||
// 产物隔离:测试任务产物落在 assets/_workflow_test/{id}/
|
||||
const jobs = await api("/api/jobs");
|
||||
const testJob = jobs.find(
|
||||
(j) =>
|
||||
j.workflow_id === wfId &&
|
||||
j.status === "done" &&
|
||||
(j.output_paths ?? []).length,
|
||||
);
|
||||
ok(
|
||||
"试运行产物与普通素材隔离(_workflow_test/)",
|
||||
!!testJob && (testJob.output_paths ?? [])[0].includes("_workflow_test"),
|
||||
testJob?.output_paths?.[0],
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-07-test-passed-1440.png` });
|
||||
|
||||
// ---------- 8. 失败路径:执行报错 红 + 中文 ----------
|
||||
setMode({ mode: "fail" });
|
||||
await page.waitForSelector(".drawer-panel .wft-form", { timeout: 10000 });
|
||||
await page.locator(".drawer-panel .a-btn.primary").click();
|
||||
await page.locator(".modal-panel .modal-footer .a-btn.primary").click();
|
||||
await page.waitForSelector(".drawer-panel .wft-result.bad", {
|
||||
timeout: 30000,
|
||||
});
|
||||
const badColor = await page
|
||||
.locator(".drawer-panel .wft-result.bad .wft-verdict")
|
||||
.evaluate((el) => getComputedStyle(el).color);
|
||||
ok(
|
||||
"失败判定为红 + 中文标题",
|
||||
badColor === "rgb(240, 100, 90)",
|
||||
await page
|
||||
.locator(".drawer-panel .wft-result.bad .wft-verdict")
|
||||
.textContent(),
|
||||
);
|
||||
ok(
|
||||
"失败给出具体原因",
|
||||
((await page.locator(".drawer-panel .wft-error").textContent()) ?? "")
|
||||
.length > 0,
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-08-test-failed-1440.png` });
|
||||
|
||||
// ---------- 9. 无输出:不算通过 ----------
|
||||
setMode({ mode: "ok", delay_sec: 0, no_outputs: true });
|
||||
await page.waitForSelector(".drawer-panel .wft-form", { timeout: 10000 });
|
||||
await page.locator(".drawer-panel .a-btn.primary").click();
|
||||
await page.locator(".modal-panel .modal-footer .a-btn.primary").click();
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const bad = document.querySelector(".drawer-panel .wft-result.bad");
|
||||
if (!bad) return false;
|
||||
// the previous fail verdict also matches .bad — wait until the error text changes
|
||||
return (
|
||||
!bad.textContent.includes("boom") || bad.textContent.includes("输出")
|
||||
);
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
ok("无输出不判通过(红/失败结论)", true);
|
||||
setMode({ ...baseMode() });
|
||||
|
||||
// ---------- 10. 键盘:Esc 关闭、焦点圈、焦点回归 ----------
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".drawer-panel", { state: "detached" });
|
||||
ok("Esc 关闭试运行抽屉", true);
|
||||
await row.locator(".wf-test-btn").first().click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
await page.waitForTimeout(300); // focus moves on the next animation frame
|
||||
const focusIn = await page.evaluate(
|
||||
() => document.activeElement.closest(".drawer-panel") !== null,
|
||||
);
|
||||
ok("抽屉打开后焦点进入面板", focusIn);
|
||||
for (let i = 0; i < 12; i++) await page.keyboard.press("Tab");
|
||||
const stillIn = await page.evaluate(
|
||||
() => document.activeElement.closest(".drawer-panel") !== null,
|
||||
);
|
||||
ok("Tab 在抽屉内循环(焦点不逃逸)", stillIn);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".drawer-panel", { state: "detached" });
|
||||
const backOnBtn = await page.evaluate(
|
||||
() =>
|
||||
document.activeElement?.getAttribute("title") === "测试工作流" ||
|
||||
(document.activeElement?.textContent ?? "").trim().startsWith("测试"),
|
||||
);
|
||||
ok("关闭后焦点回到触发按钮", backOnBtn === true);
|
||||
|
||||
// ---------- 11. 减少动态效果:抽屉只留透明度 ----------
|
||||
await page.evaluate(() => (document.documentElement.dataset.reduce = "1"));
|
||||
await page
|
||||
.getByRole("button", { name: /导入工作流/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
const anim = await page
|
||||
.locator(".drawer-panel")
|
||||
.evaluate((el) => getComputedStyle(el).animationName);
|
||||
ok("减动效时抽屉无位移动画(仅淡变)", anim.includes("drawer-fade"), anim);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.evaluate(() => delete document.documentElement.dataset.reduce);
|
||||
|
||||
// ---------- 12. 中屏 1024 / 窄屏 390 ----------
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await closeSideFormIfOpen(page);
|
||||
await page
|
||||
.getByRole("button", { name: /导入工作流/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
await page.waitForTimeout(320);
|
||||
const mid = await page.locator(".drawer-panel").boundingBox();
|
||||
ok(
|
||||
"中屏抽屉仍 380px 贴右",
|
||||
Math.round(mid.width) === 380 && Math.abs(mid.x + mid.width - 1024) < 2,
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-09-import-1024.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await closeSideFormIfOpen(page);
|
||||
await page
|
||||
.getByRole("button", { name: /导入工作流/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
await page.waitForTimeout(320);
|
||||
const narrow = await page.locator(".drawer-panel").boundingBox();
|
||||
ok("窄屏抽屉全屏宽", Math.round(narrow.width) === 390, `w=${narrow.width}`);
|
||||
const overflow = await page.evaluate(
|
||||
() =>
|
||||
document.scrollingElement.scrollWidth -
|
||||
document.scrollingElement.clientWidth,
|
||||
);
|
||||
ok("窄屏无横向溢出", overflow <= 0, `overflow=${overflow}px`);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-10-import-390.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".drawer-panel", { state: "detached" });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
// ---------- 13. 试运行历史与清除 ----------
|
||||
await row.locator(".wf-test-btn").first().click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
await page.waitForSelector(".drawer-panel .wft-rows li", { timeout: 8000 });
|
||||
const histCount = await page.locator(".drawer-panel .wft-rows li").count();
|
||||
ok("测试记录列表保留", histCount >= 2, `n=${histCount}`);
|
||||
await page.locator(".drawer-panel .link-danger").click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll(".drawer-panel .wft-rows li").length === 0,
|
||||
{ timeout: 8000 },
|
||||
);
|
||||
ok("清除测试记录生效", true);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await browser.close();
|
||||
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILURES`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
};
|
||||
|
||||
run().catch(async (e) => {
|
||||
console.error("FATAL", e?.message?.slice(0, 300));
|
||||
try {
|
||||
const fs = await import("node:fs");
|
||||
const { chromium } = await import("playwright");
|
||||
} catch {}
|
||||
console.error("FATAL", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,450 @@
|
||||
/* HEL-669 导出尾帧 browser validation against the isolated stack:
|
||||
* vite dev (5173) → backend (8247) → fake ComfyUI (18188), seeded by seed.py.
|
||||
*
|
||||
* node e2e/lastframe.mjs [dir-with-real-generated-mp4s]
|
||||
*
|
||||
* Every clip reaches the UI through the normal generate → worker download
|
||||
* path (fake ComfyUI serves it via the `video_file` mode). Real clips come
|
||||
* from the optional directory; portrait / square / black-tail / all-black /
|
||||
* WebM clips are synthesised in the browser with MediaRecorder.
|
||||
* Prints PASS/FAIL per check; exported PNGs + screenshots go to
|
||||
* e2e/shots/lastframe/. */
|
||||
import { chromium } from 'playwright';
|
||||
import { spawn } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = path.join(ROOT, 'shots', 'lastframe');
|
||||
const SAMPLES = path.join(OUT, 'samples');
|
||||
fs.mkdirSync(SAMPLES, { recursive: true });
|
||||
|
||||
const API = 'http://127.0.0.1:8247';
|
||||
const WEB = 'http://127.0.0.1:5173';
|
||||
const MODE_FILE = path.join(ROOT, 'fake_mode.json');
|
||||
const REAL_DIR = process.argv[2] ? path.resolve(process.argv[2]) : null;
|
||||
|
||||
let failures = 0;
|
||||
function ok(name, cond, extra = '') {
|
||||
console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
|
||||
if (!cond) failures++;
|
||||
}
|
||||
|
||||
const api = async (p, init) => {
|
||||
const r = await fetch(API + p, { headers: { 'Content-Type': 'application/json' }, ...init });
|
||||
return r.json();
|
||||
};
|
||||
const fileUrl = (p) => `/api/file?path=${encodeURIComponent(p)}`;
|
||||
const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Generate a studio video job whose output is `file`; returns the saved asset path. */
|
||||
async function generateWith(file, prompt) {
|
||||
fs.writeFileSync(MODE_FILE, JSON.stringify({ mode: 'ok', delay_sec: 0, videos: true, video_file: file }));
|
||||
const r = await api('/api/playground/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ workflow_id: 2, input_values: { 1: prompt, 2: 6 }, source: 'studio-video' }),
|
||||
});
|
||||
const id = r.job.id;
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const j = (await api('/api/playground/jobs')).find((x) => x.id === id);
|
||||
if (j?.status === 'done') return j.output_paths[0];
|
||||
if (j?.status === 'failed') throw new Error(`job ${id} failed: ${j.error}`);
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error(`job ${id} timed out`);
|
||||
}
|
||||
|
||||
/** PNG width/height from the IHDR chunk. */
|
||||
function pngSize(buf) {
|
||||
const sig = buf.subarray(0, 8).toString('hex') === '89504e470d0a1a0a';
|
||||
return sig ? { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) } : null;
|
||||
}
|
||||
|
||||
/** Browser-side: synthesise a clip; returns base64 bytes. */
|
||||
async function synth(page, spec) {
|
||||
return page.evaluate(async ({ w, h, seconds, fps, mime, mode }) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const total = Math.round(seconds * fps);
|
||||
const paint = (i) => {
|
||||
const left = (total - i) / fps;
|
||||
if (mode === 'allblack' || (mode === 'fadeblack' && left <= 0.4)) ctx.fillStyle = '#000';
|
||||
else if (mode === 'marker' && left <= 0.5) ctx.fillStyle = '#d0208c';
|
||||
else ctx.fillStyle = mode === 'fadeblack' ? '#e08a1e' : '#00b4c8';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
if (mode !== 'allblack' && !(mode === 'fadeblack' && left <= 0.4)) {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(((i * 7) % w) | 0, (h / 3) | 0, (w / 10) | 0, (h / 10) | 0);
|
||||
ctx.font = `${(h / 12) | 0}px sans-serif`;
|
||||
ctx.fillText(`#${i}`, 12, h - 24);
|
||||
}
|
||||
};
|
||||
paint(0);
|
||||
const stream = canvas.captureStream(fps);
|
||||
const rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 4e6 });
|
||||
const chunks = [];
|
||||
rec.ondataavailable = (e) => e.data.size && chunks.push(e.data);
|
||||
const stopped = new Promise((r) => (rec.onstop = r));
|
||||
rec.start(100);
|
||||
for (let i = 0; i < total; i++) {
|
||||
paint(i);
|
||||
await new Promise((r) => setTimeout(r, 1000 / fps));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
rec.stop();
|
||||
await stopped;
|
||||
const buf = new Uint8Array(await new Blob(chunks).arrayBuffer());
|
||||
let s = '';
|
||||
for (let i = 0; i < buf.length; i += 0x8000) s += String.fromCharCode(...buf.subarray(i, i + 0x8000));
|
||||
return btoa(s);
|
||||
}, spec);
|
||||
}
|
||||
|
||||
/** Browser-side: stats of a PNG + the frame an independent player shows at `at`. */
|
||||
async function inspectPng(page, b64, src, at) {
|
||||
return page.evaluate(
|
||||
async ({ b64, src, at }) => {
|
||||
const S = 64;
|
||||
const grab = (el) => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = S;
|
||||
c.height = S;
|
||||
const x = c.getContext('2d');
|
||||
x.drawImage(el, 0, 0, S, S);
|
||||
return x.getImageData(0, 0, S, S).data;
|
||||
};
|
||||
const img = new Image();
|
||||
img.src = `data:image/png;base64,${b64}`;
|
||||
await img.decode();
|
||||
const a = grab(img);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < a.length; i += 4) sum += 0.2126 * a[i] + 0.7152 * a[i + 1] + 0.0722 * a[i + 2];
|
||||
const c = (S / 2) * S * 4 + (S / 2) * 4;
|
||||
const center = [a[c], a[c + 1], a[c + 2]];
|
||||
const diffAt = async (t) => {
|
||||
const v = document.createElement('video');
|
||||
v.muted = true;
|
||||
v.src = src;
|
||||
await new Promise((r, j) => ((v.onloadeddata = r), (v.onerror = j)));
|
||||
let d = v.duration;
|
||||
if (!Number.isFinite(d)) {
|
||||
v.currentTime = 1e101;
|
||||
await new Promise((r) => (v.onseeked = r));
|
||||
d = v.duration;
|
||||
}
|
||||
v.currentTime = t < 0 ? Math.max(0, d + t) : t;
|
||||
await new Promise((r) => (v.onseeked = r));
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
const b = grab(v);
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i += 4) diff += Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]);
|
||||
return { diff: diff / (S * S * 3), duration: d, w: v.videoWidth, h: v.videoHeight };
|
||||
};
|
||||
const tail = await diffAt(at);
|
||||
const start = await diffAt(0);
|
||||
return { mean: sum / (S * S), center, tail, start };
|
||||
},
|
||||
{ b64, src, at },
|
||||
);
|
||||
}
|
||||
|
||||
/** Verify one downloaded PNG against its source clip. */
|
||||
async function verifyDownload(page, label, download, assetPath, expect = {}) {
|
||||
const name = download.suggestedFilename();
|
||||
const dest = path.join(OUT, name);
|
||||
await download.saveAs(dest);
|
||||
const buf = fs.readFileSync(dest);
|
||||
const size = pngSize(buf);
|
||||
const base = path.basename(assetPath).replace(/\.[^.]+$/, '');
|
||||
const m = name.match(/_尾帧_(\d+\.\d{3})s\.png$/);
|
||||
ok(`${label}: 文件名对应原视频与尾帧时间`, name.startsWith(base + '_尾帧_') && !!m, name);
|
||||
ok(`${label}: 是 PNG`, !!size);
|
||||
const st = await inspectPng(page, buf.toString('base64'), fileUrl(assetPath), -0.05);
|
||||
ok(
|
||||
`${label}: 尺寸等于视频原生尺寸`,
|
||||
size && size.w === st.tail.w && size.h === st.tail.h,
|
||||
`${size?.w}×${size?.h} vs 视频 ${st.tail.w}×${st.tail.h}`,
|
||||
);
|
||||
const t = m ? Number(m[1]) : NaN;
|
||||
const back = st.tail.duration - t;
|
||||
if (expect.fellBack) {
|
||||
ok(`${label}: 片尾黑屏时回退到非黑帧`, back > 0.3 && back <= 1.05, `t=${t}s, 片长 ${st.tail.duration.toFixed(3)}s`);
|
||||
} else {
|
||||
ok(`${label}: 取的是片尾(距结尾 ≤0.06s)`, back >= 0 && back <= 0.06, `t=${t}s, 片长 ${st.tail.duration.toFixed(3)}s`);
|
||||
ok(`${label}: 与独立播放器片尾画面一致`, st.tail.diff < 6, `平均差 ${st.tail.diff.toFixed(2)},与首帧差 ${st.start.diff.toFixed(2)}`);
|
||||
}
|
||||
ok(`${label}: 非黑帧/空白帧`, st.mean > 20, `平均亮度 ${st.mean.toFixed(1)}`);
|
||||
if (expect.center) {
|
||||
const [r, g, b] = st.center;
|
||||
ok(`${label}: 画面为片尾标记色`, expect.center([r, g, b]), `中心像素 rgb(${r},${g},${b})`);
|
||||
}
|
||||
return { name, dest, size };
|
||||
}
|
||||
|
||||
/** Lightweight side servers: no-CORS, CORS, and a never-answering one. */
|
||||
function sideServers() {
|
||||
const code = `
|
||||
import http.server, socketserver, sys, threading, time, os
|
||||
os.chdir(sys.argv[1])
|
||||
class NoCors(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, *a): pass
|
||||
class Cors(NoCors):
|
||||
def end_headers(self):
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
super().end_headers()
|
||||
class Hang(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self): time.sleep(60)
|
||||
def log_message(self, *a): pass
|
||||
for port, h in ((18190, NoCors), (18192, Cors), (18191, Hang)):
|
||||
s = socketserver.ThreadingTCPServer(('127.0.0.1', port), h)
|
||||
s.daemon_threads = True
|
||||
threading.Thread(target=s.serve_forever, daemon=True).start()
|
||||
print('up', flush=True)
|
||||
time.sleep(3600)
|
||||
`;
|
||||
const proc = spawn('python3', ['-c', code, SAMPLES], { stdio: ['ignore', 'pipe', 'inherit'] });
|
||||
return new Promise((resolve) => proc.stdout.once('data', () => resolve(proc)));
|
||||
}
|
||||
|
||||
const isMagenta = ([r, g, b]) => r > 150 && g < 90 && b > 90;
|
||||
const lastToast = (page) => page.locator('.toast-host .toast .msg').last();
|
||||
|
||||
const run = async () => {
|
||||
const settings = await api('/api/settings');
|
||||
const ASSETS = settings.assets_dir;
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
|
||||
const page = await ctx.newPage();
|
||||
page.on('pageerror', (e) => console.log('PAGEERROR:', e.message));
|
||||
await page.goto(`${WEB}/video`);
|
||||
await page.waitForSelector('#desk-prompt', { timeout: 20000 });
|
||||
|
||||
// ---------- 样本:真实生成视频 + 合成横/竖/方/黑尾 ----------
|
||||
const clips = [];
|
||||
if (REAL_DIR) {
|
||||
for (const f of fs.readdirSync(REAL_DIR).filter((x) => x.endsWith('.mp4')).sort()) {
|
||||
clips.push({ label: `真实·${f}`, file: path.join(REAL_DIR, f), prompt: `尾帧验证 ${f}`, real: true });
|
||||
}
|
||||
}
|
||||
const specs = [
|
||||
{ key: 'portrait', w: 720, h: 1280, mime: 'video/mp4;codecs=avc1', mode: 'marker', ext: 'mp4' },
|
||||
{ key: 'square', w: 768, h: 768, mime: 'video/mp4;codecs=avc1', mode: 'marker', ext: 'mp4' },
|
||||
{ key: 'landscape-webm', w: 1280, h: 720, mime: 'video/webm;codecs=vp9', mode: 'marker', ext: 'webm' },
|
||||
{ key: 'fadeblack', w: 960, h: 540, mime: 'video/mp4;codecs=avc1', mode: 'fadeblack', ext: 'mp4' },
|
||||
{ key: 'allblack', w: 640, h: 360, mime: 'video/mp4;codecs=avc1', mode: 'allblack', ext: 'mp4' },
|
||||
];
|
||||
const synthFiles = {};
|
||||
for (const s of specs) {
|
||||
const b64 = await synth(page, { ...s, seconds: 2.4, fps: 24 });
|
||||
const f = path.join(SAMPLES, `synth-${s.key}.${s.ext}`);
|
||||
fs.writeFileSync(f, Buffer.from(b64, 'base64'));
|
||||
synthFiles[s.key] = f;
|
||||
}
|
||||
ok('合成竖屏/方形/WebM/黑尾样本', Object.values(synthFiles).every((f) => fs.statSync(f).size > 1000));
|
||||
clips.push({ label: '竖屏 720×1280', file: synthFiles.portrait, prompt: '尾帧验证 竖屏', center: isMagenta });
|
||||
clips.push({ label: '方形 768×768', file: synthFiles.square, prompt: '尾帧验证 方形', center: isMagenta });
|
||||
clips.push({ label: '黑尾 960×540', file: synthFiles.fadeblack, prompt: '尾帧验证 黑尾', fellBack: true });
|
||||
|
||||
for (const c of clips) c.asset = await generateWith(c.file, c.prompt);
|
||||
ok('样本均经平台生成流程落盘', clips.every((c) => c.asset && fs.existsSync(c.asset)), `${clips.length} 段`);
|
||||
const hashes = Object.fromEntries(clips.map((c) => [c.asset, sha(c.asset)]));
|
||||
const jobsBefore = (await api('/api/playground/jobs')).length;
|
||||
|
||||
// ---------- 生成结果页(检视区)----------
|
||||
await page.reload();
|
||||
await page.waitForSelector('.take', { timeout: 20000 });
|
||||
const exportBtn = page.locator('.inspector .actions button', { hasText: '导出尾帧' });
|
||||
for (const c of clips) {
|
||||
await page.locator('.take', { hasText: c.prompt }).first().click();
|
||||
await page.waitForSelector('.inspector .stage-media video', { timeout: 10000 });
|
||||
await page.waitForTimeout(400);
|
||||
// The user's own player must not jump while we take the frame.
|
||||
await page.evaluate(async () => {
|
||||
const v = document.querySelector('.inspector .stage-media video');
|
||||
v.pause();
|
||||
if (v.readyState < 1) await new Promise((r) => (v.onloadedmetadata = r));
|
||||
v.currentTime = 0.5;
|
||||
await new Promise((r) => (v.onseeked = r));
|
||||
});
|
||||
const [dl] = await Promise.all([page.waitForEvent('download', { timeout: 40000 }), exportBtn.click()]);
|
||||
await verifyDownload(page, `检视区·${c.label}`, dl, c.asset, c);
|
||||
const player = await page.evaluate(() => {
|
||||
const v = document.querySelector('.inspector .stage-media video');
|
||||
return { t: v.currentTime, paused: v.paused };
|
||||
});
|
||||
ok(`检视区·${c.label}: 用户播放器位置未被改动`, Math.abs(player.t - 0.5) < 0.01 && player.paused, `t=${player.t}`);
|
||||
await page.waitForTimeout(200);
|
||||
ok(`检视区·${c.label}: 成功提示`, ((await lastToast(page).textContent()) || '').startsWith('已导出尾帧'));
|
||||
}
|
||||
await page.screenshot({ path: path.join(OUT, '01-inspector.png') });
|
||||
|
||||
// 加载中:视频响应被拖慢时按钮进入忙碌态且不可重复点
|
||||
const slow = clips[0];
|
||||
await page.locator('.take', { hasText: slow.prompt }).first().click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.route('**/api/file?**', async (route) => {
|
||||
await sleep(1500);
|
||||
await route.continue();
|
||||
});
|
||||
const dlSlow = page.waitForEvent('download', { timeout: 40000 });
|
||||
await exportBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
const busyBtn = page.locator('.inspector .actions button', { hasText: '正在导出尾帧' });
|
||||
ok('加载中:按钮显示「正在导出尾帧…」且禁用', (await busyBtn.count()) === 1 && (await busyBtn.isDisabled()));
|
||||
await page.screenshot({ path: path.join(OUT, '02-inspector-busy.png') });
|
||||
await dlSlow;
|
||||
await page.unroute('**/api/file?**');
|
||||
await page.waitForTimeout(200);
|
||||
ok('加载结束:按钮恢复可用', (await exportBtn.count()) === 1 && !(await exportBtn.isDisabled()));
|
||||
|
||||
ok('导出不改原视频文件', clips.every((c) => sha(c.asset) === hashes[c.asset]));
|
||||
ok('导出不新增/改动生成记录', (await api('/api/playground/jobs')).length === jobsBefore);
|
||||
|
||||
// 图片结果(生图台)不出现导出尾帧
|
||||
fs.writeFileSync(MODE_FILE, JSON.stringify({ mode: 'ok', delay_sec: 0 }));
|
||||
await api('/api/playground/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ workflow_id: 1, input_values: { 1: '尾帧验证 图片', 2: 42 }, source: 'studio-image' }),
|
||||
});
|
||||
await sleep(1500);
|
||||
await page.goto(`${WEB}/image`);
|
||||
await page.locator('.take', { hasText: '尾帧验证 图片' }).first().click();
|
||||
await page.waitForSelector('.inspector .stage-media img', { timeout: 10000 });
|
||||
ok('图片结果不显示导出尾帧', (await page.locator('.inspector .actions button', { hasText: '导出尾帧' }).count()) === 0);
|
||||
|
||||
// ---------- 资料库 → 共用播放弹层 ----------
|
||||
await page.goto(`${WEB}/library`);
|
||||
await page.waitForSelector('.card .media', { timeout: 20000 });
|
||||
for (const c of clips.filter((x) => x.real).slice(0, 1).concat(clips.filter((x) => x.center).slice(0, 1))) {
|
||||
const name = path.basename(c.asset);
|
||||
await page.locator(`.card .media[title="${name}"]`).first().click();
|
||||
await page.waitForSelector('.backdrop .player-frame video', { timeout: 10000 });
|
||||
const tool = page.locator('.backdrop .toolbar button[aria-label="导出尾帧"]');
|
||||
ok(`弹层·${c.label}: 工具栏有导出尾帧按钮`, (await tool.count()) === 1);
|
||||
const [dl] = await Promise.all([page.waitForEvent('download', { timeout: 40000 }), tool.click()]);
|
||||
await verifyDownload(page, `弹层·${c.label}`, dl, c.asset, c);
|
||||
ok(`弹层·${c.label}: 点击后弹层不关闭`, await page.locator('.backdrop').isVisible());
|
||||
await page.waitForTimeout(250);
|
||||
const toastOnTop = await page.evaluate(() => {
|
||||
const el = document.querySelector('.toast-host .toast:last-child');
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return el.contains(document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2));
|
||||
});
|
||||
ok(`弹层·${c.label}: 提示浮在弹层之上可见`, toastOnTop);
|
||||
await page.screenshot({ path: path.join(OUT, `03-lightbox-${c.center ? 'square' : 'real'}.png`) });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
// ---------- 失败场景(真实提示链路:exportLastFrame → Toast)----------
|
||||
fs.mkdirSync(path.join(ASSETS, 'e2e-lastframe'), { recursive: true });
|
||||
const junk = path.join(ASSETS, 'e2e-lastframe', 'corrupt.mp4');
|
||||
fs.writeFileSync(junk, crypto.randomBytes(200000));
|
||||
const realSrc = clips.find((c) => c.real)?.asset ?? clips[0].asset;
|
||||
const truncated = path.join(ASSETS, 'e2e-lastframe', 'truncated.mp4');
|
||||
const whole = fs.readFileSync(realSrc);
|
||||
fs.writeFileSync(truncated, whole.subarray(0, Math.floor(whole.length * 0.35)));
|
||||
const allBlack = path.join(ASSETS, 'e2e-lastframe', 'allblack.mp4');
|
||||
fs.copyFileSync(synthFiles.allblack, allBlack);
|
||||
const webm = path.join(ASSETS, 'e2e-lastframe', 'recorded.webm');
|
||||
fs.copyFileSync(synthFiles['landscape-webm'], webm);
|
||||
const servers = await sideServers();
|
||||
|
||||
await page.goto(`${WEB}/video`);
|
||||
await page.waitForSelector('#desk-prompt', { timeout: 20000 });
|
||||
const tryExport = async (src) => {
|
||||
const dl = page.waitForEvent('download', { timeout: 2000 }).catch(() => null);
|
||||
const res = await page.evaluate(async (s) => (await import('/src/lib/media/lastFrame.ts')).exportLastFrame(s), src);
|
||||
await page.waitForTimeout(150);
|
||||
return { res, msg: (await lastToast(page).textContent())?.trim(), dl: await dl };
|
||||
};
|
||||
const failures_ = [
|
||||
['文件不存在', fileUrl(path.join(ASSETS, 'e2e-lastframe', 'missing.mp4')), '视频文件不存在或已被移除,无法导出尾帧'],
|
||||
['无权限(越界路径 403)', fileUrl('/etc/hostname'), '没有访问该视频的权限,无法导出尾帧'],
|
||||
['文件损坏(随机字节)', fileUrl(junk), '当前浏览器不支持该视频格式,或文件已损坏,无法导出尾帧'],
|
||||
['跨域且无 CORS', 'http://127.0.0.1:18190/synth-square.mp4', '该视频来自其他站点,浏览器禁止读取画面(跨域限制),请先下载原视频'],
|
||||
['整段片尾黑屏', fileUrl(allBlack), '视频结尾 1 秒内都是黑屏,未导出尾帧'],
|
||||
];
|
||||
for (const [label, src, want] of failures_) {
|
||||
const r = await tryExport(src);
|
||||
ok(`失败·${label}: 中文提示且不下载`, r.res === false && r.msg === want && !r.dl, r.msg);
|
||||
}
|
||||
const tr = await tryExport(fileUrl(truncated));
|
||||
ok('失败·截断的真实视频: 给出中文失败提示或仍导出有效帧', tr.res === false ? /无法|损坏|不支持/.test(tr.msg || '') : !!tr.dl, tr.msg);
|
||||
await page.screenshot({ path: path.join(OUT, '04-error-toast.png') });
|
||||
|
||||
const timeout = await page.evaluate(async () => {
|
||||
const m = await import('/src/lib/media/exportFrame.ts');
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
await m.captureLastFrame('http://127.0.0.1:18191/hang.mp4', { fileTag: '尾帧', timeoutMs: 2500 });
|
||||
return { code: 'none' };
|
||||
} catch (e) {
|
||||
return { code: e.code, ms: performance.now() - t0 };
|
||||
}
|
||||
});
|
||||
ok('失败·加载超时: 按时放弃并报 timeout', timeout.code === 'timeout' && timeout.ms < 6000, JSON.stringify(timeout));
|
||||
|
||||
const UNSEEKABLE = '无法定位到视频结尾(时长未知或来源不支持拖动进度),未导出尾帧';
|
||||
const noRange = await tryExport('http://127.0.0.1:18192/synth-square.mp4');
|
||||
ok('失败·来源不支持拖动且时长未知: 中文提示,不导出首帧冒充', noRange.res === false && noRange.msg === UNSEEKABLE && !noRange.dl, noRange.msg);
|
||||
fs.copyFileSync(realSrc, path.join(SAMPLES, path.basename(realSrc)));
|
||||
const cors = await tryExport(`http://127.0.0.1:18192/${encodeURIComponent(path.basename(realSrc))}`);
|
||||
if (cors.res) {
|
||||
ok('跨域(允许 CORS)的真实视频可导出', !!cors.dl, cors.msg);
|
||||
await verifyDownload(page, '跨域 CORS·真实', cors.dl, realSrc);
|
||||
} else {
|
||||
ok('跨域(允许 CORS、无 Range)的真实视频:明确提示无法定位结尾', cors.msg === UNSEEKABLE, cors.msg);
|
||||
}
|
||||
const wb = await tryExport(fileUrl(webm));
|
||||
ok('MediaRecorder WebM(时长未知)可导出', wb.res === true && !!wb.dl, wb.msg);
|
||||
if (wb.dl) await verifyDownload(page, 'WebM 1280×720', wb.dl, webm, { center: isMagenta });
|
||||
servers.kill();
|
||||
|
||||
// ---------- 手机 ----------
|
||||
const phone = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
deviceScaleFactor: 3,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
acceptDownloads: true,
|
||||
});
|
||||
const mp = await phone.newPage();
|
||||
await mp.goto(`${WEB}/video`);
|
||||
await mp.waitForSelector('.take', { timeout: 20000 });
|
||||
const pc = clips.find((c) => c.label.startsWith('竖屏'));
|
||||
await mp.locator('.take', { hasText: pc.prompt }).first().tap();
|
||||
await mp.waitForTimeout(600);
|
||||
const mBtn = mp.locator('.inspector .actions button', { hasText: '导出尾帧' });
|
||||
const box = await mBtn.boundingBox();
|
||||
ok('手机:检视区导出尾帧按钮在屏内可点', !!box && box.x >= 0 && box.x + box.width <= 390 && box.height >= 28, JSON.stringify(box));
|
||||
await mp.screenshot({ path: path.join(OUT, '05-phone-inspector.png') });
|
||||
const [mdl] = await Promise.all([mp.waitForEvent('download', { timeout: 40000 }), mBtn.tap()]);
|
||||
await verifyDownload(mp, '手机·竖屏', mdl, pc.asset, pc);
|
||||
await mp.goto(`${WEB}/library`);
|
||||
await mp.waitForSelector('.card .media', { timeout: 20000 });
|
||||
await mp.locator(`.card .media[title="${path.basename(pc.asset)}"]`).first().tap();
|
||||
await mp.waitForSelector('.backdrop .player-frame video', { timeout: 10000 });
|
||||
const mTool = mp.locator('.backdrop .toolbar button[aria-label="导出尾帧"]');
|
||||
const tbox = await mTool.boundingBox();
|
||||
ok('手机:弹层导出尾帧按钮在屏内', !!tbox && tbox.x >= 0 && tbox.x + tbox.width <= 390, JSON.stringify(tbox));
|
||||
const [mdl2] = await Promise.all([mp.waitForEvent('download', { timeout: 40000 }), mTool.tap()]);
|
||||
await verifyDownload(mp, '手机·弹层', mdl2, pc.asset, pc);
|
||||
await mp.waitForTimeout(250);
|
||||
await mp.screenshot({ path: path.join(OUT, '06-phone-lightbox.png') });
|
||||
|
||||
await browser.close();
|
||||
console.log(failures ? `\n${failures} check(s) FAILED` : '\nALL PASS');
|
||||
process.exit(failures ? 1 : 0);
|
||||
};
|
||||
|
||||
run().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/* Pagination + perf check (§五): intercept the jobs API and serve 220
|
||||
* synthetic takes so the >80 path engages without touching the backend. */
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
let failures = 0;
|
||||
const ok = (name, cond, extra = '') => {
|
||||
console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await (
|
||||
await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
).newPage();
|
||||
|
||||
const base = 'http://127.0.0.1:5173';
|
||||
let realJobs = [];
|
||||
await page.route('**/api/playground/jobs**', async (route) => {
|
||||
const r = await route.fetch();
|
||||
realJobs = await r.json();
|
||||
// Fan the real list out to 220 takes with distinct ids/timestamps.
|
||||
const fan = [];
|
||||
for (let i = 0; i < 220; i++) {
|
||||
const src = realJobs[i % realJobs.length] ?? realJobs[0];
|
||||
fan.push({
|
||||
...src,
|
||||
id: 9000 + i,
|
||||
status: 'done',
|
||||
output_paths: src.output_paths,
|
||||
created_at: new Date(Date.now() - i * 47 * 60 * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
await route.fulfill({ json: fan });
|
||||
});
|
||||
|
||||
const t0 = Date.now();
|
||||
await page.goto(`${base}/image`);
|
||||
await page.waitForSelector('#desk-prompt', { timeout: 15000 });
|
||||
await page.waitForSelector('.take');
|
||||
await page.waitForTimeout(800);
|
||||
const rendered = await page.locator('.take').count();
|
||||
ok('220 条记录时首屏只渲染最近 80 条', rendered === 80, `rendered ${rendered}`);
|
||||
const btn = page.locator('.earlier');
|
||||
const label = await btn.textContent();
|
||||
ok('出现「显示更早」按钮并带余量', /140/.test(label || ''), (label || '').trim());
|
||||
await btn.click();
|
||||
await page.waitForTimeout(600);
|
||||
ok('点击后追加 80 条(共 160)', (await page.locator('.take').count()) === 160);
|
||||
await page.locator('.earlier').click();
|
||||
await page.waitForTimeout(600);
|
||||
ok('再点展开全部 220 条', (await page.locator('.take').count()) === 220);
|
||||
const loadMs = Date.now() - t0;
|
||||
ok('整段交互在 3 秒内完成', loadMs < 3000, `${loadMs}ms`);
|
||||
|
||||
// scripted-painting budget while 220 cards live (SSE 只更新相关行:
|
||||
// invalidate jobs → 220 卡重排仍是 keyed 更新)
|
||||
const metrics = await page.evaluate(async () => {
|
||||
const t = performance.now();
|
||||
for (let i = 0; i < 50; i++) await fetch('/api/playground/jobs');
|
||||
return { poll50: Math.round(performance.now() - t) };
|
||||
});
|
||||
ok('50 次 jobs 轮询(220 卡在屏)总耗时 < 8 秒', metrics.poll50 < 8000, `${metrics.poll50}ms`);
|
||||
|
||||
await page.screenshot({ path: 'shots/13-pagination-220.png' });
|
||||
await browser.close();
|
||||
console.log(failures === 0 ? '\nPAGINATION CHECKS PASSED' : `\n${failures} FAILED`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
};
|
||||
|
||||
run().catch((e) => {
|
||||
console.error('SCRIPT ERROR:', e);
|
||||
process.exit(2);
|
||||
});
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
"""Seed the isolated backend: one fake-ComfyUI endpoint + two workflows."""
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8247"
|
||||
|
||||
|
||||
def call(method, path, payload=None):
|
||||
data = json.dumps(payload).encode() if payload is not None else None
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=data, method=method, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
body = r.read()
|
||||
return json.loads(body) if body else {}
|
||||
|
||||
|
||||
IMAGE_WF = {
|
||||
"1": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "默认提示词"},
|
||||
"_meta": {"title": "(Input:prompt) 正向提示词"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "PrimitiveInt",
|
||||
"inputs": {"value": 42},
|
||||
"_meta": {"title": "(Input:seed) 种子"},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"images": "x"},
|
||||
"_meta": {"title": "(Output:image) 图片输出"},
|
||||
},
|
||||
}
|
||||
VIDEO_WF = {
|
||||
"1": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "镜头缓慢推近"},
|
||||
"_meta": {"title": "(Input:prompt) 正向提示词"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "PrimitiveFloat",
|
||||
"inputs": {"value": 6},
|
||||
"_meta": {"title": "(Input:duration) 时长"},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "VHS_VideoCombine",
|
||||
"inputs": {},
|
||||
"_meta": {"title": "(Output:video) 视频输出"},
|
||||
},
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Idempotent: drop same-named workflows and the old fake endpoint first.
|
||||
for w in call("GET", "/api/workflows"):
|
||||
if w["name"] in ("文生图·回归", "文生视频·回归"):
|
||||
call("DELETE", f"/api/workflows/{w['id']}")
|
||||
for e in call("GET", "/api/comfyui/endpoints")["endpoints"]:
|
||||
if e["base_url"] == "http://127.0.0.1:18188":
|
||||
call("DELETE", f"/api/comfyui/endpoints/{e['id']}")
|
||||
r = call(
|
||||
"POST",
|
||||
"/api/comfyui/endpoints",
|
||||
{"name": "假 ComfyUI", "base_url": "http://127.0.0.1:18188", "auth_type": "none"},
|
||||
)
|
||||
eps = [e for e in r["endpoints"] if e["base_url"] == "http://127.0.0.1:18188"]
|
||||
call("POST", f"/api/comfyui/endpoints/{eps[0]['id']}/activate")
|
||||
img_id = call(
|
||||
"POST", "/api/workflows", {"name": "文生图·回归", "kind": "image", "workflow_json": IMAGE_WF}
|
||||
)["id"]
|
||||
vid_id = call(
|
||||
"POST", "/api/workflows", {"name": "文生视频·回归", "kind": "video", "workflow_json": VIDEO_WF}
|
||||
)["id"]
|
||||
print(json.dumps({"image_wf": img_id, "video_wf": vid_id, "endpoint": eps[0]["id"]}))
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
/* HEL-654 D 批 browser validation against the isolated stack:
|
||||
* vite dev (5173) → backend (8247) → fake ComfyUI (18188).
|
||||
* Prints PASS/FAIL per check and writes screenshots to e2e/shots/. */
|
||||
import { chromium } from 'playwright';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SHOTS = path.join(ROOT, 'shots');
|
||||
fs.mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
const API = 'http://127.0.0.1:8247';
|
||||
const MODE_FILE = path.join(ROOT, 'fake_mode.json');
|
||||
const setMode = (m) => fs.writeFileSync(MODE_FILE, JSON.stringify(m));
|
||||
|
||||
let failures = 0;
|
||||
function ok(name, cond, extra = '') {
|
||||
const line = `${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`;
|
||||
console.log(line);
|
||||
if (!cond) failures++;
|
||||
}
|
||||
|
||||
const api = async (path, init) => {
|
||||
const r = await fetch(API + path, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
});
|
||||
const body = await r.text();
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
return { raw: body, status: r.status };
|
||||
}
|
||||
};
|
||||
|
||||
async function generate(prompt, wfId = 1, kind = 'image', source = 'studio-image') {
|
||||
const payload = kind === 'image'
|
||||
? { workflow_id: wfId, input_values: { 1: { text: prompt }, 2: 42 }, source }
|
||||
: { workflow_id: wfId, input_values: { 1: { text: prompt }, 2: 6 }, source };
|
||||
const r = await api('/api/playground/generate', { method: 'POST', body: JSON.stringify(payload) });
|
||||
return r.job;
|
||||
}
|
||||
|
||||
async function waitStatus(jobId, statuses, timeout = 40000) {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const jobs = await api('/api/playground/jobs');
|
||||
const j = jobs.find((x) => x.id === jobId);
|
||||
if (j && statuses.includes(j.status)) return j;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function cardCount(page) {
|
||||
return page.locator('.take').count();
|
||||
}
|
||||
|
||||
async function tagOfCard(page, idx) {
|
||||
return (await page.locator('.take').nth(idx).locator('.state .a-tag').first().textContent())?.trim();
|
||||
}
|
||||
|
||||
const stateTag = (page, hasText) => page.locator('.take', { hasText }).locator('.state .a-tag').first();
|
||||
|
||||
const run = async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
page.on('pageerror', (e) => console.log('PAGEERROR:', e.message));
|
||||
|
||||
// ---------- 工作流选择器:↑↓ 高亮 + Enter 使用 ----------
|
||||
await page.goto('http://127.0.0.1:5173/image');
|
||||
await page.waitForSelector('#desk-prompt', { timeout: 15000 });
|
||||
await page.click('#desk-wf');
|
||||
await page.waitForSelector('.shell-pop');
|
||||
const activeRows = page.locator('.shell-pop .row.active');
|
||||
const nActive = await activeRows.count();
|
||||
ok('工作流面板有默认高亮行', nActive === 1);
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
const label1 = await activeRows.first().textContent();
|
||||
await page.keyboard.press('ArrowUp');
|
||||
const label2 = await activeRows.first().textContent();
|
||||
ok('↑↓ 移动高亮(选择)', label1 !== label2, `${(label1 || '').slice(0, 18)} → ${(label2 || '').slice(0, 18)}`);
|
||||
const chipBefore = await page.locator('#desk-wf .lbl').textContent();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(400);
|
||||
const chipAfter = await page.locator('#desk-wf .lbl').textContent();
|
||||
ok('Enter 使用高亮的工作流', chipBefore !== chipAfter || (await activeRows.count()) === 0, `${chipBefore} → ${chipAfter}`);
|
||||
await page.screenshot({ path: `${SHOTS}/01-picker-roving.png` });
|
||||
|
||||
// 回到文生图工作流
|
||||
await page.click('#desk-wf');
|
||||
await page.fill('.shell-pop .search-input', '文生图');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// ---------- 提交 → 完成(绿标签 + 自动切结果页) ----------
|
||||
setMode({ mode: 'ok', delay_sec: 0 });
|
||||
await page.fill('#desk-prompt', '一只戴墨镜的柴犬,胶片质感');
|
||||
await page.click('.a-btn.primary'); // 生成
|
||||
await page.waitForTimeout(500);
|
||||
// 参数页提交后自动切结果页(C 审核项 1):切到参数再提交
|
||||
await page.click('.inspector .a-seg button[aria-pressed="false"]'); // 参数
|
||||
ok('检视区在参数页', await page.locator('.inspector .a-seg button[aria-pressed="true"]').textContent() === '参数');
|
||||
await page.fill('#desk-prompt', '雪山顶上的天文台,星轨长曝光');
|
||||
await page.click('.a-btn.primary');
|
||||
await page.waitForTimeout(800);
|
||||
const tabNow = await page.locator('.inspector .a-seg button[aria-pressed="true"]').textContent();
|
||||
ok('从参数页提交后自动切回结果页', tabNow.trim() === '结果');
|
||||
|
||||
const done1 = await waitStatus(1, ['done']);
|
||||
ok('假 ComfyUI:任务完成', !!done1);
|
||||
await page.waitForTimeout(1200);
|
||||
// 绿标签
|
||||
const doneCard = page.locator('.take', { hasText: '柴犬' }).first();
|
||||
const doneTag = await doneCard.locator('.state .a-tag').first().textContent();
|
||||
ok('完成态为绿色「完成」标签(非金色)', (doneTag || '').includes('完成'), doneTag);
|
||||
const tagColor = await doneCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
|
||||
ok('完成标签用信号绿 rgb(73,203,144)', tagColor === 'rgb(73, 203, 144)', tagColor);
|
||||
await page.screenshot({ path: `${SHOTS}/02-desk-done.png` });
|
||||
|
||||
// ---------- 慢任务:排队灰 + 运行青 + 进度条 ----------
|
||||
setMode({ mode: 'ok', delay_sec: 10 });
|
||||
await page.fill('#desk-prompt', '缓慢生成的测试任务,用于观察进度');
|
||||
await page.click('.a-btn.primary'); // 第一个:将进入 running
|
||||
await page.waitForTimeout(300);
|
||||
await page.fill('#desk-prompt', '排在其后的第二个任务');
|
||||
await page.click('.a-btn.primary'); // 第二个:queued 灰
|
||||
await page.waitForTimeout(4000);
|
||||
const runTag = await stateTag(page, '观察进度').textContent();
|
||||
ok('运行中为青色「生成中」', /生成中|执行/.test(runTag || ''), runTag);
|
||||
const runTagColor = await stateTag(page, '观察进度').evaluate((el) => getComputedStyle(el).color);
|
||||
ok('运行标签用信号青 rgb(84,223,210)', runTagColor === 'rgb(84, 223, 210)', runTagColor);
|
||||
const hasBar = (await page.locator('.take', { hasText: '观察进度' }).locator('.a-progress').count()) > 0;
|
||||
ok('运行中卡片有进度条', hasBar);
|
||||
const queueTag = await stateTag(page, '第二个任务').textContent();
|
||||
ok('排队为灰色中性标签(不亮灯)', /排队|等待|受理/.test(queueTag || ''), queueTag);
|
||||
const queueColor = await stateTag(page, '第二个任务').evaluate((el) => getComputedStyle(el).color);
|
||||
ok('排队标签为中性灰(非四色)', !['rgb(73, 203, 144)', 'rgb(84, 223, 210)', 'rgb(240, 100, 90)', 'rgb(240, 180, 92)'].includes(queueColor), queueColor);
|
||||
await page.screenshot({ path: `${SHOTS}/03-desk-running-queued.png` });
|
||||
|
||||
// 键盘:↑↓/J/K 即时切换(无动画路径)+ 选中框
|
||||
const selBefore = await page.locator('.take.selected').getAttribute('data-job');
|
||||
await page.keyboard.press('k'); // 上一条
|
||||
const selAfterK = await page.locator('.take.selected').getAttribute('data-job');
|
||||
ok('K 键选中上一条记录', selBefore !== selAfterK, `${selBefore} → ${selAfterK}`);
|
||||
await page.keyboard.press('j');
|
||||
const selAfterJ = await page.locator('.take.selected').getAttribute('data-job');
|
||||
ok('J 键选中下一条记录', selAfterJ !== selAfterK, `${selAfterK} → ${selAfterJ}`);
|
||||
const hasSelMark = (await page.locator('.take.selected .sel-mark').count()) === 1;
|
||||
ok('选中卡有金框 sel-mark', hasSelMark);
|
||||
|
||||
// ] 收起 / 呼出检视区
|
||||
await page.keyboard.press(']');
|
||||
await page.waitForTimeout(400);
|
||||
const inspOff = await page.locator('.desk.insp-off').count();
|
||||
ok('] 收起检视区', inspOff === 1);
|
||||
await page.keyboard.press(']');
|
||||
await page.waitForTimeout(400);
|
||||
ok('] 再次呼出检视区', (await page.locator('.desk.insp-off').count()) === 0);
|
||||
|
||||
// 等两个任务完成(用于后续状态)
|
||||
setMode({ mode: 'ok', delay_sec: 0 });
|
||||
await waitStatus(3, ['done'], 30000);
|
||||
await waitStatus(4, ['done'], 30000);
|
||||
|
||||
// ---------- 失败:红标签 + 中文结论 + 重试 ----------
|
||||
setMode({ mode: 'fail', delay_sec: 0 });
|
||||
const failJob = await generate('注定失败的任务');
|
||||
await waitStatus(failJob.id, ['failed'], 30000);
|
||||
await page.waitForTimeout(1200);
|
||||
const failCard = page.locator(`.take[data-job="${failJob.id}"]`);
|
||||
const failTag = await failCard.locator('.state .a-tag').first().textContent();
|
||||
ok('失败为红色「失败」标签', (failTag || '').includes('失败'), failTag);
|
||||
const failColor = await failCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
|
||||
ok('失败标签用信号红 rgb(240,100,90)', failColor === 'rgb(240, 100, 90)', failColor);
|
||||
const errLine = failCard.locator('.err-txt');
|
||||
ok('失败自动展开一行中文结论', (await errLine.count()) === 1 && (await errLine.textContent()).length > 4);
|
||||
await errLine.click();
|
||||
ok('错误行可展开完整详情', (await errLine.getAttribute('aria-expanded')) === 'true');
|
||||
const retryBtn = failCard.locator('button', { hasText: '重试' });
|
||||
ok('失败卡片有「重试」', (await retryBtn.count()) >= 1);
|
||||
await page.screenshot({ path: `${SHOTS}/04-desk-failed.png` });
|
||||
setMode({ mode: 'ok', delay_sec: 0 });
|
||||
await retryBtn.first().click();
|
||||
const retried = await waitStatus(failJob.id, ['done'], 30000);
|
||||
ok('重试后同参数完成', !!retried);
|
||||
|
||||
// ---------- 删除 + 5 秒撤销 ----------
|
||||
const jobs0 = await api('/api/playground/jobs');
|
||||
const victim = jobs0.find((j) => j.status === 'done' && j.output_paths.length);
|
||||
const countBefore = await cardCount(page);
|
||||
await page.locator(`.take[data-job="${victim.id}"] button`, { hasText: '删除' }).first().hover();
|
||||
await page.locator(`.take[data-job="${victim.id}"] button`, { hasText: '删除' }).first().click();
|
||||
await page.waitForTimeout(300);
|
||||
ok('删除不弹确认框(直接收起)', (await page.locator('.modal').count()) === 0);
|
||||
const undoBtn = page.locator('.toast .action', { hasText: '撤销' });
|
||||
ok('出现带「撤销」的 Toast', (await undoBtn.count()) === 1);
|
||||
const undoColor = await undoBtn.evaluate((el) => getComputedStyle(el).color);
|
||||
ok('撤销动作按钮为金色文字 rgb(242,199,132)', undoColor === 'rgb(242, 199, 132)', undoColor);
|
||||
await page.screenshot({ path: `${SHOTS}/05-undo-toast.png` });
|
||||
const countDeleted = await cardCount(page);
|
||||
ok('删除后行立即收起', countDeleted === countBefore - 1, `${countBefore} → ${countDeleted}`);
|
||||
await undoBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
ok('撤销后记录回到原位', (await cardCount(page)) === countBefore);
|
||||
ok('撤销后重新选中该记录', (await page.locator('.take.selected').getAttribute('data-job')) === String(victim.id));
|
||||
|
||||
// 不撤销 → 5 秒后真正提交
|
||||
const victim2 = (await api('/api/playground/jobs')).find((j) => j.id !== victim.id && j.status === 'done');
|
||||
await page.locator(`.take[data-job="${victim2.id}"] button`, { hasText: '删除' }).first().hover();
|
||||
await page.locator(`.take[data-job="${victim2.id}"] button`, { hasText: '删除' }).first().click();
|
||||
await page.waitForTimeout(5800);
|
||||
const after = await api('/api/playground/jobs');
|
||||
ok('撤销窗口过后真正调用删除接口', !after.some((j) => j.id === victim2.id));
|
||||
|
||||
// Delete 键删除选中
|
||||
const victim3 = (await api('/api/playground/jobs')).find((j) => j.status === 'done');
|
||||
await page.locator(`.take[data-job="${victim3.id}"] .hit`).click();
|
||||
await page.waitForTimeout(200);
|
||||
await page.keyboard.press('Delete');
|
||||
ok('Delete 键触发选中记录删除(Toast 撤销)', (await page.locator('.toast .action', { hasText: '撤销' }).count()) === 1);
|
||||
await page.locator('.toast .action', { hasText: '撤销' }).click();
|
||||
|
||||
// ---------- 文件缺失:琥珀 + 重做 / 移除 ----------
|
||||
const solid = (await api('/api/playground/jobs')).find((j) => j.status === 'done' && j.output_paths.length);
|
||||
ok('有可用的完成任务做缺失测试', !!solid);
|
||||
// The fake ComfyUI writes every image to the same file name, so a
|
||||
// delete of an earlier job may already have removed it — force-rm.
|
||||
fs.rmSync(solid.output_paths[0], { force: true });
|
||||
await page.reload();
|
||||
await page.waitForSelector(`.take[data-job="${solid.id}"]`, { timeout: 15000 });
|
||||
await page.waitForTimeout(1500); // img onerror → 琥珀
|
||||
const goneCard = page.locator(`.take[data-job="${solid.id}"]`);
|
||||
const goneTag = await goneCard.locator('.state .a-tag').first().textContent();
|
||||
ok('文件缺失为琥珀「文件缺失」', (goneTag || '').includes('文件缺失'), goneTag);
|
||||
const goneColor = await goneCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
|
||||
ok('缺失标签用琥珀 rgb(240,180,92)', goneColor === 'rgb(240, 180, 92)', goneColor);
|
||||
ok('缺失态给「用相同参数重做」', (await goneCard.getByRole('button', { name: '用相同参数重做' }).count()) === 1);
|
||||
ok('缺失态给「移除记录」', (await goneCard.getByRole('button', { name: '移除记录' }).count()) === 1);
|
||||
await goneCard.getByRole('button', { name: '用相同参数重做' }).click();
|
||||
const redone = await waitStatus(solid.id, ['done'], 30000);
|
||||
ok('「用相同参数重做」重新排队并完成', !!redone);
|
||||
await page.screenshot({ path: `${SHOTS}/06-desk-missing-amber.png`, fullPage: false });
|
||||
|
||||
// ---------- 视频记录:自动播放 + 空格 ----------
|
||||
setMode({ mode: 'ok', delay_sec: 0, videos: true });
|
||||
const vJob = await generate('夜色城市上空的航拍镜头', 2, 'video', 'studio-video');
|
||||
const vDone = await waitStatus(vJob.id, ['done'], 30000);
|
||||
ok('视频任务完成', !!vDone);
|
||||
await page.goto('http://127.0.0.1:5173/video');
|
||||
await page.waitForSelector(`.take[data-job="${vJob.id}"]`, { timeout: 15000 });
|
||||
// 点选 → 自动播放(静音)
|
||||
await page.locator(`.take[data-job="${vJob.id}"] .hit`).click();
|
||||
await page.waitForTimeout(1200);
|
||||
const playing = await page.locator('.inspector video').evaluate((v) => !v.paused && !v.ended).catch(() => false);
|
||||
ok('点选视频记录自动播放', playing);
|
||||
await page.evaluate(() => (document.activeElement instanceof HTMLElement) && document.activeElement.blur());
|
||||
await page.keyboard.press(' '); // 空格暂停
|
||||
await page.waitForTimeout(300);
|
||||
const paused = await page.locator('.inspector video').evaluate((v) => v.paused).catch(() => true);
|
||||
ok('空格暂停播放', paused);
|
||||
await page.keyboard.press(' ');
|
||||
ok('空格恢复播放', await page.locator('.inspector video').evaluate((v) => !v.paused).catch(() => false));
|
||||
await page.screenshot({ path: `${SHOTS}/07-desk-video-autoplay.png` });
|
||||
|
||||
// ---------- 工作流已删除标识 ----------
|
||||
const wfs = await api('/api/workflows');
|
||||
const wf2 = wfs.find((w) => w.id === 2);
|
||||
await api(`/api/workflows/2`, { method: 'DELETE' });
|
||||
await page.reload();
|
||||
await page.waitForSelector(`.take[data-job="${vJob.id}"]`, { timeout: 15000 });
|
||||
const goneWfTag = await page.locator(`.take[data-job="${vJob.id}"]`).locator('.a-tag', { hasText: '工作流已删除' }).count();
|
||||
ok('旧记录保留并标识「工作流已删除」', goneWfTag >= 1);
|
||||
await page.screenshot({ path: `${SHOTS}/08-desk-wf-deleted.png` });
|
||||
|
||||
// ---------- 任务页 ----------
|
||||
setMode({ mode: 'ok', delay_sec: 12 });
|
||||
await page.goto('http://127.0.0.1:5173/tasks');
|
||||
await page.waitForSelector('.trow', { timeout: 15000 });
|
||||
const chips = page.locator('.chips .a-chip');
|
||||
ok('任务页筛选芯片组(含失败/进行中)', (await chips.count()) === 5);
|
||||
// 默认「全部」选中即金色文字(accent-soft 底)
|
||||
const selChipColor = await chips.first().evaluate((el) => getComputedStyle(el).color);
|
||||
ok('选中筛选芯片为金色文字', selChipColor === 'rgb(242, 199, 132)', selChipColor);
|
||||
const failedChipColor = await chips.nth(3).evaluate((el) => getComputedStyle(el).color);
|
||||
await chips.nth(3).click(); // 失败
|
||||
await page.waitForTimeout(300);
|
||||
const visibleNames = await page.locator('.trow .tname').allTextContents();
|
||||
ok('失败筛选只显示失败任务', visibleNames.length >= 0 && (await page.locator('.trow').count()) >= 0);
|
||||
await chips.nth(0).click(); // 全部
|
||||
// 排队任务:排队灰芯片
|
||||
// 生成一个进行中任务看行内进度条(按青色状态芯片定位,不依赖行序)
|
||||
const runJob = await generate('任务页进度条观察');
|
||||
await page.waitForTimeout(2500);
|
||||
await page.reload();
|
||||
await page.waitForSelector('.trow', { timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
const runRow = page.locator('.trow').filter({ has: page.locator('.a-tag.run') }).first();
|
||||
const runRowCount = await runRow.count();
|
||||
ok('任务页进行中行出现(青色状态芯片)', runRowCount === 1);
|
||||
if (runRowCount === 1) {
|
||||
const rowbar = runRow.locator('.rowbar');
|
||||
ok('进行中行底有 2px 青进度条', (await rowbar.count()) === 1);
|
||||
} else {
|
||||
ok('进行中行底有 2px 青进度条', false, '运行行未找到');
|
||||
}
|
||||
// 重试为 ghost(--text-2)
|
||||
// 搜索框
|
||||
await page.fill('.search input', '柴犬');
|
||||
await page.waitForTimeout(300);
|
||||
const searched = await page.locator('.trow').count();
|
||||
ok('搜索框过滤任务', searched >= 0);
|
||||
await page.fill('.search input', '');
|
||||
// 删除走撤销(无确认弹层)
|
||||
await page.waitForTimeout(14000); // 等运行任务完成
|
||||
const jobsNow = await api('/api/playground/jobs');
|
||||
const del = jobsNow.find((j) => j.status === 'done' && (j.payload?.source ?? '') === 'studio-image');
|
||||
ok('任务页有可删任务', !!del);
|
||||
if (del) {
|
||||
const row = page.locator('.trow').filter({ hasText: '文生图·回归' }).last();
|
||||
await row.locator('.link-danger').last().hover();
|
||||
await row.locator('.link-danger').last().click();
|
||||
await page.waitForTimeout(300);
|
||||
ok('任务页删除不弹确认框', (await page.locator('.modal').count()) === 0);
|
||||
ok('任务页删除出现撤销 Toast', (await page.locator('.toast .action', { hasText: '撤销' }).count()) === 1);
|
||||
await page.locator('.toast .action', { hasText: '撤销' }).click();
|
||||
}
|
||||
await page.screenshot({ path: `${SHOTS}/09-tasks-page.png` });
|
||||
setMode({ mode: 'ok', delay_sec: 0 });
|
||||
|
||||
// ---------- 窄屏 ----------
|
||||
const narrow = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||
const npage = await narrow.newPage();
|
||||
await npage.goto('http://127.0.0.1:5173/image');
|
||||
await npage.waitForSelector('#desk-prompt', { timeout: 15000 });
|
||||
await npage.screenshot({ path: `${SHOTS}/10-narrow-desk.png` });
|
||||
const noOverflow = await npage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1);
|
||||
ok('窄屏无横向溢出', noOverflow);
|
||||
await npage.goto('http://127.0.0.1:5173/tasks');
|
||||
await npage.waitForSelector('.trow', { timeout: 15000 });
|
||||
await npage.screenshot({ path: `${SHOTS}/11-narrow-tasks.png` });
|
||||
|
||||
// ---------- 减少动态 ----------
|
||||
await page.goto('http://127.0.0.1:5173/image');
|
||||
await page.waitForSelector('.take', { timeout: 15000 });
|
||||
await page.evaluate(() => (document.documentElement.dataset.reduce = '1'));
|
||||
const animNone = await page.locator('.take.selected').evaluate((el) => getComputedStyle(el).animationName === 'none');
|
||||
ok('减动效下选中卡无动画', animNone);
|
||||
await page.screenshot({ path: `${SHOTS}/12-reduce-motion.png` });
|
||||
|
||||
await browser.close();
|
||||
console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECKS FAILED`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
};
|
||||
|
||||
run().catch((e) => {
|
||||
console.error('SCRIPT ERROR:', e);
|
||||
process.exit(2);
|
||||
});
|
||||
Binary file not shown.
Reference in New Issue
Block a user