Compare commits

...
Author SHA1 Message Date
施工员andmultica-agent 0ee8280ddb feat(theme): HEL-680 S1 全站无彩灰阶视觉底座——theme-1 灰阶 token 换值、取景框角线选中、相纸白边与「定影」完成态
Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 11:01:21 +08:00
leefer 1fc8bdb8bb Add 146 ComfyUI workflow JSONs organized by category (0-基础处理/1-语音/2-音乐/3-图像/4-视频/5-数字人/6-动作迁移) 2026-10-10 20:30:14 +08:00
总工 2c3c05277b HEL-672: merge H3 first-last frame (feat/hel-671-first-last-frame) 2026-10-04 13:53:26 +08:00
施工员andmultica-agent 2689f5354a feat(comfy): H3 图生视频支持首尾帧——可选尾帧插槽、接线克隆 LoadImage 与改名迁移 (HEL-671)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-04 13:40:11 +08:00
总工 7bd8c29faa HEL-670: merge last-frame export (feat/hel-669-last-frame) 2026-10-01 23:19:08 +08:00
施工员andmultica-agent 4f3293fc02 feat(media): 视频一键导出尾帧 PNG(生成结果页 + 共用播放弹层)(HEL-669)
- lib/media/exportFrame.ts:隐藏 video 取片尾前约 1 帧,黑帧/空帧逐级回退(≤1s),
  原生尺寸 canvas 导出 PNG;加载/损坏/404/403/跨域/超时/无法定位结尾分类报错
- lib/media/lastFrame.ts:下载 + 中文 Toast 反馈
- Inspector.svelte 操作区、ImageLightbox.svelte 视频工具栏加「导出尾帧」
- ToastHost 层级提到灯箱之上,弹层内反馈可见
- 七语言文案;npm test 新增 check-export-frame;e2e/lastframe.mjs 浏览器验收

Co-authored-by: multica-agent <github@multica.ai>
2026-10-01 23:12:34 +08:00
总工 4c87c6dea0 HEL-664: merge F-batch remaining pages & site-wide polish (feat/hel-656-f-pages) 2026-09-30 17:08:05 +08:00
总工andmultica-agent d882fd62d8 feat(ui): F批——其余页面融合收口与整站规范统一 (HEL-656)
- 移除 Calliope 旧壳死代码:/project/[id] 旧四段编辑器页面与 9 个遗留组件
  (NavRail/StoryStage/AssetsStage/ScriptStage/QueueStage/ActivityPanel/
  JobRow/AssetThumb/ProjectCard),/projects 死页面(308→/ 已有)
- 素材库 /library 迁入 AppShell 新壳:a-chip 筛选、ui/Modal、ConfirmDialog
  实心红两级删除、窄屏单列;移除 A 时期 308→/ 重定向;导航轨/更多菜单新增入口
- 智能体子系统去杂色:agentPalette 默认紫→中性、回退色板收敛为 avatar 五色族;
  AgentChat 气泡按 §3.2/3.6(用户 bg-3/Agent bg-2+line)、进行中指示改青、
  紫色空态图标清除;AgentComposer 天蓝/金芯片收敛
- 首页/工作台/五面板/StageStepper/PromptBlock/JobChip 类名收敛到全局 a-btn/
  a-input/a-tag/field;Stepper 补金色当前下划+已确认✓;镜头序号按规范用
  accent-lo+mono;分页/工作台选择页金色越界改中性或青
- 金色纪律修正:次级文字操作(重试/测试/链接)与必填星标、任务「测试」标签
  去金色;「默认」金标签按 §3.9 保留
- 断点收敛:1023/1279/900/780/700/640 → 1180/760(1440 增强保留)
- canvas/build-scene 可用级 ≤760 纵向堆叠适配 + 硬编码色/旧 fallback 清理;
  AppHeader 去 Calliope 品牌与死链
- favicon 收口:static/favicon.png 金色品牌图标,移除 204 stub 与构建豁免
- i18n 七语种 +3 key(shell.navLibrary/library.loadError/library.subtitle)全对齐
- 新增 e2e/fpages.mjs:F 页面三档走查/重定向/五段导入编辑/规范色断言 38 项

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 16:29:47 +08:00
总工 b4c7334f45 HEL-663: merge E-batch import & test-run drawers (feat/hel-655-e-drawers) 2026-09-30 15:17:19 +08:00
总工andmultica-agent 6064bd805d feat(ui): E批——工作流导入与试运行右侧抽屉 + 四步检查 (HEL-655)
- 新增 ui/Drawer.svelte 右侧抽屉基元:380/420px、--d-drawer 280ms
  --ease-drawer 可反向、Esc/Tab 焦点圈、遮罩点击关闭、减动效仅留 ≤120ms 淡变
- 新增 WorkflowImportDrawer.svelte:导入两步向导抽屉化,四步检查
  (解析 JSON→识别节点→核对模型→建立输入槽)逐条出现(80ms 错峰)、
  ✓绿/…青/✕红/待确认琥珀 + 中文原因 + 下一步建议 + 可展开错误 + 重新检查 +
  「查看原始 JSON」折叠;沿用 /api/workflows/analyze 原判定与保存边界
- WorkflowTestDialog.svelte 居中弹窗改右侧抽屉:运行中青芯片 + 进度条 +
  mono 阶段日志滚动区,结果缩略图横排、失败红 + 长错误可展开;
  逻辑(确认弹层、dry_run 拒绝、产物隔离、重复提交复用)不变
- connections 页:导入入口统一为抽屉按钮,次级文字操作(测试/重新检查/更改)
  改 ghost(HEL-649 v2 §1.2),连接卡片改 §3.7 网格;移除内联导入块与死样式
- i18n 七语种各 +16 键(wfd.*)计数一致
- e2e:fake_comfy.py 支持 object_info/no_outputs 场景;新增 import.mjs
  43 项浏览器回归全过;D 批 shoot.mjs 54/54 复跑通过

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 14:42:56 +08:00
总工 c3deaf90bb HEL-662: merge D-batch record stream (feat/hel-654-d-records) 2026-09-30 13:27:05 +08:00
总工andmultica-agent 949cd2ecad HEL-654: D 批——记录流状态语义、金框结果切换、撤销删除与任务页重皮
- 点选金框滑动 + 检视区 150ms 交叉淡变;↑↓/J/K 即时切换、空格播放、
  Delete 删除、] 检视区、浮层让位;新记录入场/完成金光/失败抖动,减动效只余短淡变
- 单条删除 5 秒撤销(Toast 金色动作钮),窗口过后才提交 DELETE,离页 flush;
  任务页同队列同步;工作流删除保留旧记录并标识「工作流已删除」
- 红/琥珀界线:失败红 + 中文结论展开;done 但文件取不到 → 琥珀「文件缺失」
  + 用相同参数重做/移除记录
- 参数页提交自动切结果页;工作流选择器 ↑↓ 真高亮 + Enter 使用(ShellPop arrowRoving 开关)
- 记录流 80 条分页(220 条专项:50 次轮询 490ms);任务页 §3.5 芯片+搜索+
  行内进度条+ghost 重试;i18n 七语种 +17 键

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 13:02:51 +08:00
总工 0c620e61fe HEL-661: merge C-batch generate desk (feat/hel-653-c-generate) 2026-09-30 11:39:13 +08:00
6f4d50f4f0 HEL-653: C 批生成台——拆分输入台/工作流选择/输入槽/记录流/检视区
- StudioGenerate 拆为 generate/ 下的 Composer、WorkflowPicker、SlotTray、ParamChip、
  TakeFeed/TakeCard、Inspector、ParamsPanel、ContextBar,逻辑收进 schema/takes/context
- 输入槽只按工作流 JSON 实际接线渲染;常用参数芯片 + 高级参数折叠,显示原值与已改标记
- 工作台素材/镜头卡新增「在生成台调参」,带项目上下文进入;结果不回填(后端无目标)
- 共享样式(a-chip/a-seg/a-slot/a-tile 等)进 app.css;ShellPop 支持向上弹出
- /image、/video 仍是同一生成台,按用途预选默认工作流与筛选

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 10:59:46 +08:00
8b03291327 HEL-652: B 批导航壳层——60px 图标轨、顶栏与手机底栏
- 216px 文字侧栏改为 60px 图标轨:金色渐变 Logo、当前页金色指示条滑动、350ms 延迟悬停提示,底部连接/模型/设置/语言
- 顶栏:标题+副标题、项目范围胶囊(全部项目列表/最近项目)、Ctrl/⌘K 搜索、减少动态开关、任务托盘胶囊、本次连接胶囊(名称+延迟)
- ≤760px:隐藏图标轨,底部 项目/创作/生成/更多 四个标签,更多为底部抽屉(其余页面、搜索、减少动态、语言);删除「手机端暂不适配」拦截
- 新增 ShellPop(Esc/↑↓/Tab 循环/外部点击关闭、焦点归还)与 CommandPalette(复用 Modal)
- 减少动态:跟随系统设置,另可手动开启并持久化(data-reduce)
- .a-darkroom 背景接入 --page-glow;新增壳层尺寸 token 与 .a-kbd/.a-menu-* 公共类
- 生图/生视频顶栏去掉与全局胶囊重复的连接状态(参数栏仍显示所选连接与队列)
- 路由 URL 不变(/projects 仍重定向到项目总览 /)

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 09:26:36 +08:00
总工andmultica-agent 225fcb49ea fix(ui): edited-draft marker uses amber warn per HEL-658 ruling (HEL-657)
Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 08:49:53 +08:00
255 changed files with 17680 additions and 13506 deletions
@@ -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:
+39
View File
@@ -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
+1 -1
View File
@@ -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 ."
},
+156
View File
@@ -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>
@@ -17,6 +17,10 @@
allowUpload?: boolean;
/** Force required-field error rings visible (e.g. after a blocked Generate). */
showErrors?: boolean;
/** Show each field's workflow value (原值) and mark fields that differ from it. */
showOrig?: boolean;
/** Extra per-field messages keyed by nodeId (e.g. range checks); shown with showErrors. */
errors?: Record<string, string>;
/** Emits labels of required-but-empty inputs whenever the set changes. */
onValidityChange?: (missing: string[]) => void;
onChange?: (values: Record<string, string | number>) => void;
@@ -30,10 +34,23 @@
assetOptions = [],
allowUpload = false,
showErrors = false,
showOrig = false,
errors = {},
onValidityChange,
onChange,
}: Props = $props();
function hasOrig(inp: ComfyDynamicInput): boolean {
return inp.defaultValue !== undefined && inp.defaultValue !== '';
}
function isChanged(inp: ComfyDynamicInput): boolean {
if (!showOrig) return false;
const v = values[inp.nodeId];
if (isBlank(v)) return hasOrig(inp);
return !hasOrig(inp) || String(v) !== String(inp.defaultValue);
}
// Unique per mounted form so label/id pairs never collide across instances.
const uid = Math.random().toString(36).slice(2, 8);
let touched = $state<Record<string, boolean>>({});
@@ -80,6 +97,11 @@
return isMissing(inp) && (showErrors || !!touched[inp.nodeId]);
}
function extraError(inp: ComfyDynamicInput): string | null {
const msg = errors[inp.nodeId]?.trim();
return msg && !isMissing(inp) && (showErrors || !!touched[inp.nodeId]) ? msg : null;
}
function thumbFor(current: string): string | null {
if (!current) return null;
const opt = assetOptions.find((o) => o.path === current);
@@ -224,7 +246,9 @@
{#each inputs as inp (inp.nodeId)}
{@const current = String(values[inp.nodeId] ?? '')}
{@const id = inputId(inp.nodeId)}
{@const errored = showError(inp)}
{@const extra = extraError(inp)}
{@const errored = showError(inp) || !!extra}
{@const changed = isChanged(inp)}
<div class="field">
<label class="label" for={id}>
{inp.label}
@@ -235,6 +259,9 @@
{#if inp.role}<span class="role">{inp.role}</span>{/if}
<span class="kind">{inp.kind}</span>
{/if}
{#if showOrig && hasOrig(inp)}
<span class="orig">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
{/if}
</label>
{#if readonly}
<div class="readonly">{values[inp.nodeId] ?? inp.defaultValue ?? '—'}</div>
@@ -243,6 +270,7 @@
{id}
rows="3"
class:invalid={errored}
class:changed
aria-required={inp.required || undefined}
aria-invalid={errored || undefined}
value={current}
@@ -254,6 +282,7 @@
{id}
type="number"
class:invalid={errored}
class:changed
aria-required={inp.required || undefined}
aria-invalid={errored || undefined}
value={values[inp.nodeId] ?? ''}
@@ -264,6 +293,7 @@
<select
{id}
class:invalid={errored}
class:changed
value={current || String(inp.defaultValue ?? '')}
onchange={(e) => setValue(inp.nodeId, e.currentTarget.value)}
onblur={() => markTouched(inp.nodeId)}
@@ -445,6 +475,7 @@
{id}
type="text"
class:invalid={errored}
class:changed
aria-required={inp.required || undefined}
aria-invalid={errored || undefined}
value={current}
@@ -453,7 +484,7 @@
/>
{/if}
{#if errored}
<span class="error-msg" role="alert">{t('cdf.requiredMsg', { label: inp.label })}</span>
<span class="error-msg" role="alert">{extra ?? t('cdf.requiredMsg', { label: inp.label })}</span>
{/if}
</div>
{/each}
@@ -516,11 +547,23 @@
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
input.changed,
textarea.changed,
select.changed {
border-color: var(--accent-line);
}
input.invalid,
textarea.invalid,
select.invalid {
border-color: var(--error);
}
.orig {
float: right;
font-weight: 400;
font-size: var(--fs-mini);
font-family: var(--mono);
color: var(--text-muted);
}
input.invalid:focus-visible,
textarea.invalid:focus-visible,
select.invalid:focus-visible {
@@ -1,7 +1,9 @@
<script lang="ts">
import SafeMedia from '$lib/components/SafeMedia.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import { t } from '$lib/i18n.svelte';
import { exportLastFrame } from '$lib/media/lastFrame';
interface Props {
src: string | null;
@@ -61,6 +63,17 @@
}
return alt ? `${alt.trim().replace(/\s+/g, '-').toLowerCase()}.png` : 'media';
});
let frameBusy = $state(false);
async function exportTail() {
if (!src || frameBusy) return;
frameBusy = true;
try {
await exportLastFrame(src);
} finally {
frameBusy = false;
}
}
</script>
<svelte:window onkeydown={src ? onKeydown : undefined} />
@@ -90,6 +103,26 @@ aria-label={zoomed ? t('lightbox.fitToScreen') : t('lightbox.zoom100')}
<Icon name="zoom-in" size={17} />
</button>
{/if}
{#if isVideo}
<button
class="tool"
type="button"
aria-label={frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
title={frameBusy ? t('lastFrame.exporting') : `${t('lastFrame.export')} · ${t('lastFrame.hint')}`}
disabled={frameBusy}
aria-busy={frameBusy}
onclick={(e) => {
e.stopPropagation();
exportTail();
}}
>
{#if frameBusy}
<Spinner size="sm" />
{:else}
<Icon name="image" size={17} />
{/if}
</button>
{/if}
<a
class="tool"
href={src}
@@ -1,164 +0,0 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { jobsApi, type Job } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import { progressFor } from '$lib/jobProgress';
import { toast } from '$lib/toast';
import Button from './ui/Button.svelte';
import Icon from './ui/Icon.svelte';
import type { IconName } from './ui/icons';
import ProgressBar from './ui/ProgressBar.svelte';
import StatusChip from './ui/StatusChip.svelte';
interface Props {
job: Job;
/** Optional override for the leading label (defaults to "Kind #id"). */
label?: string;
}
let { job, label }: Props = $props();
const client = useQueryClient();
let busy = $state(false);
const kindIcon: IconName = $derived(
job.kind === 'video'
? 'video'
: job.kind === 'image'
? 'image'
: job.kind === 'export'
? 'film'
: 'queue',
);
const kindLabel = $derived(
job.kind === 'export'
? t('job.exportFilm')
: job.kind.charAt(0).toUpperCase() + job.kind.slice(1),
);
const title = $derived(label ?? t('job.title', { kind: kindLabel, id: String(job.id) }));
const active = $derived(job.status === 'pending' || job.status === 'running');
const entry = $derived(progressFor(job.id));
async function refresh() {
await client.invalidateQueries({ queryKey: ['jobs'] });
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
}
async function cancel() {
busy = true;
try {
await jobsApi.cancel(job.id);
await refresh();
toast.info(t('job.cancelled', { id: String(job.id) }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('job.cancelFailed'));
} finally {
busy = false;
}
}
async function retry() {
busy = true;
try {
await jobsApi.retry(job.id);
await refresh();
toast.success(t('job.requeued', { id: String(job.id) }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('job.retryFailed'));
} finally {
busy = false;
}
}
</script>
<div class="job-row">
<span class="kind" aria-hidden="true"><Icon name={kindIcon} size={14} /></span>
<div class="main">
<div class="top">
<span class="label" {title}>{title}</span>
<StatusChip status={job.status} />
</div>
{#if active}
<div class="bar">
<ProgressBar
size="sm"
value={entry?.progress ?? 0}
indeterminate={job.status === 'running' && entry == null}
label={entry?.message}
/>
</div>
{:else if job.status === 'failed' && job.error}
<p class="err" title={job.error}>{job.error}</p>
{/if}
</div>
<div class="actions">
{#if active}
<Button size="sm" variant="ghost" loading={busy} title={t('job.cancelJob')} onclick={cancel}>
<Icon name="close" size={13} />
</Button>
{:else if job.status === 'failed'}
<Button size="sm" variant="ghost" loading={busy} title={t('job.retryJob')} onclick={retry}>
<Icon name="retry" size={13} />
</Button>
{/if}
</div>
</div>
<style>
.job-row {
display: grid;
grid-template-columns: 20px 1fr auto;
gap: 8px;
align-items: start;
padding: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
}
.kind {
display: flex;
align-items: center;
justify-content: center;
height: 20px;
color: var(--text-muted);
}
.main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 20px;
}
.label {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar {
padding-bottom: 2px;
}
.err {
margin: 0;
font-size: 11px;
line-height: 1.4;
color: var(--error);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.actions {
display: flex;
align-items: center;
}
</style>
@@ -1,137 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import type { IconName } from '$lib/components/ui/icons';
const items = $derived(
[
{ id: 'story', key: 'nav.story', icon: 'story' },
{ id: 'assets', key: 'nav.assets', icon: 'assets' },
{ id: 'script', key: 'nav.script', icon: 'script' },
{ id: 'video', key: 'nav.video', icon: 'video' },
] as { id: string; key: string; icon: IconName }[],
);
interface Props {
active?: string;
onSelect?: (id: string) => void;
/** Optional per-stage counts — a nonzero value shows a small pill on the icon. */
badges?: Record<string, number>;
}
let { active = 'story', onSelect, badges = {} }: Props = $props();
</script>
<nav class="nav" aria-label={t('nav.projectStages')}>
<button
class="nav-item home"
title={t('nav.projects')}
aria-label={t('nav.backProjects')}
onclick={() => goto('/projects')}
>
<span class="icon"><Icon name="home" size={20} /></span>
<span class="label">{t('nav.home')}</span>
</button>
<div class="divider" aria-hidden="true"></div>
{#each items as item (item.id)}
{@const count = badges[item.id] ?? 0}
<button
class="nav-item"
class:active={active === item.id}
title={t(item.key)}
aria-current={active === item.id ? 'page' : undefined}
onclick={() => onSelect?.(item.id)}
>
<span class="icon">
<Icon name={item.icon} size={20} />
{#if count > 0}
<span class="badge" aria-label={t('nav.countActive', { count })}>
{count > 9 ? '9+' : count}
</span>
{/if}
</span>
<span class="label">{t(item.key)}</span>
</button>
{/each}
</nav>
<style>
.nav {
width: 64px;
background: var(--bg-surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-md) 0;
gap: var(--space-sm);
flex-shrink: 0;
}
.divider {
width: 32px;
height: 1px;
background: var(--border);
margin: 2px 0 4px;
}
.nav-item {
width: 56px;
border: none;
background: transparent;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 0;
cursor: pointer;
color: var(--text-muted);
font-size: 11px;
font-family: inherit;
transition: all 0.15s;
}
.nav-item:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
.nav-item.active {
background: var(--accent);
color: white;
box-shadow: 0 0 16px var(--accent-glow);
}
.nav-item.home:hover {
color: var(--accent);
}
.nav-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.icon {
position: relative;
display: inline-flex;
line-height: 1;
}
.badge {
position: absolute;
top: -6px;
right: -12px;
min-width: 15px;
height: 15px;
padding: 0 4px;
box-sizing: border-box;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 9px;
font-weight: 700;
line-height: 15px;
text-align: center;
box-shadow: 0 0 0 2px var(--bg-surface);
}
.nav-item.active .badge {
background: #fff;
color: var(--accent);
box-shadow: 0 0 0 2px var(--accent);
}
</style>
@@ -1,478 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { assetUrl, projects, type Project } from '$lib/api';
import { toast } from '$lib/toast';
import Button from '$lib/components/ui/Button.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
import { t } from '$lib/i18n.svelte';
interface Props {
project: Project;
}
const { project }: Props = $props();
const client = useQueryClient();
const stats = $derived(
project.stats ?? {
scene_count: 0,
character_count: 0,
asset_ready_count: 0,
asset_total_count: 0,
},
);
// Explicit project cover wins; otherwise fall back to the first asset image.
// The project prop is stable for the lifetime of a card, so the eager
// queryKey capture below is intentional.
// svelte-ignore state_referenced_locally
const assetsQuery = createQuery({
queryKey: ['assets', project.id],
queryFn: () => projects.getAssets(project.id),
staleTime: 60_000,
});
const thumbUrl = $derived.by(() => {
if (project.cover_path) return assetUrl(project.cover_path);
const data = $assetsQuery.data;
if (!data) return null;
const char = data.characters.find((c) => c.portrait_path || c.sheet_path);
const path =
char?.portrait_path ??
char?.sheet_path ??
data.locations.find((l) => l.reference_image_path)?.reference_image_path ??
null;
return assetUrl(path);
});
// A broken cover (deleted file, 404) falls back to the gradient tile.
let thumbFailedFor = $state<string | null>(null);
function relativeTime(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '';
const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
if (mins < 1) return t('projectCard.timeJustNow');
if (mins < 60) return t('projectCard.timeM', { count: mins });
const hours = Math.round(mins / 60);
if (hours < 24) return t('projectCard.timeH', { count: hours });
const days = Math.round(hours / 24);
if (days < 30) return t('projectCard.timeD', { count: days });
const months = Math.round(days / 30);
if (months < 12) return t('projectCard.timeMo', { count: months });
return t('projectCard.timeY', { count: Math.round(months / 12) });
}
const updatedLabel = $derived(relativeTime(project.updated_at));
const updatedTitle = $derived(new Date(project.updated_at).toLocaleString());
// Overflow menu (⋯) — closes on outside click, focus leaving, or Esc.
let menuOpen = $state(false);
let menuEl = $state<HTMLDivElement | null>(null);
$effect(() => {
if (!menuOpen) return;
const onDocClick = (e: MouseEvent) => {
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') menuOpen = false;
};
const onFocusIn = (e: FocusEvent) => {
if (menuEl && !menuEl.contains(e.target as Node)) menuOpen = false;
};
window.addEventListener('click', onDocClick);
window.addEventListener('keydown', onKey);
document.addEventListener('focusin', onFocusIn);
return () => {
window.removeEventListener('click', onDocClick);
window.removeEventListener('keydown', onKey);
document.removeEventListener('focusin', onFocusIn);
};
});
let renameOpen = $state(false);
let renameValue = $state('');
const renameMutation = createMutation({
mutationFn: (title: string) => projects.update(project.id, { title }),
onSuccess: (updated) => {
client.invalidateQueries({ queryKey: ['projects'] });
toast.success(t('projectCard.renamedTo', { title: updated.title }));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('projectCard.renameFailed'));
},
});
function openRename() {
menuOpen = false;
renameValue = project.title;
renameOpen = true;
}
function submitRename() {
const title = renameValue.trim();
renameOpen = false;
if (!title || title === project.title) return;
$renameMutation.mutate(title);
}
let deleteOpen = $state(false);
const deleteMutation = createMutation({
mutationFn: () => projects.delete(project.id),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['projects'] });
toast.success(t('projectCard.deleted', { title: project.title }));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('projectCard.deleteFailed'));
},
});
</script>
<article class="project-card">
<button
type="button"
class="card-main"
aria-label={t('projectCard.open', { title: project.title })}
onclick={() => goto(`/project/${project.id}`)}
>
<div class="card-thumb">
{#if thumbUrl && thumbFailedFor !== thumbUrl}
<img
class="thumb-img"
src={thumbUrl}
alt=""
loading="lazy"
onerror={() => (thumbFailedFor = thumbUrl)}
/>
{:else}
<div class="card-thumb-icon"><Icon name="video" size={26} /></div>
{/if}
<div class="card-status">
<StatusChip status={project.status} label={t(`projectCard.status.${project.status}`)} />
</div>
</div>
<div class="card-body">
<div class="card-title">
{project.title}
<span class="open-arrow">→</span>
</div>
<p class="card-desc">{project.idea || t('projectCard.noIdea')}</p>
<div class="card-meta">
<div class="card-stats">
<span class="card-stat">
<Icon name="script" size={13} />
{t('projectCard.scenes', { count: stats.scene_count })}
</span>
<span class="card-stat">
<Icon name="assets" size={13} />
{t('projectCard.characters', { count: stats.character_count })}
</span>
<span class="card-stat">
<Icon name="image" size={13} />
{t('projectCard.assets', {
ready: stats.asset_ready_count,
total: stats.asset_total_count,
})}
</span>
</div>
<span title={updatedTitle}>{updatedLabel}</span>
</div>
</div>
</button>
<div class="menu-wrap" bind:this={menuEl}>
<button
type="button"
class="menu-btn"
aria-label={t('projectCard.actions', { title: project.title })}
aria-haspopup="menu"
aria-expanded={menuOpen}
onclick={() => (menuOpen = !menuOpen)}
>
⋯
</button>
{#if menuOpen}
<div class="menu" role="menu" aria-label={t('projectCard.menuLabel')}>
<button type="button" role="menuitem" class="menu-item" onclick={openRename}>
<Icon name="edit" size={14} />
{t('common.rename')}
</button>
<button
type="button"
role="menuitem"
class="menu-item"
onclick={() => {
menuOpen = false;
goto(`/project/${project.id}?stage=assets`);
}}
>
<Icon name="image" size={14} />
{t('projectCard.changeCover')}
</button>
<button
type="button"
role="menuitem"
class="menu-item danger"
onclick={() => {
menuOpen = false;
deleteOpen = true;
}}
>
<Icon name="trash" size={14} />
{t('common.delete')}
</button>
</div>
{/if}
</div>
</article>
<Modal bind:open={renameOpen} title={t('projectCard.renameTitle')}>
<form
onsubmit={(e) => {
e.preventDefault();
submitRename();
}}
>
<label class="field">
<span class="field-label">{t('common.title')}</span>
<input class="field-input" bind:value={renameValue} required />
</label>
</form>
{#snippet footer()}
<Button variant="ghost" onclick={() => (renameOpen = false)}>{t('common.cancel')}</Button>
<Button
variant="primary"
disabled={!renameValue.trim() || $renameMutation.isPending}
onclick={submitRename}
>
{t('common.rename')}
</Button>
{/snippet}
</Modal>
<ConfirmDialog
bind:open={deleteOpen}
title={t('projectCard.deleteTitle')}
message={t('projectCard.deleteMessage', { title: project.title })}
confirmLabel={t('common.delete')}
danger
onconfirm={() => $deleteMutation.mutate()}
/>
<style>
.project-card {
position: relative;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: all 0.2s;
}
.project-card:hover {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
}
.project-card:hover .open-arrow {
opacity: 1;
transform: translateX(0);
}
.card-main {
display: block;
width: 100%;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.card-main:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.card-thumb {
height: 170px;
background: linear-gradient(135deg, var(--bg-elevated) 0%, #1a1625 100%);
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.card-thumb::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 30%, rgba(139, 92, 246, 0.12) 0%, transparent 40%),
radial-gradient(circle at 80% 70%, rgba(59, 130, 246, 0.08) 0%, transparent 40%);
}
.thumb-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.card-thumb-icon {
width: 64px;
height: 64px;
border-radius: var(--radius-md);
background: rgba(139, 92, 246, 0.12);
border: 1px solid rgba(139, 92, 246, 0.2);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
}
.card-status {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
.card-body {
padding: var(--space-md);
}
.card-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 var(--space-xs);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-sm);
}
.open-arrow {
color: var(--accent);
opacity: 0;
transform: translateX(-8px);
transition: all 0.2s;
font-size: 14px;
}
.card-desc {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.5;
margin: 0 0 var(--space-md);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-sm);
font-size: 12px;
color: var(--text-muted);
}
.card-stats {
display: flex;
gap: var(--space-md);
flex-wrap: wrap;
}
.card-stat {
display: flex;
align-items: center;
gap: 4px;
}
.menu-wrap {
position: absolute;
top: 8px;
left: 8px;
z-index: 3;
}
.menu-btn {
width: 30px;
height: 30px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: rgba(10, 10, 12, 0.55);
color: var(--text-secondary);
font-size: 16px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.15s;
}
.project-card:hover .menu-btn,
.menu-btn:focus-visible,
.menu-btn[aria-expanded='true'] {
opacity: 1;
}
.menu-btn:hover {
background: var(--bg-elevated);
color: var(--text-primary);
border-color: var(--border);
}
.menu-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.menu {
position: absolute;
top: 34px;
left: 0;
min-width: 140px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
padding: 4px;
display: flex;
flex-direction: column;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
cursor: pointer;
text-align: left;
}
.menu-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.menu-item.danger {
color: var(--error);
}
.menu-item.danger:hover {
background: rgba(239, 68, 68, 0.12);
}
.menu-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
@media (prefers-reduced-motion: reduce) {
.project-card:hover {
transform: none;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,867 +0,0 @@
<script lang="ts">
import { tick } from 'svelte';
import { goto } from '$app/navigation';
import { toStore } from 'svelte/store';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { assetUrl, jobsApi, projects, type Job, type Scene, type Clip } from '$lib/api';
import { estimateTargetSeconds } from '$lib/durationBudget';
import { agentDeepLink } from '$lib/agentTasks';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import Button from '$lib/components/ui/Button.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
interface Props {
projectId: number;
}
let { projectId }: Props = $props();
const client = useQueryClient();
let editing = $state<Scene | null>(null);
let editOpen = $state(false);
let highlightId = $state<number | null>(null);
let adding = $state(false);
let deletingId = $state<number | null>(null);
let sceneToDelete = $state<Scene | null>(null);
let deleteOpen = $state(false);
const scenesQuery = createQuery(
toStore(() => ({
queryKey: ['scenes', projectId],
queryFn: () => projects.getScenes(projectId),
})),
);
// Shared cache key with QueueStage — clip job state shows up on scene cards
// too. Refreshed via SSE invalidation from the project page, not polling.
const jobsQuery = createQuery(
toStore(() => ({
queryKey: ['jobs', projectId],
queryFn: () => jobsApi.list(projectId),
})),
);
// Cache-shared with the parent's story query — only used for the target-length hint.
const storyQuery = createQuery(
toStore(() => ({
queryKey: ['story', projectId],
queryFn: () => projects.getStory(projectId),
})),
);
// Cache-shared with AssetsStage — resolves scene location chips.
const assetsQuery = createQuery(
toStore(() => ({
queryKey: ['assets', projectId],
queryFn: () => projects.getAssets(projectId),
})),
);
const scenes = $derived($scenesQuery.data?.scenes ?? []);
const sceneCount = $derived(scenes.length);
const totalClips = $derived(scenes.reduce((n, s) => n + (s.clips?.length ?? 0), 0));
const totalSec = $derived($scenesQuery.data?.estimated_duration_sec ?? 0);
const targetSec = $derived.by(() => {
const target = $storyQuery.data?.project?.target_duration;
return target ? estimateTargetSeconds(target) : 0;
});
const busy = $derived(adding || deletingId != null);
// ── Break into shots (coverage expansion) ────────────────────────────
let expandingIds = $state<number[]>([]);
let expandingAll = $state(false);
let expandedIds = $state<number[]>([]);
function isExpanded(scene: Scene): boolean {
return (scene.clips?.length ?? 0) > 1 || expandedIds.includes(scene.id);
}
async function breakIntoShots(scene: Scene) {
if (expandingIds.length > 0 || expandingAll) return;
expandingIds = [...expandingIds, scene.id];
try {
const res = await projects.expandClips(projectId, { scene_ids: [scene.id] });
expandedIds = [...new Set([...expandedIds, scene.id])];
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(
t('script.toast.breakIntoShots', {
num: scene.order_index,
clips: res.scenes?.[0]?.clip_count ?? '?',
}),
);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.breakFail'));
} finally {
expandingIds = expandingIds.filter((id) => id !== scene.id);
}
}
async function breakAllIntoShots() {
if (expandingIds.length > 0 || expandingAll) return;
if (
!window.confirm(t('script.toast.breakAllConfirm', { count: sceneCount }))
)
return;
expandingAll = true;
try {
const res = await projects.expandClips(projectId, { all: true });
expandedIds = scenes.map((s) => s.id);
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.boardExpanded', { count: res.total_clips }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.breakAllFail'));
} finally {
expandingAll = false;
}
}
async function deleteClip(clip: Clip, scene: Scene) {
try {
await projects.deleteClip(projectId, clip.id);
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.deletedShot', { num: `${scene.order_index}.${clip.order_index}` }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.deleteClipFail'));
}
}
const saveMutation = createMutation({
mutationFn: () =>
projects.updateScene(projectId, editing!.id, {
heading: editing!.heading,
action: editing!.action,
dialog: editing!.dialog,
duration_sec: editing!.duration_sec,
}),
onSuccess: () => {
editing = null;
editOpen = false;
client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.sceneSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('script.toast.saveSceneFail'));
},
});
// Chain-from-previous toggle — persists on the scene; consumed at render time.
let chainPendingId = $state<number | null>(null);
async function toggleChain(scene: Scene) {
if (chainPendingId != null) return;
chainPendingId = scene.id;
try {
await projects.updateScene(projectId, scene.id, {
chain_from_prev: !scene.chain_from_prev,
});
await client.invalidateQueries({ queryKey: ['scenes'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.updateSceneFail'));
} finally {
chainPendingId = null;
}
}
function jobForScene(sceneId: number): Job | undefined {
const jobs = ($jobsQuery.data ?? []).filter(
(j) => j.scene_id === sceneId && j.kind === 'video',
);
if (jobs.length === 0) return undefined;
return [...jobs].sort((a, b) => b.id - a.id)[0];
}
function jobForClip(clipId: number): Job | undefined {
const jobs = ($jobsQuery.data ?? []).filter(
(j) => (j as Job & { clip_id?: number | null }).clip_id === clipId && j.kind === 'video',
);
if (jobs.length === 0) return undefined;
return [...jobs].sort((a, b) => b.id - a.id)[0];
}
/** Per-scene production status — only uses fields the API actually exposes. */
function sceneStatus(scene: Scene): { status: string; label: string } {
const job = jobForScene(scene.id);
if (job && (job.status === 'pending' || job.status === 'running')) {
return { status: 'generating', label: t('script.status.rendering') };
}
if (job?.status === 'failed') return { status: 'failed', label: t('script.status.renderFailed') };
if (scene.video_path || job?.status === 'done') {
return { status: 'ready', label: t('script.status.clipReady') };
}
return { status: 'idle', label: t('script.status.noClip') };
}
function locationName(scene: Scene): string | null {
if (scene.location_id == null) return null;
const locs = $assetsQuery.data?.locations ?? [];
return locs.find((l) => l.id === scene.location_id)?.name ?? null;
}
function avatarFor(char: { portrait_path: string | null; sheet_path: string | null }) {
return assetUrl(char.portrait_path ?? char.sheet_path);
}
function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
return parts
.slice(0, 2)
.map((p) => p[0].toUpperCase())
.join('');
}
/** Hide a broken avatar image (portrait moved/deleted on disk), revealing initials. */
function hideBrokenAvatar(e: Event) {
const img = e.currentTarget as HTMLImageElement | null;
if (img) img.style.display = 'none';
}
function formatClock(sec: number): string {
const s = Math.max(0, Math.round(sec));
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${r.toString().padStart(2, '0')}`;
}
async function move(sceneId: number, dir: -1 | 1) {
if (busy) return;
const list = $scenesQuery.data?.scenes ?? [];
const idx = list.findIndex((s) => s.id === sceneId);
const swap = idx + dir;
if (idx < 0 || swap < 0 || swap >= list.length) return;
const ids = list.map((s) => s.id);
[ids[idx], ids[swap]] = [ids[swap], ids[idx]];
await projects.reorderScenes(projectId, ids);
client.invalidateQueries({ queryKey: ['scenes'] });
}
/** Hand off script regeneration to a fresh, project-linked agent chat. */
function requestRegenerate() {
if (busy) return;
goto(agentDeepLink(projectId, 'script'));
}
function openEdit(scene: Scene) {
editing = { ...scene };
editOpen = true;
}
async function addScene() {
if (busy) return;
adding = true;
try {
const existing = $scenesQuery.data?.scenes ?? [];
const nextIndex = existing.length + 1;
const created = await projects.createScene(projectId, {
order_index: nextIndex,
heading: `NEW SCENE ${nextIndex}`,
action: '',
dialog: '',
duration_sec: 5,
});
// Keep order_index contiguous 1..n so Regenerate sees the real board size
const ids = [...existing.map((s) => s.id), created.id];
await projects.reorderScenes(projectId, ids);
await client.invalidateQueries({ queryKey: ['scenes'] });
await client.refetchQueries({ queryKey: ['scenes', projectId] });
editing = { ...created, order_index: nextIndex, heading: `NEW SCENE ${nextIndex}` };
editOpen = true;
highlightId = created.id;
await tick();
document
.getElementById(`scene-${created.id}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
toast.success(t('script.toast.sceneAdded', { num: nextIndex }));
window.setTimeout(() => {
if (highlightId === created.id) highlightId = null;
}, 2800);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.addSceneFail'));
} finally {
adding = false;
}
}
function requestDelete(scene: Scene) {
if (busy) return;
sceneToDelete = scene;
deleteOpen = true;
}
async function confirmDeleteScene() {
const scene = sceneToDelete;
sceneToDelete = null;
if (!scene || busy) return;
const label = scene.heading?.trim() || `Scene #${scene.order_index}`;
deletingId = scene.id;
try {
await projects.deleteScene(projectId, scene.id);
if (editing?.id === scene.id) {
editing = null;
editOpen = false;
}
const remaining = ($scenesQuery.data?.scenes ?? [])
.filter((s) => s.id !== scene.id)
.map((s) => s.id);
if (remaining.length > 0) {
await projects.reorderScenes(projectId, remaining);
}
await client.invalidateQueries({ queryKey: ['scenes'] });
toast.success(t('script.toast.deleted', { name: label }));
} catch (err) {
toast.error(err instanceof Error ? err.message : t('script.toast.deleteFailed'));
} finally {
deletingId = null;
}
}
</script>
<header class="stage-header">
<div>
<h2>3. Script</h2>
<p class="muted">
Estimated duration: {formatClock(totalSec)}
{#if targetSec > 0}
/ target ~{formatClock(targetSec)}
{/if}
{#if sceneCount > 0}
· {sceneCount} scenes
{#if totalClips > 0}
/ {totalClips} shot clips
{/if}
{/if}
</p>
</div>
<div class="stage-actions">
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
<Icon name="plus" size={14} /> {t('script.addScene')}
</Button>
<Button
variant="secondary"
disabled={busy || expandingAll || expandingIds.length > 0 || sceneCount === 0}
loading={expandingAll}
onclick={breakAllIntoShots}
title={t('script.breakAllTitle')}
>
<Icon name="film" size={14} /> {t('script.breakAllIntoShots')}
</Button>
<Button variant="primary" disabled={busy} onclick={requestRegenerate}>
<Icon name="sparkle" size={15} /> {t('script.regenerate')}
</Button>
</div>
</header>
<div class="script-panel">
{#if $scenesQuery.isLoading}
<div class="card">{t('script.loadingScenes')}</div>
{:else if sceneCount === 0}
<EmptyState
title={t('script.noScenesTitle')}
body={t('script.noScenesBody')}
>
{#snippet icon()}
<Icon name="script" size={28} />
{/snippet}
{#snippet action()}
<Button variant="secondary" disabled={busy} loading={adding} onclick={addScene}>
<Icon name="plus" size={14} /> {t('script.addScene')}
</Button>
{/snippet}
</EmptyState>
{:else}
{#each scenes as scene, i (scene.id)}
{@const st = sceneStatus(scene)}
{@const locName = locationName(scene)}
<article
id="scene-{scene.id}"
class="card scene"
class:fresh={highlightId === scene.id}
class:deleting={deletingId === scene.id}
>
<div class="scene-head">
<div class="scene-title">
<span class="grip" aria-hidden="true"><Icon name="drag" size={14} /></span>
<span class="num">#{scene.order_index}</span>
<strong>{scene.heading || t('script.untitledScene')}</strong>
{#if highlightId === scene.id}
<span class="fresh-tag">{t('script.justAdded')}</span>
{/if}
<StatusChip status={st.status} label={st.label} />
</div>
<div class="actions">
<button
type="button"
class="icon-btn"
aria-label={t('script.moveSceneUp')}
title={t('script.moveSceneUp')}
disabled={busy || i === 0}
onclick={() => move(scene.id, -1)}
>
<Icon name="chevron-up" size={15} />
</button>
<button
type="button"
class="icon-btn"
aria-label={t('script.moveSceneDown')}
title={t('script.moveSceneDown')}
disabled={busy || i === sceneCount - 1}
onclick={() => move(scene.id, 1)}
>
<Icon name="chevron-down" size={15} />
</button>
<Button variant="secondary" size="sm" disabled={busy} onclick={() => openEdit(scene)}>
{t('script.edit')}
</Button>
<Button
variant="danger"
size="sm"
disabled={busy}
loading={deletingId === scene.id}
onclick={() => requestDelete(scene)}
>
{t('script.delete')}
</Button>
</div>
</div>
{#if scene.action}
<p class="muted">{scene.action}</p>
{:else}
<p class="muted empty-line">{t('script.noActionYet')}</p>
{/if}
{#if scene.dialog}
<pre class="dialog">{scene.dialog}</pre>
{/if}
<div class="clip-block">
<div class="clip-head">
<span class="clip-title">
<Icon name="film" size={12} />
{(scene.clips ?? []).length}
{((scene.clips ?? []).length === 1 ? t('script.shotClipSingular') : t('script.shotClipPlural', { n: (scene.clips ?? []).length }))}
{#if (scene.clips ?? []).some((c) => c.clip_path)}
· {t('script.renderedCount', { count: (scene.clips ?? []).filter((c) => c.clip_path).length })}
{/if}
</span>
<Button
variant="secondary"
size="sm"
disabled={busy || expandingIds.length > 0 || expandingAll}
loading={expandingIds.includes(scene.id)}
onclick={() => breakIntoShots(scene)}
title={isExpanded(scene) ? t('script.rebreakTitle') : t('script.breakTitle')}
>
{isExpanded(scene) ? t('script.rebreak') : t('script.break')}
</Button>
</div>
{#if isExpanded(scene) && (scene.clips ?? []).length > 1}
<ul class="clip-list">
{#each scene.clips ?? [] as clip (clip.id)}
{@const clipJob = jobForClip(clip.id)}
<li class="clip-row">
<span class="clip-num" class:ready={Boolean(clip.clip_path)}>
#{scene.order_index}.{clip.order_index}
</span>
<span class="clip-desc">{clip.description || t('script.untitledShot')}</span>
{#if clip.shot_size}
<span class="chip">{clip.shot_size}</span>
{/if}
{#if clipJob && (clipJob.status === 'pending' || clipJob.status === 'running')}
<span class="chip chip-render">{t('script.rendering')}</span>
{:else if clip.clip_path}
<span class="chip chip-ok">✓ {t('script.clipDone')}</span>
{/if}
{#if clip.duration_sec}
<span class="clip-dur">{clip.duration_sec}s</span>
{/if}
<button
type="button"
class="icon-btn"
aria-label={t('script.deleteShotAria', { id: `${scene.order_index}.${clip.order_index}` })}
title={t('script.deleteShotTitle')}
onclick={() => deleteClip(clip, scene)}
>
<Icon name="trash" size={13} />
</button>
</li>
{/each}
</ul>
{/if}
</div>
<div class="chips">
{#each scene.characters ?? [] as c (c.id)}
{@const avatar = avatarFor(c)}
<span class="chip">
<span class="avatar">
<span class="initials" aria-hidden="true">{initialsOf(c.name)}</span>
{#if avatar}
<img src={avatar} alt="" loading="lazy" onerror={hideBrokenAvatar} />
{/if}
</span>
{c.name}
</span>
{/each}
{#if locName}
<span class="chip"><Icon name="folder" size={12} /> {locName}</span>
{/if}
{#if scene.duration_sec}
<span class="chip"><Icon name="clock" size={12} /> {formatClock(scene.duration_sec)}</span>
{/if}
{#if i > 0}
<button
type="button"
class="chip chip-toggle"
class:chip-on={Boolean(scene.chain_from_prev)}
disabled={chainPendingId === scene.id}
title={t('script.chainTitle')}
onclick={() => toggleChain(scene)}
>
<Icon name="film" size={12} />
{Boolean(scene.chain_from_prev) ? t('script.chainOn') : t('script.chainOff')}
</button>
{/if}
</div>
</article>
{/each}
{/if}
</div>
<Modal
bind:open={editOpen}
title={editing ? t('script.editSceneNumbered', { n: editing.order_index }) : t('script.editScene')}
onclose={() => (editing = null)}
>
{#if editing}
<label class="field">
<span class="field-label">{t('script.headingField')}</span>
<input class="field-input" bind:value={editing.heading} placeholder={t('script.headingPlaceholder')} />
</label>
<label class="field">
<span class="field-label">{t('script.actionField')}</span>
<textarea
class="field-textarea"
bind:value={editing.action}
rows="4"
placeholder={t('script.actionPlaceholder')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('script.dialogField')}</span>
<textarea
class="field-textarea"
bind:value={editing.dialog}
rows="4"
placeholder={t('script.dialogPlaceholder')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('script.durationField')}</span>
<input class="field-input" type="number" bind:value={editing.duration_sec} min="1" />
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (editOpen = false)}>{t('common.cancel')}</Button>
<Button
variant="primary"
loading={$saveMutation.isPending}
onclick={() => $saveMutation.mutate()}
>
{t('script.save')}
</Button>
{/snippet}
</Modal>
<ConfirmDialog
bind:open={deleteOpen}
title={t('script.deleteSceneTitle')}
message={sceneToDelete
? t('script.deleteSceneMessage', {
heading: sceneToDelete.heading?.trim() || t('script.sceneNumber', { n: sceneToDelete.order_index }),
})
: ''}
confirmLabel={t('script.delete')}
danger
onconfirm={() => void confirmDeleteScene()}
oncancel={() => (sceneToDelete = null)}
/>
<style>
.stage-header {
/* Pins inside the stage scroll container so Add Scene / Regenerate
stay reachable while scrolling long scripts. */
position: sticky;
top: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-lg);
gap: var(--space-md);
padding: 8px 0 12px;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
}
.stage-header h2 {
margin: 0 0 4px;
font-size: 22px;
font-weight: 700;
}
.stage-actions {
display: flex;
gap: 8px;
}
.muted {
color: var(--text-secondary);
margin: 0;
}
.script-panel {
position: relative;
min-height: 220px;
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-bottom: var(--space-md);
scroll-margin-top: 84px;
scroll-margin-bottom: 24px;
}
.scene {
transition:
border-color 0.25s ease,
box-shadow 0.25s ease,
opacity 0.2s ease;
}
.scene.fresh {
border-color: var(--accent);
box-shadow:
0 0 0 1px var(--accent),
0 0 24px var(--accent-glow);
animation: scene-in 0.45s ease-out;
}
.scene.deleting {
opacity: 0.45;
}
@keyframes scene-in {
from {
opacity: 0.35;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.scene.fresh {
animation: none;
}
}
.scene-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 8px;
}
.scene-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.grip {
display: inline-flex;
color: var(--text-muted);
cursor: grab;
opacity: 0.6;
}
.num {
color: var(--accent);
font-weight: 700;
}
.fresh-tag {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
background: var(--accent-glow);
border-radius: 999px;
padding: 2px 8px;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.icon-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
border-color: var(--border);
}
.icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.icon-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.empty-line {
font-style: italic;
color: var(--text-muted);
}
.dialog {
background: var(--bg-elevated);
padding: 12px;
border-radius: var(--radius-sm);
white-space: pre-wrap;
font-family: var(--font-mono);
font-size: 13px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
color: var(--text-secondary);
}
.chip-toggle {
cursor: pointer;
font-family: inherit;
}
.chip-toggle:hover {
color: var(--text-primary);
border-color: #52525b;
}
.chip-toggle:disabled {
opacity: 0.6;
cursor: wait;
}
.chip-on {
color: var(--accent);
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--bg-elevated));
}
.clip-block {
margin-top: 10px;
border-top: 1px dashed var(--border);
padding-top: 8px;
}
.clip-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.clip-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.clip-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.clip-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 4px 8px;
font-size: 12px;
}
.clip-num {
font-family: var(--font-mono);
font-weight: 700;
color: var(--text-secondary);
flex-shrink: 0;
}
.clip-num.ready {
color: var(--accent);
}
.clip-desc {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
}
.clip-dur {
color: var(--text-muted);
flex-shrink: 0;
}
.chip-ok {
color: var(--accent);
border-color: var(--accent);
}
.chip-render {
color: var(--warning, #eab308);
border-color: var(--warning, #eab308);
}
.avatar {
position: relative;
width: 20px;
height: 20px;
border-radius: 50%;
overflow: hidden;
background: var(--bg-surface);
border: 1px solid var(--border);
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.avatar img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.initials {
font-size: 9px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.02em;
}
</style>
@@ -1,947 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
import {
projects,
type Beat,
type Character,
type Item,
type Location,
type StoryData,
} from '$lib/api';
import {
buildStoryDraftPromptPreview,
estimateTargetSeconds,
recommendBeatCount,
recommendSceneCount,
} from '$lib/durationBudget';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { agentDeepLink } from '$lib/agentTasks';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import StatusChip from '$lib/components/ui/StatusChip.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
interface Props {
projectId: number;
story: StoryData;
configured: boolean;
onGoAssets?: () => void;
}
let { projectId, story, configured, onGoAssets }: Props = $props();
const client = useQueryClient();
const GENRES = [
'Adventure / Mystery',
'Drama',
'Sci-Fi',
'Fantasy',
'Horror',
'Romance',
'Thriller',
];
const LENGTHS_HINT = $derived(t('story.lengthsHint'));
const TONES = [
'Cinematic, atmospheric',
'Dark, tense',
'Whimsical, warm',
'Gritty, realistic',
'Epic, sweeping',
];
const GENRE_KEYS: Record<string, string> = {
'Adventure / Mystery': 'genres.adventure',
Drama: 'genres.drama',
'Sci-Fi': 'genres.scifi',
Fantasy: 'genres.fantasy',
Horror: 'genres.horror',
Romance: 'genres.romance',
Thriller: 'genres.thriller',
};
const TONE_KEYS: Record<string, string> = {
'Cinematic, atmospheric': 'tones.cinematic',
'Dark, tense': 'tones.dark',
'Whimsical, warm': 'tones.whimsical',
'Gritty, realistic': 'tones.gritty',
'Epic, sweeping': 'tones.epic',
};
function genreLabel(v: string): string {
return t(GENRE_KEYS[v] ?? v);
}
function toneLabel(v: string): string {
return t(TONE_KEYS[v] ?? v);
}
const EXAMPLE = {
idea: 'A lone cartographer discovers a map that rewrites itself every midnight, leading her into a forgotten city beneath the desert.',
genre: 'Adventure / Mystery',
tone: 'Cinematic, atmospheric',
target_duration: '2 minutes',
};
let editingBeat = $state<Beat | null>(null);
let editingChar = $state<Character | null>(null);
let editingLoc = $state<Location | null>(null);
let editingItem = $state<Item | null>(null);
let beatModalOpen = $state(false);
let charModalOpen = $state(false);
let locModalOpen = $state(false);
let itemModalOpen = $state(false);
let confirmExampleOpen = $state(false);
let deletingBeat = $state<Beat | null>(null);
let beatDeleteOpen = $state(false);
let addingBeat = $state(false);
// Static defaults — the $effect below syncs all drafts from the story prop.
let ideaDraft = $state('');
let genreDraft = $state(GENRES[0]);
let toneDraft = $state(TONES[0]);
let lengthDraft = $state('2 minutes');
let showDraftPrompt = $state(false);
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
let saveState = $state<SaveState>('idle');
let savedTimer: ReturnType<typeof setTimeout> | null = null;
const beatBudget = $derived(recommendBeatCount(lengthDraft));
const sceneBudget = $derived(recommendSceneCount(lengthDraft));
const secsBudget = $derived(estimateTargetSeconds(lengthDraft));
const draftPromptPreview = $derived(
buildStoryDraftPromptPreview({
title: story.project.title,
idea: ideaDraft,
genre: genreDraft,
tone: toneDraft,
targetDuration: lengthDraft,
}),
);
$effect(() => {
ideaDraft = story.project.idea ?? '';
genreDraft = story.project.genre || GENRES[0];
toneDraft = story.project.tone || TONES[0];
lengthDraft = story.project.target_duration || '2 minutes';
});
$effect(() => {
return () => {
if (savedTimer) clearTimeout(savedTimer);
};
});
function armSavedTimer() {
if (savedTimer) clearTimeout(savedTimer);
savedTimer = setTimeout(() => {
if (saveState === 'saved') saveState = 'idle';
}, 2000);
}
const saveProject = createMutation({
mutationFn: (payload: {
idea?: string;
genre?: string;
tone?: string;
target_duration?: string;
}) => projects.update(projectId, payload),
onMutate: () => {
saveState = 'saving';
},
onSuccess: () => {
client.invalidateQueries({ queryKey: ['story'] });
saveState = 'saved';
armSavedTimer();
},
onError: (err) => {
saveState = 'error';
toast.error(err instanceof Error ? err.message : t('story.saveSettingsFailed'));
},
});
/** Autosave path — returns false on failure (mutation onError already toasted). */
async function persistSettings(): Promise<boolean> {
try {
await $saveProject.mutateAsync({
idea: ideaDraft,
genre: genreDraft || undefined,
tone: toneDraft || undefined,
target_duration: lengthDraft || undefined,
});
return true;
} catch {
return false;
}
}
async function loadExample() {
ideaDraft = EXAMPLE.idea;
genreDraft = EXAMPLE.genre;
toneDraft = EXAMPLE.tone;
lengthDraft = EXAMPLE.target_duration;
const saved = await persistSettings();
if (saved) toast.success(t('story.exampleLoaded'));
}
function requestLoadExample() {
if (ideaDraft.trim()) confirmExampleOpen = true;
else void loadExample();
}
/** Hand off to a fresh, project-linked agent chat with the composer pre-filled. */
async function draftStoryline() {
if (!configured) return;
const saved = await persistSettings();
if (!saved) return; // persistSettings already toasted the failure
goto(agentDeepLink(projectId, 'story'));
}
function openBeat(beat: Beat) {
editingBeat = { ...beat };
beatModalOpen = true;
}
async function addBeat() {
if (addingBeat) return;
addingBeat = true;
try {
const nextIndex =
story.beats.reduce((max, b) => Math.max(max, b.order_index), 0) + 1;
// No api.ts binding exists yet for POST /beats — call the endpoint directly.
const res = await fetch(`/api/projects/${projectId}/beats`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_index: nextIndex,
title: t('story.newBeat', { n: nextIndex }),
description: '',
}),
});
if (!res.ok) {
const body = await res.text().catch(() => 'unknown error');
throw new Error(`${res.status}: ${body}`);
}
const created = (await res.json()) as Beat;
await client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.beatAdded', { index: nextIndex }));
openBeat(created);
} catch (err) {
toast.error(err instanceof Error ? err.message : t('story.addBeatFailed'));
} finally {
addingBeat = false;
}
}
function requestDeleteBeat(beat: Beat) {
deletingBeat = beat;
beatDeleteOpen = true;
}
const deleteBeatMutation = createMutation({
mutationFn: (beatId: number) => projects.deleteBeat(projectId, beatId),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.beatDeleted'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.deleteBeatFailed'));
},
});
const saveBeat = createMutation({
mutationFn: () =>
projects.updateBeat(projectId, editingBeat!.id, {
title: editingBeat!.title,
description: editingBeat!.description,
order_index: editingBeat!.order_index,
}),
onSuccess: () => {
editingBeat = null;
beatModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.beatSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveBeatFailed'));
},
});
const saveChar = createMutation({
mutationFn: () =>
projects.updateCharacter(projectId, editingChar!.id, {
name: editingChar!.name,
role: editingChar!.role,
age: editingChar!.age,
appearance: editingChar!.appearance,
personality: editingChar!.personality,
consistency_prompt: editingChar!.consistency_prompt,
}),
onSuccess: () => {
editingChar = null;
charModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.characterSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveCharacterFailed'));
},
});
const saveLoc = createMutation({
mutationFn: () =>
projects.updateLocation(projectId, editingLoc!.id, {
name: editingLoc!.name,
description: editingLoc!.description,
consistency_prompt: editingLoc!.consistency_prompt,
}),
onSuccess: () => {
editingLoc = null;
locModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.locationSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveLocationFailed'));
},
});
const saveItem = createMutation({
mutationFn: () =>
projects.updateItem(projectId, editingItem!.id, {
name: editingItem!.name,
description: editingItem!.description,
consistency_prompt: editingItem!.consistency_prompt,
}),
onSuccess: () => {
editingItem = null;
itemModalOpen = false;
client.invalidateQueries({ queryKey: ['story'] });
toast.success(t('story.itemSaved'));
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : t('story.saveItemFailed'));
},
});
function goToAssets() {
goto('?stage=assets', { keepFocus: true, noScroll: true });
}
function beatCountLabel(n: number): string {
return t('story.beats', { n });
}
</script>
{#snippet saveIndicator()}
{#if saveState === 'saving'}
<span class="save-ind"><Spinner size="sm" /> {t('common.saving')}</span>
{:else if saveState === 'saved'}
<span class="save-ind saved"><Icon name="check" size={13} /> {t('common.saved')}</span>
{:else if saveState === 'error'}
<span class="save-ind err">
<Icon name="alert" size={13} /> {t('common.saveFailed')}
<Button variant="ghost" size="sm" onclick={() => void persistSettings()}>{t('common.retry')}</Button>
</span>
{/if}
{/snippet}
<header class="stage-header">
<h2>{t('story.stageTitle')}</h2>
<div class="stage-actions">
<Button
variant="secondary"
disabled={$saveProject.isPending}
onclick={requestLoadExample}
>
{t('story.loadExample')}
</Button>
<Button variant="primary" disabled={!configured} onclick={() => void draftStoryline()}>
<Icon name="sparkle" size={15} /> {t('story.draftStoryline')}
</Button>
</div>
</header>
{#if !configured}
<div class="banner">{t('story.llmBanner')}</div>
{/if}
<div class="stack-col">
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.ideaTitle')}</h3>
{@render saveIndicator()}
{/snippet}
<textarea
class="field-textarea"
bind:value={ideaDraft}
rows="5"
placeholder={t('story.ideaPlaceholder')}
onblur={() => void persistSettings()}
></textarea>
</Card>
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.settingsTitle')}</h3>
{@render saveIndicator()}
{/snippet}
<div class="grid-3">
<label class="field">
<span class="field-label">{t('projects.genre')}</span>
<select
class="field-select"
bind:value={genreDraft}
onchange={() => void persistSettings()}
>
{#each GENRES as g}
<option value={g}>{genreLabel(g)}</option>
{/each}
{#if genreDraft && !GENRES.includes(genreDraft)}
<option value={genreDraft}>{genreLabel(genreDraft)}</option>
{/if}
</select>
</label>
<label class="field">
<span class="field-label">{t('story.targetLength')}</span>
<input
class="field-input"
type="text"
bind:value={lengthDraft}
placeholder={LENGTHS_HINT}
onblur={() => void persistSettings()}
/>
<span class="field-hint beat-hint">
{t('story.lengthHint', { secs: secsBudget, beats: beatBudget, scenes: sceneBudget })}
</span>
</label>
<label class="field">
<span class="field-label">{t('projects.tone')}</span>
<select
class="field-select"
bind:value={toneDraft}
onchange={() => void persistSettings()}
>
{#each TONES as tone}
<option value={tone}>{toneLabel(tone)}</option>
{/each}
{#if toneDraft && !TONES.includes(toneDraft)}
<option value={toneDraft}>{toneLabel(toneDraft)}</option>
{/if}
</select>
</label>
</div>
</Card>
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.draftPromptTitle')}</h3>
<Button variant="ghost" size="sm" onclick={() => (showDraftPrompt = !showDraftPrompt)}>
{showDraftPrompt ? t('story.hidePrompt') : t('story.showPrompt')}
</Button>
{/snippet}
{#if showDraftPrompt}
<pre class="prompt-preview">{draftPromptPreview}</pre>
<p class="field-hint">
{t('story.promptExplain')}
</p>
{:else}
<p class="field-hint prompt-collapsed">
{t('story.promptCollapsedHint')}
</p>
{/if}
</Card>
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.beatsTitle')}</h3>
<div class="head-actions">
{#if story.beats.length}
<StatusChip status="ready" label={beatCountLabel(story.beats.length)} />
{/if}
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
<Icon name="plus" size={14} /> {t('story.addBeat')}
</Button>
</div>
{/snippet}
{#if story.beats.length === 0}
<EmptyState
title={t('story.noBeats')}
body={t('story.noBeatsBody')}
>
{#snippet icon()}
<Icon name="story" size={28} />
{/snippet}
{#snippet action()}
<Button variant="secondary" size="sm" loading={addingBeat} onclick={addBeat}>
<Icon name="plus" size={14} /> {t('story.addBeatManual')}
</Button>
{/snippet}
</EmptyState>
{:else}
<div class="beat-list">
{#each story.beats as beat (beat.id)}
<div class="beat-row">
<button type="button" class="beat" onclick={() => openBeat(beat)}>
<div class="beat-number">{beat.order_index}</div>
<div class="beat-body">
<h4>{beat.title}</h4>
<p>{beat.description}</p>
</div>
</button>
<div class="beat-actions">
<Button
variant="ghost"
size="sm"
title={t('story.deleteBeat', { index: beat.order_index })}
onclick={() => requestDeleteBeat(beat)}
>
<Icon name="trash" size={14} /><span class="sr-only">{t('story.deleteBeat', { index: beat.order_index })}</span>
</Button>
</div>
</div>
{/each}
</div>
<div class="beats-footer">
<Button variant="primary" onclick={goToAssets}>
{t('story.continueAssets')} <Icon name="chevron-right" size={15} />
</Button>
</div>
{/if}
</Card>
{#if story.characters.length || story.locations.length || story.items.length}
<Card>
{#snippet header()}
<h3 class="card-h">{t('story.extracted')}</h3>
<Button variant="ghost" size="sm" onclick={goToAssets}>
{t('story.openAssets')} <Icon name="chevron-right" size={14} />
</Button>
{/snippet}
<div class="chips">
{#each story.characters as char (char.id)}
<button
type="button"
class="chip"
onclick={() => {
editingChar = { ...char };
charModalOpen = true;
}}
title={char.appearance ?? ''}
>
<span class="chip-tag">{t('story.charTag')}</span>
{char.name}
</button>
{/each}
{#each story.locations as loc (loc.id)}
<button
type="button"
class="chip"
onclick={() => {
editingLoc = { ...loc };
locModalOpen = true;
}}
title={loc.description ?? ''}
>
<span class="chip-tag">{t('story.envTag')}</span>
{loc.name}
</button>
{/each}
{#each story.items as item (item.id)}
<button
type="button"
class="chip"
onclick={() => {
editingItem = { ...item };
itemModalOpen = true;
}}
title={item.description ?? ''}
>
<span class="chip-tag">{t('story.itemTag')}</span>
{item.name}
</button>
{/each}
</div>
</Card>
{/if}
</div>
<Modal bind:open={beatModalOpen} title={t('story.editBeatTitle')} onclose={() => (editingBeat = null)}>
{#if editingBeat}
<label class="field">
<span class="field-label">{t('story.fieldTitle')}</span>
<input class="field-input" bind:value={editingBeat.title} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldDescription')}</span>
<textarea class="field-textarea" rows="4" bind:value={editingBeat.description}></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (beatModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveBeat.isPending} onclick={() => $saveBeat.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<Modal bind:open={charModalOpen} title={t('story.editCharTitle')} onclose={() => (editingChar = null)}>
{#if editingChar}
<label class="field">
<span class="field-label">{t('story.fieldName')}</span>
<input class="field-input" bind:value={editingChar.name} placeholder={t('story.fieldName')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldRole')}</span>
<input class="field-input" bind:value={editingChar.role} placeholder={t('story.fieldRole')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldAge')}</span>
<input class="field-input" bind:value={editingChar.age} placeholder={t('story.fieldAge')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldAppearance')}</span>
<textarea
class="field-textarea"
rows="3"
bind:value={editingChar.appearance}
placeholder={t('story.fieldAppearance')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldPersonality')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingChar.personality}
placeholder={t('story.fieldPersonality')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldConsistency')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingChar.consistency_prompt}
placeholder={t('story.fieldConsistency')}
></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (charModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveChar.isPending} onclick={() => $saveChar.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<Modal bind:open={locModalOpen} title={t('story.editLocTitle')} onclose={() => (editingLoc = null)}>
{#if editingLoc}
<label class="field">
<span class="field-label">{t('story.fieldName')}</span>
<input class="field-input" bind:value={editingLoc.name} placeholder={t('story.fieldName')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldDescription')}</span>
<textarea
class="field-textarea"
rows="3"
bind:value={editingLoc.description}
placeholder={t('story.fieldDescription')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldConsistency')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingLoc.consistency_prompt}
placeholder={t('story.fieldConsistency')}
></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (locModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveLoc.isPending} onclick={() => $saveLoc.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<Modal bind:open={itemModalOpen} title={t('story.editItemTitle')} onclose={() => (editingItem = null)}>
{#if editingItem}
<label class="field">
<span class="field-label">{t('story.fieldName')}</span>
<input class="field-input" bind:value={editingItem.name} placeholder={t('story.fieldName')} />
</label>
<label class="field">
<span class="field-label">{t('story.fieldDescription')}</span>
<textarea
class="field-textarea"
rows="3"
bind:value={editingItem.description}
placeholder={t('story.fieldDescription')}
></textarea>
</label>
<label class="field">
<span class="field-label">{t('story.fieldConsistency')}</span>
<textarea
class="field-textarea"
rows="2"
bind:value={editingItem.consistency_prompt}
placeholder={t('story.fieldConsistency')}
></textarea>
</label>
{/if}
{#snippet footer()}
<Button variant="ghost" onclick={() => (itemModalOpen = false)}>{t('common.cancel')}</Button>
<Button variant="primary" loading={$saveItem.isPending} onclick={() => $saveItem.mutate()}>
{t('common.save')}
</Button>
{/snippet}
</Modal>
<ConfirmDialog
bind:open={confirmExampleOpen}
title={t('story.confirmExampleTitle')}
message={t('story.confirmExampleMsg')}
confirmLabel={t('story.loadExample')}
onconfirm={() => void loadExample()}
/>
<ConfirmDialog
bind:open={beatDeleteOpen}
title={t('story.confirmDeleteBeatTitle')}
message={deletingBeat
? t('story.confirmDeleteBeatMsg', { index: deletingBeat.order_index, title: deletingBeat.title })
: ''}
confirmLabel={t('common.delete')}
danger
onconfirm={() => {
const id = deletingBeat?.id;
deletingBeat = null;
if (id != null) $deleteBeatMutation.mutate(id);
}}
oncancel={() => (deletingBeat = null)}
/>
<style>
.stage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-lg);
gap: var(--space-md);
}
.stage-header h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
}
.stage-actions {
display: flex;
gap: var(--space-sm);
}
.banner {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.3);
color: var(--warning);
padding: var(--space-md);
border-radius: var(--radius-md);
margin-bottom: var(--space-lg);
font-size: 13px;
}
.stack-col {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.card-h {
margin: 0;
font-size: 16px;
color: var(--text-secondary);
font-weight: 600;
}
.head-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.save-ind {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
}
.save-ind.saved {
color: var(--success);
animation: saved-fade 2s ease forwards;
}
.save-ind.err {
color: var(--error);
}
@keyframes saved-fade {
0% {
opacity: 0;
}
12% {
opacity: 1;
}
78% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-md);
}
@media (max-width: 900px) {
.grid-3 {
grid-template-columns: 1fr;
}
}
.grid-3 .field {
margin-bottom: 0;
}
.beat-hint {
display: block;
}
.beat-hint strong {
color: var(--accent);
font-weight: 600;
}
.prompt-preview {
margin: 0 0 var(--space-sm);
padding: 12px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
color: var(--text-secondary);
max-height: 280px;
overflow: auto;
}
.prompt-collapsed {
margin: 0;
}
.beat-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.beat-row {
display: flex;
align-items: flex-start;
gap: var(--space-xs);
}
.beat {
display: flex;
flex: 1;
min-width: 0;
gap: var(--space-md);
align-items: flex-start;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
padding: 8px;
cursor: pointer;
text-align: left;
color: inherit;
font: inherit;
}
.beat:hover {
border-color: var(--border);
background: var(--bg-elevated);
}
.beat:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.beat-actions {
flex-shrink: 0;
padding-top: 8px;
}
.beat-number {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: var(--text-secondary);
flex-shrink: 0;
}
.beat-body h4 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
}
.beat-body p {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.45;
}
.beats-footer {
display: flex;
justify-content: flex-end;
margin-top: var(--space-md);
padding-top: var(--space-md);
border-top: 1px solid var(--border);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
}
.chip:hover {
border-color: var(--accent);
}
.chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.chip-tag {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent);
font-weight: 700;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
@@ -37,8 +37,13 @@
<span class="dot"></span>
<Icon name={kindIcon[toast.kind]} size={15} />
</span>
<span class="msg">{toast.message}</span>
<button
<span class="msg">{toast.message}</span>
{#if toast.action}
<button type="button" class="action" onclick={() => (toast.action?.run(), dismissToast(toast.id))}>
{toast.action.label}
</button>
{/if}
<button
type="button"
class="dismiss"
aria-label={t('toast.dismiss')}
@@ -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;
@@ -146,7 +175,7 @@
}
@media (max-width: 760px) {
.toast-host {
bottom: 20px;
bottom: calc(var(--tabbar-h) + var(--sp-5) + env(safe-area-inset-bottom));
right: 16px;
left: 16px;
max-width: none;
File diff suppressed because it is too large Load Diff
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();
@@ -0,0 +1,410 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import ShellPop from '$lib/components/shell/ShellPop.svelte';
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import ParamChip from './ParamChip.svelte';
import SlotTray from './SlotTray.svelte';
import WorkflowPicker from './WorkflowPicker.svelte';
import { chipValue, type BlockReason, type DeskSchema, type FormValue, type FormValues } from './schema';
/**
* 输入台 (spec-v2 §3.3): slots from the workflow JSON, the prompt, and one
* row of chips — workflow, connection, common params, 参数 — ending in
* 生成 with its reason line. The whole composer takes dropped files.
* ≤760px it collapses to one line and rises as a bottom sheet on focus.
*/
interface Props {
ds: DeskSchema;
values: FormValues;
workflows: Workflow[];
current: Workflow | null;
endpoints: Array<{ id: string; name: string }>;
endpointId: string;
conn: { state: 'ok' | 'fail' | 'untested' | 'dry'; ms?: number | null };
errors: Record<string, string>;
showErrors: boolean;
block: BlockReason | null;
changedCount: number;
submitting: boolean;
/** Shows 已提交 on the button for --d-flash. */
sent: boolean;
slotBusy?: boolean;
promptEl?: HTMLTextAreaElement | null;
onSet: (nodeId: string, value: FormValue) => void;
onPickWorkflow: (wf: Workflow) => void;
onPickEndpoint: (id: string) => void;
onSubmit: () => void;
onReveal: () => void;
onOpenParams: () => void;
onFromResults: (nodeId: string) => void;
}
let {
ds,
values,
workflows,
current,
endpoints,
endpointId,
conn,
errors,
showErrors,
block,
changedCount,
submitting,
sent,
slotBusy = $bindable(false),
promptEl = $bindable(null),
onSet,
onPickWorkflow,
onPickEndpoint,
onSubmit,
onReveal,
onOpenParams,
onFromResults,
}: Props = $props();
let tray = $state<ReturnType<typeof SlotTray> | null>(null);
let root = $state<HTMLElement | null>(null);
let dragDepth = $state(0);
let expanded = $state(false);
let connOpen = $state(false);
const hasSlots = $derived(ds.slots.length + ds.lists.length > 0);
const promptVal = $derived(ds.prompt ? String(values[ds.prompt.nodeId] ?? '') : '');
const promptErr = $derived(showErrors && ds.prompt ? errors[ds.prompt.nodeId] : undefined);
const endpointName = $derived(endpoints.find((e) => e.id === endpointId)?.name ?? t('desk.noConn'));
/** Composer chips: ratio, the width × height pair, resolution, duration, seed. */
const chips = $derived.by(() => {
const out: Array<{ key: string; inputs: ComfyDynamicInput[]; text: string }> = [];
if (ds.ratio) out.push({ key: ds.ratio.nodeId, inputs: [ds.ratio], text: chipValue(ds.ratio, values, ds) });
if (ds.width && ds.height) {
const w = values[ds.width.nodeId] ?? ds.width.defaultValue ?? '?';
const h = values[ds.height.nodeId] ?? ds.height.defaultValue ?? '?';
out.push({ key: ds.width.nodeId, inputs: [ds.width, ds.height], text: `${w}×${h}` });
} else {
for (const inp of [ds.width, ds.height]) {
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: `${inp.label} ${chipValue(inp, values, ds)}` });
}
}
for (const inp of [ds.resolution, ds.duration, ds.seed]) {
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: chipValue(inp, values, ds) });
}
return out;
});
function onDrop(e: DragEvent) {
e.preventDefault();
dragDepth = 0;
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) tray?.dropFiles(files);
}
function hasFiles(e: DragEvent): boolean {
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
}
function onFocusOut(e: FocusEvent) {
const next = e.relatedTarget as Node | null;
if (!next || !root?.contains(next)) expanded = false;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && expanded && !e.defaultPrevented) {
expanded = false;
(document.activeElement as HTMLElement | null)?.blur();
}
}
</script>
<div class="composer-wrap">
<div class="a-sprocket" aria-hidden="true"></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={root}
class="composer"
class:drag={dragDepth > 0}
class:expanded
onfocusin={() => (expanded = true)}
onfocusout={onFocusOut}
onkeydown={onKeydown}
ondragenter={(e) => {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth += 1;
}}
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
ondragover={(e) => hasFiles(e) && e.preventDefault()}
ondrop={onDrop}
>
{#if hasSlots}
<div class="slots">
<SlotTray bind:this={tray} bind:busy={slotBusy} {ds} {values} {showErrors} {errors} {onSet} {onFromResults} />
</div>
{/if}
{#if ds.prompt}
<textarea
bind:this={promptEl}
id="desk-prompt"
class="prompt"
class:invalid={!!promptErr}
rows="2"
value={promptVal}
placeholder={t('desk.promptPh')}
aria-label={t('studio.promptLabel')}
aria-invalid={!!promptErr || undefined}
aria-keyshortcuts="Control+Enter Meta+Enter /"
oninput={(e) => onSet(ds.prompt!.nodeId, e.currentTarget.value)}
></textarea>
{#if promptErr}<p class="a-slot-hint err prompt-err" role="alert">{promptErr}</p>{/if}
{:else if current}
<p class="no-prompt">{t('desk.noPromptInput')}</p>
{/if}
<div class="foot">
<WorkflowPicker {workflows} {current} {endpoints} onPick={onPickWorkflow} />
<div class="conn opt">
<button
type="button"
id="desk-conn"
class="a-chip"
class:err={conn.state === 'fail'}
aria-haspopup="dialog"
aria-expanded={connOpen}
title={t('studio.connLabel')}
onclick={() => (connOpen = !connOpen)}
>
<span class="status-chip-dot {conn.state === 'ok' ? 'run' : conn.state === 'fail' ? 'fail' : 'idle'}"></span>
<span class="lbl">{endpointName}</span>
{#if conn.state === 'ok' && conn.ms != null}<span class="mono">· {Math.round(conn.ms)} ms</span>{/if}
{#if conn.state === 'dry'}<span>· {t('desk.dryRun')}</span>{/if}
</button>
<ShellPop bind:open={connOpen} label={t('studio.connLabel')} placement="top" width="280px">
<div class="a-menu-group">{t('studio.connLabel')}</div>
{#each endpoints as ep (ep.id)}
<button
type="button"
class="a-menu-item"
aria-current={ep.id === endpointId ? 'true' : undefined}
onclick={() => {
onPickEndpoint(ep.id);
connOpen = false;
}}
>
<span class="a-menu-label">{ep.name}</span>
</button>
{/each}
<a class="a-menu-item" href="/connections">
<Icon name="link" size={15} />
<span class="a-menu-label">{t('studio.connManage')}</span>
</a>
</ShellPop>
</div>
{#each chips as c (c.key)}
<div class="opt">
<ParamChip
inputs={c.inputs}
{values}
text={c.text}
invalid={showErrors && c.inputs.some((i) => !!errors[i.nodeId]?.trim())}
{onSet}
/>
</div>
{/each}
{#if ds.common.length + ds.advanced.length > 0}
<button type="button" class="a-chip opt" class:changed={changedCount > 0} onclick={onOpenParams}>
<Icon name="settings" size={14} />
<span class="lbl">
{changedCount > 0 ? t('desk.paramsChanged', { n: changedCount }) : t('desk.paramsAll')}
</span>
</button>
{/if}
<div class="submit">
{#if block}
<button type="button" class="why" class:err={block.tone === 'err'} onclick={onReveal}>
{block.text}
</button>
{/if}
<button
type="button"
class="a-btn primary gen"
class:sent
disabled={!!block || submitting || slotBusy}
aria-keyshortcuts="Control+Enter Meta+Enter"
title={t('desk.genHint')}
onclick={onSubmit}
>
<span class="l-idle"><Icon name="sparkle" size={13} />{t('desk.generate')}</span>
<span class="l-sent" aria-hidden={!sent}><Icon name="check" size={13} />{t('desk.sent')}</span>
</button>
</div>
</div>
</div>
</div>
<style>
.composer-wrap {
padding: var(--sp-2) var(--sp-7) var(--sp-6);
}
.a-sprocket {
margin: 0 var(--sp-2) var(--sp-3);
}
.composer {
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
background: var(--composer-bg);
transition:
border-color var(--d-state) ease,
box-shadow var(--d-state) ease;
}
.composer:focus-within {
border-color: var(--accent-line);
box-shadow: var(--composer-focus);
}
.composer.drag {
border-color: var(--accent-line);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.slots {
max-height: 40vh;
overflow-y: auto;
}
.prompt {
display: block;
box-sizing: border-box;
width: 100%;
min-height: 52px;
max-height: 180px;
padding: var(--sp-5) var(--sp-6) var(--sp-2);
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
line-height: 1.6;
resize: none;
field-sizing: content;
}
.prompt:focus-visible {
outline: 0;
}
.prompt::placeholder {
color: var(--text-3);
}
.prompt-err,
.no-prompt {
margin: 0;
padding: 0 var(--sp-6);
}
.no-prompt {
padding-top: var(--sp-5);
font-size: var(--fs-aux);
color: var(--text-3);
}
.foot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3) var(--sp-3);
}
.conn {
position: relative;
}
.submit {
display: flex;
align-items: center;
gap: var(--sp-4);
margin-left: auto;
}
.why {
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: var(--fs-aux);
color: var(--warn);
white-space: nowrap;
text-decoration: underline dotted;
text-underline-offset: 3px;
cursor: pointer;
}
.why.err {
color: var(--err);
}
/* 生成:按下即缩,提交后文字换成「已提交」约 --d-flash,期间仍可再次提交 */
.gen {
display: inline-grid;
min-width: 84px;
border-radius: var(--r-pill);
}
.gen > span {
grid-area: 1 / 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--sp-1);
transition:
opacity var(--d-state) ease,
transform var(--d-pop) var(--ease-out);
}
.gen .l-sent {
opacity: 0;
transform: translateY(8px);
}
.gen.sent .l-idle {
opacity: 0;
transform: translateY(-8px);
}
.gen.sent .l-sent {
opacity: 1;
transform: none;
}
:global(:root[data-reduce='1']) .gen > span {
transform: none;
}
@media (max-width: 1179px) {
.composer-wrap {
padding: var(--sp-2) var(--sp-6) var(--sp-5);
}
}
@media (max-width: 760px) {
.composer-wrap {
padding: var(--sp-1) var(--sp-4) var(--sp-4);
}
.composer:not(.expanded) .slots,
.composer:not(.expanded) .opt {
display: none;
}
.composer:not(.expanded) .prompt {
min-height: 40px;
max-height: 40px;
padding: var(--sp-4) var(--sp-5) var(--sp-1);
white-space: nowrap;
overflow: hidden;
}
.composer.expanded {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
z-index: 45;
border-bottom: 0;
border-radius: var(--r-lg) var(--r-lg) 0 0;
box-shadow: var(--shadow-float);
max-height: calc(100dvh - var(--topbar-h) - var(--tabbar-h));
overflow-y: auto;
}
.composer.expanded .slots {
max-height: none;
}
.submit {
gap: var(--sp-3);
}
}
</style>
@@ -0,0 +1,70 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
import { backHref, type DeskContext } from './context';
/**
* 上下文条 (spec-v2 §3.3): the desk was opened from a creation stage.
* Says where from, and plainly that takes stay in the desk feed — the
* desk's generate call has no shot/asset link to fill back.
*/
interface Props {
ctx: DeskContext;
projectTitle: string;
onDismiss: () => void;
}
let { ctx, projectTitle, onDismiss }: Props = $props();
const stageName = $derived(t(`workbench.stage.${ctx.stage}`));
</script>
<div class="context-bar" role="status">
<Icon name="link" size={14} />
<span class="from">
<b>{t('desk.ctxFrom', { project: projectTitle })}</b>{#if ctx.label}&nbsp;· {ctx.label}{/if}
</span>
<span class="note">{t('desk.ctxNoBackfill', { stage: stageName })}</span>
<a class="a-btn ghost xs back" href={backHref(ctx)}>{t('desk.ctxBack', { stage: stageName })}</a>
<button type="button" class="a-btn ghost xs" aria-label={t('desk.ctxDismiss')} onclick={onDismiss}>
<Icon name="close" size={12} />
</button>
</div>
<style>
.context-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-4);
margin: var(--sp-4) var(--sp-7) 0;
padding: var(--sp-3) var(--sp-5);
border-radius: var(--r-md);
background: var(--run-soft);
color: var(--text);
font-size: var(--fs-aux);
}
.context-bar > :global(svg) {
color: var(--run);
}
.from b {
color: var(--run);
font-weight: 600;
}
.note {
color: var(--text-2);
}
.back {
margin-left: auto;
}
@media (max-width: 1179px) {
.context-bar {
margin: var(--sp-4) var(--sp-6) 0;
}
}
@media (max-width: 760px) {
.context-bar {
margin: var(--sp-3) var(--sp-5) 0;
}
}
</style>
@@ -0,0 +1,581 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import type { Job, Workflow } from '$lib/comfy/types';
import { progressFor } from '$lib/jobProgress';
import { jobStatusInfo } from '$lib/jobStatus';
import { parseDbTs } from '$lib/dbtime';
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
import { t } from '$lib/i18n.svelte';
import { exportLastFrame } from '$lib/media/lastFrame';
import { usageLabel } from './schema';
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf, specOf } from './takes';
/**
* 检视区 (spec-v2 §3.3): 结果 shows the selected take on the projection
* stage with its spec, inputs and workflow; 参数 hosts the full form.
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
* fullscreen — `overlay` / `open` drive the last two.
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
* ↑↓/J/K 即时无动画), a finished/just-picked video auto-plays muted,
* failed takes get 重试, and a done take whose files no longer load
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
*/
interface Props {
tab: 'result' | 'params';
job: Job | null;
wf: Workflow | undefined;
nowMs: number;
open: boolean;
/** Collapsed or off-screen — nothing inside may take focus. */
inactive: boolean;
/** Current workflow can take an image output (送入当前图片槽). */
canSendToSlot: boolean;
/** Animate this result change (pointer pick) — 150ms cross-fade. */
crossfade: boolean;
/** Bump to auto-play the stage video (pointer pick / completion). */
playSignal: number;
/** The stage <video> element, for the desk's space-bar toggle. */
videoEl?: HTMLVideoElement | null;
params: Snippet;
onClose: () => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onSendToSlot: (path: string) => void;
onDelete: (job: Job) => void;
}
let {
tab = $bindable(),
job,
wf,
nowMs,
open,
inactive,
canSendToSlot,
crossfade,
playSignal,
videoEl = $bindable(null),
params,
onClose,
onReuse,
onRetry,
onSendToSlot,
onDelete,
}: Props = $props();
let outIndex = $state(0);
$effect.pre(() => {
void job?.id;
outIndex = 0;
});
const st = $derived(job ? jobStatusInfo(job) : null);
// §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files
// cannot be fetched — the failed case stays red.
const gonePaths = $derived(missingOutputsOf(job));
const fileGone = $derived(gonePaths.length > 0);
const outputs = $derived(job && st?.tone === 'ok' ? job.output_paths.filter((p) => !gonePaths.includes(p)) : []);
const output = $derived(outputs[Math.min(outIndex, outputs.length - 1)] ?? null);
const allOutputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
const video = $derived(!!job && !!output && isVideoOutput(job, output));
const wfDeleted = $derived(!!job && job.workflow_id == null);
const prompt = $derived(job ? promptOf(job, wf) : '');
const inputs = $derived(job ? inputsOf(job, wf) : []);
const spec = $derived(job ? specOf(job, wf) : []);
const endpoint = $derived(
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ?? '',
);
const progress = $derived.by(() => {
if (!job || job.status !== 'running') return 0;
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase;
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
return live?.progress ?? 3;
});
let dims = $state('');
$effect.pre(() => {
void output;
dims = '';
});
function noteImg(e: Event) {
const img = e.currentTarget as HTMLImageElement;
dims = `${img.naturalWidth}×${img.naturalHeight}`;
}
function noteVideo(e: Event) {
const v = e.currentTarget as HTMLVideoElement;
dims = `${v.videoWidth}×${v.videoHeight}`;
}
// 完成或点选后自动播放(静音);键盘切换不触发。
$effect(() => {
void playSignal;
if (inactive || tab !== 'result' || !videoEl) return;
videoEl.muted = true;
videoEl.play().catch(() => {});
});
function reduced(): boolean {
return (
document.documentElement.dataset.reduce === '1' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
/** 150ms 交叉淡变 on pointer picks; keyboard switches stay instant. */
function xfade(_node: Element) {
const ms = crossfade ? (reduced() ? 120 : 150) : 0;
return { duration: ms, css: (t: number) => `opacity: ${t}` };
}
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
let frameBusy = $state(false);
async function exportTail() {
const src = assetUrl(output);
if (!src || frameBusy) return;
frameBusy = true;
try {
await exportLastFrame(src);
} finally {
frameBusy = false;
}
}
</script>
<aside class="inspector" class:open inert={inactive} aria-label={t('desk.inspector')}>
<div class="tabs">
<div class="a-seg" role="group" aria-label={t('desk.inspector')}>
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}>{t('desk.tabResult')}</button>
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}>{t('desk.tabParams')}</button>
</div>
<span class="keys"><span class="a-kbd">]</span>{t('desk.keyInspector')}</span>
<button type="button" class="a-btn ghost sm close" aria-label={t('desk.closeInspector')} onclick={onClose}>
<Icon name="close" size={14} />
</button>
</div>
<div class="body">
{#if tab === 'params'}
{@render params()}
{:else if !job || !st}
<div class="a-stage stage"><div class="stage-empty">{t('desk.pickTake')}</div></div>
{:else}
<div class="a-stage stage">
{#key mediaKey}
<div class="stage-media" in:xfade out:xfade>
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<div class="print a-photo">
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
<video
bind:this={videoEl}
src={assetUrl(output)}
controls
playsinline
preload="metadata"
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
</div>
{:else if output}
<div class="print a-photo">
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
<img
src={assetUrl(output)}
alt={prompt || t('desk.tabResult')}
onload={(e) => {
clearOutputMissing(output);
noteImg(e);
}}
onerror={() => output && markOutputMissing(output)}
/>
</div>
{:else if fileGone}
<div class="stage-empty gone">
<span>{t('desk.missingTitle')}</span>
<span class="sub">{t('desk.missingSub')}</span>
</div>
{:else}
<div class="stage-empty">
<span>{st.label}</span>
<span class="sub">{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span>
{#if progress > 0}
<div class="a-progress stage-bar" style:--p={progress / 100}><i></i></div>
{/if}
</div>
{/if}
</div>
{/key}
</div>
{#if allOutputs.length > 1}
<div class="strip" role="group" aria-label={t('desk.outputs')}>
{#each allOutputs as p, i (p)}
<button
type="button"
class="strip-item"
class:gone={gonePaths.includes(p)}
aria-pressed={p === output}
aria-label={t('desk.outputN', { n: i + 1 })}
onclick={() => (outIndex = i)}
>
{#if isVideoOutput(job, p)}
<Icon name="film" size={14} />
{:else}
<img src={assetUrl(p)} alt="" loading="lazy" />
{/if}
</button>
{/each}
</div>
{/if}
<div class="meta">
{#if dims}<span class="mono"><b>{dims}</b></span>{/if}
{#each spec as s (s.label)}
{#if !(dims && s.label === t('desk.specSize'))}
<span>{s.label} <b class="mono">{s.value}</b></span>
{/if}
{/each}
<span>{t('desk.elapsed')} <b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span>
{#if endpoint}<span>{endpoint}</span>{/if}
</div>
<div class="actions">
{#if fileGone && !outputs.length}
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('desk.redo')}</button>
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('desk.removeRecord')}</button>
{:else}
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}>{t('desk.reuseToComposer')}</button>
{#if output && !video && canSendToSlot}
<button type="button" class="a-btn secondary sm" onclick={() => onSendToSlot(output)}>
{t('desk.sendToSlot')}
</button>
{/if}
{#if output}
<a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a>
{/if}
{#if output && video}
<button
type="button"
class="a-btn secondary sm"
title={t('lastFrame.hint')}
disabled={frameBusy}
aria-busy={frameBusy}
onclick={exportTail}
>
{frameBusy ? t('lastFrame.exporting') : t('lastFrame.export')}
</button>
{/if}
{#if st.tone === 'fail'}
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button>
{/if}
{#if job.status !== 'pending' && job.status !== 'running'}
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
{/if}
{/if}
</div>
{#if st.tone === 'fail'}
<div class="note err" role="alert">{st.detail}</div>
{:else if fileGone}
<div class="note warn" role="alert">{t('jobst.missingDetail')}</div>
{/if}
{#if inputs.length || prompt}
<div class="section-title">{t('desk.inputs')}</div>
{#if inputs.length}
<div class="inputs">
{#each inputs as inp (inp.label + inp.path)}
{#if inp.kind === 'image'}
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
{:else}
<span class="mini glyph" title={inp.label}><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span>
{/if}
{/each}
</div>
{/if}
{#if prompt}<p class="prompt">{prompt}</p>{/if}
{/if}
<div class="section-title">{t('desk.workflow')}</div>
<dl class="kv">
<dt>{t('desk.kvName')}</dt>
<dd>
{wf?.name ?? t('studio.jobName', { id: job.id })}
{#if wfDeleted}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
</dd>
{#if wf?.usage}
<dt>{t('desk.kvUsage')}</dt>
<dd>{usageLabel(wf.usage)}</dd>
{/if}
<dt>{t('desk.kvCreated')}</dt>
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
{#if job.comfy_prompt_id}
<dt>{t('desk.kvPromptId')}</dt>
<dd class="mono">{job.comfy_prompt_id}</dd>
{/if}
</dl>
{/if}
</div>
</aside>
<style>
.inspector {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
border-left: 1px solid var(--line);
background: var(--bg);
}
.tabs {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--line);
}
.keys {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
font-size: var(--fs-mini);
color: var(--text-3);
}
.close {
padding: 0 var(--sp-3);
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--sp-6);
}
.stage {
aspect-ratio: 16 / 10;
display: grid;
place-items: center;
}
.stage-media {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: var(--sp-5);
box-sizing: border-box;
}
/* 相纸白边 (HEL-680):检视舞台上的结果按相纸呈现,媒体自适应内含 */
.print {
position: relative;
display: grid;
max-width: 100%;
max-height: 100%;
--paper-w: 5px;
}
.print > img,
.print > video {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
}
.stage-empty {
display: grid;
place-items: center;
gap: var(--sp-2);
padding: var(--sp-6);
text-align: center;
color: var(--text-3);
font-size: var(--fs-aux);
}
.stage-empty.gone {
color: var(--warn);
}
.stage-empty .sub {
color: var(--text-3);
font-size: var(--fs-mini);
}
.stage-bar {
width: 160px;
}
.strip {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.strip-item {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
overflow: hidden;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--bg-3);
color: var(--text-3);
cursor: pointer;
}
.strip-item[aria-pressed='true'] {
border-color: var(--accent);
}
.strip-item.gone {
border-style: dashed;
border-color: var(--warn);
color: var(--warn);
}
.strip-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-5);
margin: var(--sp-5) 0;
font-size: var(--fs-aux);
color: var(--text-3);
}
.meta b {
color: var(--text-2);
font-weight: 500;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.note {
margin-top: var(--sp-5);
padding: var(--sp-3) var(--sp-4);
border-radius: var(--r-md);
font-size: var(--fs-aux);
word-break: break-word;
}
.note.err {
background: var(--err-soft);
color: var(--err);
}
.note.warn {
background: var(--warn-soft);
color: var(--warn);
}
.section-title {
margin: var(--sp-7) 0 var(--sp-3);
font-size: var(--fs-aux);
color: var(--text-3);
}
.inputs {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.mini {
width: 40px;
height: 40px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
object-fit: cover;
}
.mini.glyph {
display: grid;
place-items: center;
background: var(--bg-3);
color: var(--text-3);
box-sizing: border-box;
}
.prompt {
margin: 0;
padding: var(--sp-4) var(--sp-5);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-2);
font-size: var(--fs-aux);
white-space: pre-wrap;
word-break: break-word;
}
.kv {
display: grid;
grid-template-columns: 92px 1fr;
gap: var(--sp-2) var(--sp-4);
margin: 0;
font-size: var(--fs-aux);
}
.kv dt {
color: var(--text-3);
}
.kv dd {
margin: 0;
color: var(--text-2);
word-break: break-all;
}
.kv .wf-gone {
margin-left: var(--sp-2);
}
/* ≥1180: a column of the desk grid; the close button hides it (]) */
@media (max-width: 1179px) {
.inspector {
position: fixed;
top: var(--topbar-h);
right: 0;
bottom: 0;
z-index: 40;
width: min(420px, 100vw);
box-shadow: var(--shadow-float);
transform: translateX(100%);
visibility: hidden;
transition:
transform var(--d-drawer) var(--ease-drawer),
visibility 0s linear var(--d-drawer);
}
.inspector.open {
transform: none;
visibility: visible;
transition:
transform var(--d-drawer) var(--ease-drawer),
visibility 0s;
}
:global(:root[data-reduce='1']) .inspector {
transform: none;
opacity: 0;
transition:
opacity var(--d-drawer) ease,
visibility 0s linear var(--d-drawer);
}
:global(:root[data-reduce='1']) .inspector.open {
opacity: 1;
transition:
opacity var(--d-drawer) ease,
visibility 0s;
}
.keys {
display: none;
}
.close {
margin-left: auto;
}
}
@media (max-width: 760px) {
.inspector {
top: 0;
z-index: 70;
width: 100vw;
border-left: 0;
}
.body {
padding: var(--sp-5);
}
}
</style>
@@ -0,0 +1,179 @@
<script lang="ts">
import ShellPop from '$lib/components/shell/ShellPop.svelte';
import type { ComfyDynamicInput } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { inputRole, isChanged, type FormValue, type FormValues } from './schema';
/**
* One common parameter as a composer chip (spec-v2 §3.3 参数芯片): the
* pop edits it in place — options list for selects, a number field for
* the rest (seed adds 随机) — and 恢复原值 returns to the workflow value.
* `inputs` holds two entries for the width × height pair.
*/
interface Props {
inputs: ComfyDynamicInput[];
values: FormValues;
text: string;
title?: string;
invalid?: boolean;
onSet: (nodeId: string, value: FormValue) => void;
}
let { inputs, values, text, title, invalid = false, onSet }: Props = $props();
let open = $state(false);
const changed = $derived(inputs.some((i) => isChanged(i, values)));
const single = $derived(inputs.length === 1 ? inputs[0] : null);
const uid = Math.random().toString(36).slice(2, 8);
function current(inp: ComfyDynamicInput): string {
const v = values[inp.nodeId] ?? inp.defaultValue ?? '';
return Array.isArray(v) ? v.join(',') : String(v);
}
function setNumber(inp: ComfyDynamicInput, raw: string) {
if (raw === '') return onSet(inp.nodeId, '');
const n = Number(raw);
onSet(inp.nodeId, Number.isNaN(n) ? raw : n);
}
function restore() {
for (const inp of inputs) onSet(inp.nodeId, inp.defaultValue ?? '');
}
function randomSeed(inp: ComfyDynamicInput) {
const max = Math.min(inp.max ?? 2 ** 32 - 1, Number.MAX_SAFE_INTEGER);
const min = Math.max(inp.min ?? 0, 0);
onSet(inp.nodeId, min + Math.floor(Math.random() * (max - min + 1)));
}
function pickOption(inp: ComfyDynamicInput, opt: string) {
onSet(inp.nodeId, opt);
open = false;
}
</script>
<div class="param-chip">
<button
type="button"
class="a-chip"
class:changed={changed && !invalid}
class:err={invalid}
aria-haspopup="dialog"
aria-expanded={open}
title={title ?? inputs.map((i) => i.label).join(' × ')}
onclick={() => (open = !open)}
>
<span class="lbl">{text}</span>
</button>
<ShellPop bind:open label={inputs.map((i) => i.label).join(' × ')} placement="top" width="260px">
{#if single?.kind === 'select' && single.options?.length}
<div class="a-menu-group">{single.label}</div>
{#each single.options as opt (opt)}
<button
type="button"
class="a-menu-item"
aria-current={current(single) === opt ? 'true' : undefined}
onclick={() => pickOption(single, opt)}
>
<span class="a-menu-label">{opt}</span>
{#if String(single.defaultValue ?? '') === opt}
<span class="a-menu-meta">{t('desk.origValue')}</span>
{/if}
</button>
{/each}
{:else}
<div class="fields">
{#each inputs as inp (inp.nodeId)}
{@const id = `pc-${uid}-${inp.nodeId.replace(/[^a-zA-Z0-9_-]/g, '')}`}
<div class="field-row">
<label class="field-label" for={id}>
{inp.label}
{#if inp.defaultValue !== undefined && inp.defaultValue !== ''}
<span class="orig mono">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
{/if}
</label>
<div class="inline">
{#if inp.kind === 'number'}
<input
{id}
class="a-input mono"
class:invalid
type="number"
inputmode="numeric"
value={current(inp)}
oninput={(e) => setNumber(inp, e.currentTarget.value)}
onkeydown={(e) => e.key === 'Enter' && (open = false)}
/>
{:else}
<input
{id}
class="a-input"
class:invalid
type="text"
value={current(inp)}
oninput={(e) => onSet(inp.nodeId, e.currentTarget.value)}
onkeydown={(e) => e.key === 'Enter' && (open = false)}
/>
{/if}
{#if inputRole(inp) === 'seed'}
<button type="button" class="a-btn secondary sm" onclick={() => randomSeed(inp)}>
{t('desk.seedRandom')}
</button>
{/if}
</div>
{#if inp.min != null || inp.max != null}
<span class="field-hint mono">{inp.min ?? '−∞'} – {inp.max ?? '∞'}</span>
{/if}
</div>
{/each}
</div>
{/if}
{#if changed}
<div class="foot">
<button type="button" class="a-btn ghost sm" onclick={restore}>{t('desk.restoreOrig')}</button>
</div>
{/if}
</ShellPop>
</div>
<style>
.param-chip {
position: relative;
min-width: 0;
}
.fields {
display: grid;
gap: var(--sp-5);
padding: var(--sp-3);
}
.field-row {
display: grid;
gap: var(--sp-2);
}
.field-row .field-label {
display: flex;
justify-content: space-between;
gap: var(--sp-3);
margin: 0;
}
.orig {
font-size: var(--fs-mini);
color: var(--text-3);
font-weight: 400;
}
.inline {
display: flex;
gap: var(--sp-2);
}
.field-row .field-hint {
margin: 0;
}
.foot {
display: flex;
justify-content: flex-end;
padding: var(--sp-2) var(--sp-1) 0;
border-top: 1px solid var(--line);
margin-top: var(--sp-2);
}
</style>
@@ -0,0 +1,198 @@
<script lang="ts">
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import type { DeskSchema, FormValue, FormValues } from './schema';
/**
* 参数 (spec-v2 §3.3 检视区 · 参数): every non-media input the workflow
* declares, each with its 原值 and a gold edge once changed. Common ones
* first; the rest fold under 高级参数 — untouched, they keep the
* workflow's own values.
*/
interface Props {
workflow: Workflow | null;
ds: DeskSchema;
values: FormValues;
errors: Record<string, string>;
showErrors: boolean;
changedCount: number;
onSet: (nodeId: string, value: FormValue) => void;
onRestoreAll: () => void;
}
let { workflow, ds, values, errors, showErrors, changedCount, onSet, onRestoreAll }: Props = $props();
let advancedOpen = $state(false);
const brokenReasons = $derived(
(workflow?.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message),
);
const advancedErr = $derived(showErrors && ds.advanced.some((i) => !!errors[i.nodeId]?.trim()));
$effect(() => {
if (advancedErr) advancedOpen = true;
});
function patch(inputs: ComfyDynamicInput[], next: Record<string, string | number>) {
for (const inp of inputs) {
const v = next[inp.nodeId];
if (v !== undefined && v !== values[inp.nodeId]) onSet(inp.nodeId, v);
}
}
</script>
<div class="params">
{#if !workflow}
<p class="note">{t('desk.pickWorkflow')}</p>
{:else}
{#if brokenReasons.length}
<div class="note err" role="alert">
<strong>{t('studio.wfBrokenTitle')}</strong>
<ul>
{#each brokenReasons as r (r)}<li>{r}</li>{/each}
</ul>
<span>{t('studio.wfBrokenHint')}</span>
</div>
{/if}
{#if !(workflow.input_schema ?? []).length}
<p class="note">{t('studio.wfNoParamsNote')}</p>
{:else if (workflow.input_schema ?? []).some((i) => i.auto)}
<p class="note">{t('studio.wfAutoParamsNote', { n: workflow.input_schema.length })}</p>
{/if}
{#if ds.common.length}
<div class="section-title">{t('desk.paramsCommon')}</div>
<ComfyDynamicForm
inputs={ds.common}
values={values as Record<string, string | number>}
quiet
showOrig
{errors}
{showErrors}
onChange={(v) => patch(ds.common, v)}
/>
{/if}
{#if ds.advanced.length}
<div class="fold">
<button
type="button"
class="fold-head"
class:err={advancedErr}
aria-expanded={advancedOpen}
aria-controls="desk-advanced"
onclick={() => (advancedOpen = !advancedOpen)}
>
<span class="chev" aria-hidden="true"><Icon name="play" size={10} /></span>
{t('desk.advanced', { n: ds.advanced.length })}
</button>
<div id="desk-advanced" class="collapse" class:open={advancedOpen} inert={!advancedOpen}>
<div class="collapse-in">
<ComfyDynamicForm
inputs={ds.advanced}
values={values as Record<string, string | number>}
quiet
showOrig
{errors}
{showErrors}
onChange={(v) => patch(ds.advanced, v)}
/>
</div>
</div>
</div>
{/if}
{#if changedCount > 0}
<div class="restore">
<button type="button" class="a-btn ghost sm" onclick={onRestoreAll}>
<Icon name="retry" size={13} />
{t('desk.restoreAll')}
</button>
</div>
{/if}
{/if}
</div>
<style>
.params {
display: grid;
gap: var(--sp-4);
}
.note {
margin: 0;
padding: var(--sp-3) var(--sp-4);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
font-size: var(--fs-aux);
}
.note.err {
background: var(--err-soft);
color: var(--err);
}
.note ul {
margin: var(--sp-2) 0;
padding-left: 18px;
}
.section-title {
font-size: var(--fs-aux);
color: var(--text-3);
}
.fold {
border-top: 1px solid var(--line);
}
.fold-head {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: var(--sp-4) 0;
border: 0;
background: none;
color: var(--text-2);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
cursor: pointer;
transition: color var(--d-state) ease;
}
.fold-head.err {
color: var(--err);
}
.chev {
display: inline-grid;
transition: transform var(--d-pop) var(--ease-out);
}
.fold-head[aria-expanded='true'] .chev {
transform: rotate(90deg);
}
@media (hover: hover) and (pointer: fine) {
.fold-head:hover {
color: var(--text);
}
}
.collapse {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows var(--d-enter) var(--ease-out),
opacity var(--d-state) ease;
}
.collapse.open {
grid-template-rows: 1fr;
opacity: 1;
}
.collapse-in {
min-height: 0;
overflow: hidden;
}
.collapse.open .collapse-in {
padding-bottom: var(--sp-2);
}
.restore {
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,228 @@
<script lang="ts">
import MediaSlot from '$lib/components/studio/MediaSlot.svelte';
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
import { mediaFileProblem } from '$lib/comfy/imageFile';
import { createUploadManager } from '$lib/comfy/useUpload.svelte';
import type { ComfyDynamicInput } from '$lib/comfy/types';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { h3Warnings, listKind, listPaths, type DeskSchema, type FormValue, type FormValues, type SlotDef } from './schema';
/**
* 输入槽 (spec-v2 §3.3): exactly the media inputs the workflow JSON wires —
* text-to-image shows none, image-to-image one input, image-to-video a
* first frame, ref-to-video the 1–N list. Owns uploads and rejects;
* values flow back through `onSet`.
*/
interface Props {
ds: DeskSchema;
values: FormValues;
showErrors: boolean;
errors: Record<string, string>;
/** True while any upload runs (generate waits for it). */
busy?: boolean;
onSet: (nodeId: string, value: FormValue) => void;
onFromResults: (nodeId: string) => void;
}
let { ds, values, showErrors, errors, busy = $bindable(false), onSet, onFromResults }: Props = $props();
const uploads = createUploadManager();
type Reject = { name: string; message: string };
let rejects = $state<Record<string, Reject[]>>({});
let fileSizes = $state<Record<string, number>>({});
let replacingKey = $state('');
let replacingIndex = $state(-1);
$effect(() => {
busy = Object.keys(uploads.uploading).length > 0;
});
// A different workflow starts with no stale rejects.
$effect(() => {
void ds;
rejects = {};
});
const warns = $derived(h3Warnings(ds, values));
function setRejects(nodeId: string, list: Reject[]) {
rejects = { ...rejects, [nodeId]: list };
}
async function upload(nodeId: string, file: File): Promise<string | null> {
const path = await uploads.uploadSafe(nodeId, file);
if (path) fileSizes = { ...fileSizes, [path]: file.size };
return path;
}
async function slotFile(s: SlotDef, file: File) {
const problem = mediaFileProblem(s.mediaKind, file);
if (problem) {
setRejects(s.inp.nodeId, [{ name: file.name, message: problem }]);
return;
}
setRejects(s.inp.nodeId, []);
const path = await upload(s.inp.nodeId, file);
if (path) onSet(s.inp.nodeId, path);
}
/** Add picked/dropped files in order; a batch that would pass `max` is refused whole. */
async function groupAdd(entry: ComfyDynamicInput, files: File[]) {
const key = entry.nodeId;
const mk = listKind(entry);
const max = entry.max ?? 9;
const ok = files.filter((f) => !mediaFileProblem(mk, f));
setRejects(
key,
files.filter((f) => mediaFileProblem(mk, f)).map((f) => ({ name: f.name, message: mediaFileProblem(mk, f)! })),
);
const start = listPaths(values, entry);
if (start.length + ok.length > max) {
toast.error(t('studio.errRefMax', { max, n: start.length, k: ok.length }));
return;
}
for (const f of ok) {
const path = await upload(key, f);
if (!path) break;
onSet(key, [...listPaths(values, entry), path]);
}
}
async function groupReplace(entry: ComfyDynamicInput, index: number, file: File) {
const key = entry.nodeId;
const problem = mediaFileProblem(listKind(entry), file);
if (problem) {
setRejects(key, [{ name: file.name, message: problem }]);
return;
}
setRejects(key, []);
replacingKey = key;
replacingIndex = index;
try {
const path = await upload(key, file);
if (path) onSet(key, listPaths(values, entry).map((p, i) => (i === index ? path : p)));
} finally {
replacingKey = '';
replacingIndex = -1;
}
}
function fileKind(f: File): 'image' | 'video' | 'audio' {
if (f.type.startsWith('video/')) return 'video';
if (f.type.startsWith('audio/')) return 'audio';
return 'image';
}
/** Composer-wide drop: each file goes to the first wired slot of its kind with room. */
export function dropFiles(files: File[]) {
const pending = new Map<string, File[]>();
const taken = new Set<string>();
let unplaced = 0;
for (const f of files) {
const mk = fileKind(f);
const slot = ds.slots.find(
(s) =>
s.mediaKind === mk &&
!s.mask &&
!taken.has(s.inp.nodeId) &&
!String(values[s.inp.nodeId] ?? ''),
);
if (slot) {
taken.add(slot.inp.nodeId);
void slotFile(slot, f);
continue;
}
const list = ds.lists.find((g) => listKind(g) === mk);
if (list) {
pending.set(list.nodeId, [...(pending.get(list.nodeId) ?? []), f]);
continue;
}
unplaced += 1;
}
for (const [nodeId, batch] of pending) {
const entry = ds.lists.find((g) => g.nodeId === nodeId);
if (entry) void groupAdd(entry, batch);
}
if (unplaced) toast.error(t('desk.dropNoSlot', { n: unplaced }));
}
</script>
{#if ds.slots.length || ds.lists.length}
<div class="tray">
{#each ds.slots as s (s.inp.nodeId)}
{@const reject = rejects[s.inp.nodeId]?.[0]}
<MediaSlot
label={s.label}
hint={s.lastFrame && !s.inp.required ? t('studio.slotOptional') : undefined}
required={s.inp.required}
value={String(values[s.inp.nodeId] ?? '')}
kind={s.mediaKind}
size={fileSizes[String(values[s.inp.nodeId] ?? '')]}
uploading={uploads.isUploading(s.inp.nodeId)}
uploadName={uploads.uploading[s.inp.nodeId]}
need={showErrors && !!errors[s.inp.nodeId]}
error={reject?.message ?? (showErrors ? errors[s.inp.nodeId] : undefined)}
allowFromResults={s.fromResults}
anchorId={`slot-${s.inp.nodeId}`}
onFile={(f) => slotFile(s, f)}
onFromResults={() => onFromResults(s.inp.nodeId)}
onClear={() => {
setRejects(s.inp.nodeId, []);
onSet(s.inp.nodeId, '');
}}
/>
{/each}
{#each ds.lists as entry (entry.nodeId)}
<RefImageGrid
label={entry.label}
mediaKind={listKind(entry)}
paths={listPaths(values, entry)}
max={entry.max ?? 9}
sizes={fileSizes}
uploadingName={uploads.uploading[entry.nodeId]}
uploadingIndex={replacingKey === entry.nodeId ? replacingIndex : -1}
rejects={rejects[entry.nodeId] ?? []}
need={showErrors && !!errors[entry.nodeId]}
error={showErrors ? errors[entry.nodeId] : undefined}
allowFromResults={entry.kind === 'image_list'}
anchorId={`slot-${entry.nodeId}`}
onAdd={(files) => groupAdd(entry, files)}
onReplace={(i, file) => groupReplace(entry, i, file)}
onRemove={(i) => onSet(entry.nodeId, listPaths(values, entry).filter((_, j) => j !== i))}
onDismissReject={(i) => setRejects(entry.nodeId, (rejects[entry.nodeId] ?? []).filter((_, j) => j !== i))}
onFromResults={entry.kind === 'image_list' ? () => onFromResults(entry.nodeId) : undefined}
/>
{/each}
</div>
{#if showErrors && errors.firstLast}
<p class="a-slot-hint err note">{errors.firstLast}</p>
{/if}
{#if ds.hasH3Hint}
<div class="note" role="note">
<p class="a-slot-hint">{t('wfMedia.h3Limits')}</p>
{#each warns as w (w)}
<p class="a-slot-hint warn">{w}</p>
{/each}
</div>
{/if}
{/if}
<style>
.tray {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3) var(--sp-6);
padding: var(--sp-4) var(--sp-5) 0;
}
.note {
margin: 0;
padding: var(--sp-2) var(--sp-5) 0;
}
.note p {
margin: 0;
}
.warn {
color: var(--warn);
}
</style>
@@ -0,0 +1,602 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { assetUrl } from '$lib/api';
import type { Job, Workflow } from '$lib/comfy/types';
import { progressFor } from '$lib/jobProgress';
import { jobStatusInfo } from '$lib/jobStatus';
import { parseDbTs } from '$lib/dbtime';
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from './schema';
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf } from './takes';
/**
* 一条记录 (spec-v2 §3.3 记录卡): workflow · usage · time, the prompt, the
* inputs it used, its real state (phase / progress / time / error) and
* the first output. The whole card selects; actions sit above it.
* D 批 (HEL-654): the gold sel-mark frame slides between cards on
* pointer selection (driven by the feed), new takes enter with a
* 220ms rise, completion flashes once, failure shakes once, a done
* take whose files no longer load turns amber (文件缺失) with
* 用相同参数重做 / 移除记录, and takes on a deleted workflow are
* marked as such instead of pretending nothing changed.
*/
interface Props {
job: Job;
wf: Workflow | undefined;
selected: boolean;
nowMs: number;
busy?: boolean;
/** Play the 220ms entrance rise (new take / undo restore). */
enter?: boolean;
/** One-shot 900ms gold flash on completion. */
flash?: boolean;
/** One-shot failure shake. */
shake?: boolean;
onSelect: () => void;
onReuse: () => void;
onRetry: () => void;
onCancel: () => void;
onDelete: () => void;
}
let {
job,
wf,
selected,
nowMs,
busy = false,
enter = false,
flash = false,
shake = false,
onSelect,
onReuse,
onRetry,
onCancel,
onDelete,
}: Props = $props();
const st = $derived(jobStatusInfo(job));
// §3.4 红/琥珀界线: red follows the backend task status only. Amber is
// reserved for a SUCCEEDED take whose product files no longer load.
const gonePaths = $derived(missingOutputsOf(job));
const fileGone = $derived(gonePaths.length > 0);
const tone = $derived(fileGone ? 'warn' : st.tone === 'queue' ? '' : st.tone === 'fail' ? 'err' : st.tone);
const label = $derived(fileGone ? t('jobst.missing') : st.label);
const wfDeleted = $derived(job.workflow_id == null);
const prompt = $derived(promptOf(job, wf));
const inputs = $derived(inputsOf(job, wf).filter((i) => i.kind === 'image'));
const output = $derived(
fileGone
? (job.output_paths.find((p) => !gonePaths.includes(p)) ?? null)
: (job.output_paths[0] ?? null),
);
const progress = $derived.by(() => {
if (job.status !== 'running') return 0;
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase;
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
return live?.progress ?? 3;
});
const active = $derived(job.status === 'pending' || job.status === 'running');
const name = $derived(wf?.name ?? t('studio.jobName', { id: job.id }));
let portrait = $state(false);
let clipSec = $state<number | null>(null);
let errOpen = $state(false);
function noteImage(e: Event) {
const img = e.currentTarget as HTMLImageElement;
portrait = img.naturalHeight > img.naturalWidth;
}
function noteVideo(e: Event) {
const v = e.currentTarget as HTMLVideoElement;
portrait = v.videoHeight > v.videoWidth;
if (Number.isFinite(v.duration)) clipSec = v.duration;
}
</script>
<article
class="take"
class:selected
class:enter
class:done-flash={flash}
class:shake
data-job={job.id}
>
<span class="sel-mark" aria-hidden="true"></span>
<button
type="button"
class="hit"
aria-pressed={selected}
aria-label={`${name} · ${label}`}
onclick={onSelect}
></button>
<div class="head">
<span class="name">{name}</span>
{#if wfDeleted}<span class="a-tag">{t('studio.wfDeletedTag')}</span>{/if}
{#if wf?.usage}<span class="a-tag">{usageLabel(wf.usage)}</span>{/if}
<span class="meta mono">{fmtClock(parseDbTs(job.created_at))}</span>
</div>
{#if prompt}<p class="prompt">{prompt}</p>{/if}
{#if inputs.length}
<div class="inputs" aria-label={t('desk.inputs')}>
{#each inputs.slice(0, 5) as inp (inp.path + inp.label)}
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
{/each}
{#if inputs.length > 5}<span class="more">+{inputs.length - 5}</span>{/if}
</div>
{/if}
<div class="state">
<span class="a-tag {tone}">{label}</span>
{#if st.tone === 'run'}
{#if progress > 0}
<div class="a-progress bar" style:--p={progress / 100}><i></i></div>
{/if}
<span class="txt">{fmtDur(elapsedSec(job, nowMs))}</span>
{:else if st.tone === 'ok' && !fileGone}
<span class="txt">{t('desk.took', { d: fmtDur(elapsedSec(job, nowMs)) })}</span>
{:else if st.tone === 'fail'}
<button
type="button"
class="err-txt"
class:open={errOpen}
title={st.detail}
aria-expanded={errOpen}
onclick={() => (errOpen = !errOpen)}
>
{st.detail}
</button>
{:else if fileGone}
<span class="gone-txt">{t('jobst.missingDetail')}</span>
{:else}
<span class="txt">{st.detail}</span>
{/if}
<div class="actions">
{#if fileGone}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>
{t('desk.redo')}
</button>
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('desk.removeRecord')}</button>
{:else}
<button type="button" class="a-btn ghost xs" onclick={onReuse}>{t('desk.reuse')}</button>
{#if st.tone === 'fail' && job.error !== 'cancelled'}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>{t('common.retry')}</button>
{/if}
{#if active}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onCancel}>{t('common.cancel')}</button>
{/if}
{#if output}
<a class="a-btn ghost xs" href={assetUrl(output)} download>{t('desk.download')}</a>
{/if}
{#if !active}
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('studio.deleteBtn')}</button>
{/if}
{/if}
</div>
</div>
<div class="out">
{#if output}
<div class="thumb a-photo" class:portrait>
{#if st.tone === 'ok' && !fileGone}<span class="a-fix">{t('jobst.done')}</span>{/if}
{#if isVideoOutput(job, output)}
<video
src={assetUrl(output)}
preload="metadata"
muted
playsinline
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
<span class="play" aria-hidden="true"><Icon name="play" size={10} /></span>
{#if clipSec != null}<span class="badge mono">{fmtDur(clipSec)}</span>{/if}
{:else}
<img
src={assetUrl(output)}
alt=""
loading="lazy"
onload={(e) => {
clearOutputMissing(output);
noteImage(e);
}}
onerror={() => output && markOutputMissing(output)}
/>
{/if}
{#if job.output_paths.length > 1}<span class="count mono">×{job.output_paths.length}</span>{/if}
</div>
{:else if active}
<div class="thumb pending">{st.label}</div>
{:else if fileGone}
<div class="thumb gone">{t('jobst.missing')}</div>
{:else}
<div class="thumb missing">{t('desk.notGenerated')}</div>
{/if}
</div>
</article>
<style>
.take {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-4) var(--sp-6);
margin-bottom: var(--sp-3);
padding: var(--sp-5) var(--sp-6);
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--bg-2);
transition:
border-color var(--d-state) ease,
background-color var(--d-state) ease,
box-shadow var(--d-state) ease;
}
/* 选中框:取景框角线从上一条记录滑到新选中的记录(feed 驱动 WAAPI,
HEL-680 四角短线取代整框描边) */
.sel-mark {
position: absolute;
inset: -1px;
z-index: 3;
display: none;
pointer-events: none;
background-image:
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0);
background-position:
left top,
left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
background-size:
14px 2px,
2px 14px,
14px 2px,
2px 14px,
14px 2px,
2px 14px,
14px 2px,
2px 14px;
background-repeat: no-repeat;
}
.take.selected {
border-color: transparent;
background: var(--accent-wash), var(--bg-3);
box-shadow: var(--sel-shadow);
}
.take.selected > .sel-mark {
display: block;
}
@media (hover: hover) and (pointer: fine) {
.take:hover:not(.selected) {
border-color: var(--line-2);
background: var(--bg-3);
}
}
/* 新记录 / 撤销恢复:220ms 上移 8px 入场(减动效只余淡变) */
.take.enter {
animation: take-in var(--d-enter) var(--ease-out);
}
@keyframes take-in {
from {
opacity: 0;
transform: translateY(8px);
}
}
/* 完成一次性金色光晕 900ms;失败原地轻抖一次 */
.take.done-flash {
animation: done-flash var(--d-flash) var(--ease-out);
}
@keyframes done-flash {
from {
box-shadow: var(--accent-glow), 0 0 0 1px var(--accent-line);
}
}
.take.shake {
animation: take-shake 320ms var(--ease-out);
}
@keyframes take-shake {
20% {
transform: translateX(-4px);
}
45% {
transform: translateX(4px);
}
70% {
transform: translateX(-2px);
}
}
:global(:root[data-reduce='1']) .take.enter {
animation: take-fade var(--d-state) ease;
}
:global(:root[data-reduce='1']) .take.done-flash,
:global(:root[data-reduce='1']) .take.shake {
animation: none;
}
@keyframes take-fade {
from {
opacity: 0;
}
}
.hit {
position: absolute;
inset: 0;
z-index: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.hit:focus-visible {
outline-offset: -2px;
border-radius: var(--r-lg);
}
.head,
.prompt,
.inputs,
.state {
grid-column: 1;
position: relative;
pointer-events: none;
}
.head {
display: flex;
align-items: center;
gap: var(--sp-3);
min-width: 0;
}
.name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
color: var(--text-3);
font-size: var(--fs-aux);
white-space: nowrap;
}
.prompt {
margin: 0;
color: var(--text-2);
font-size: var(--fs-body-sm);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.inputs {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.mini {
width: 26px;
height: 26px;
border-radius: 5px;
object-fit: cover;
border: 1px solid var(--line-2);
}
.more {
padding-left: 2px;
font-size: var(--fs-mini);
color: var(--text-3);
}
.state {
display: flex;
align-items: center;
gap: var(--sp-3);
min-height: 22px;
font-size: var(--fs-aux);
}
.bar {
flex: 0 0 140px;
}
.txt {
color: var(--text-2);
white-space: nowrap;
}
.gone-txt {
min-width: 0;
color: var(--warn);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 失败:一行中文结论自动展开,点开看完整详情 */
.err-txt {
min-width: 0;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
color: var(--err);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: auto;
cursor: pointer;
}
.err-txt.open {
white-space: normal;
word-break: break-word;
}
.err-txt:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--r-sm);
}
.actions {
display: flex;
gap: var(--sp-1);
margin-left: auto;
pointer-events: auto;
opacity: 0;
transition: opacity var(--d-state) ease;
}
.take:focus-within .actions,
.take.selected .actions {
opacity: 1;
}
@media (hover: hover) and (pointer: fine) {
.take:hover .actions {
opacity: 1;
}
}
@media (hover: none) {
.actions {
opacity: 1;
}
}
.out {
grid-column: 2;
grid-row: 1 / span 4;
align-self: start;
position: relative;
pointer-events: none;
}
.thumb {
position: relative;
width: 150px;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: var(--r-md);
background: var(--bg-4);
box-shadow: inset 0 0 0 1px var(--on-media-line);
}
/* 相纸白边 (HEL-680):有产出的记录缩略图按相纸呈现,压过占位态样式 */
.thumb.a-photo {
border-radius: 2px;
background: var(--accent);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}
.thumb.portrait {
width: 96px;
aspect-ratio: 3 / 4;
}
.thumb img,
.thumb video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.badge,
.count {
position: absolute;
bottom: var(--sp-2);
padding: 1px 5px;
border-radius: 4px;
background: var(--media-scrim);
color: var(--on-media);
font-size: var(--fs-mini);
}
.badge {
right: var(--sp-2);
}
.count {
right: var(--sp-2);
top: var(--sp-2);
bottom: auto;
}
.play {
position: absolute;
left: var(--sp-2);
bottom: var(--sp-2);
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--media-scrim);
color: var(--on-media);
}
.thumb.pending,
.thumb.missing,
.thumb.gone {
display: grid;
place-items: center;
padding: var(--sp-2);
box-sizing: border-box;
text-align: center;
font-size: var(--fs-mini);
color: var(--text-3);
border: 1px dashed var(--line-2);
background: var(--bg-2);
box-shadow: none;
}
.thumb.pending {
border-style: solid;
color: var(--run);
}
.thumb.gone {
color: var(--warn);
border-color: var(--warn);
}
.thumb.pending::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 30%, var(--run-soft) 50%, transparent 70%);
animation: take-sheen 1.4s linear infinite;
}
@keyframes take-sheen {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
:global(:root[data-reduce='1']) .thumb.pending::after {
animation: none;
}
@media (max-width: 760px) {
.take {
padding: var(--sp-4);
}
.head {
flex-wrap: wrap;
row-gap: 2px;
}
.out {
grid-row: 1 / span 2;
}
.state {
grid-column: 1 / -1;
flex-wrap: wrap;
}
.thumb {
width: 108px;
}
.thumb.portrait {
width: 72px;
}
.bar {
flex-basis: 80px;
}
}
</style>
@@ -0,0 +1,344 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { SvelteSet } from 'svelte/reactivity';
import Icon from '$lib/components/ui/Icon.svelte';
import type { Job, Workflow } from '$lib/comfy/types';
import { parseDbTs } from '$lib/dbtime';
import { t } from '$lib/i18n.svelte';
import TakeCard from './TakeCard.svelte';
import { dayLabel, matchesFilter, type FeedFilter } from './takes';
/**
* 记录流 (spec-v2 §3.3): every desk take, newest last above the composer,
* filtered 全部 / 图片 / 视频 / 失败 and grouped by day.
* D 批 (HEL-654): rows pending their 5-second undo collapse out
* (200ms) instead of vanishing, new/restored takes rise in (220ms),
* the gold selection frame slides between cards on pointer picks
* (键盘 ↑↓/J/K 即时切换无动画 — driven by the desk through the
* exported helpers), and long histories page in from the top
* (spec §五: 记录流超 200 条分页).
*/
interface Props {
jobs: Job[];
workflowOf: (job: Job) => Workflow | undefined;
filter: FeedFilter;
selectedId: number | null;
nowMs: number;
busyId: number | null;
loading: boolean;
/** Takes whose delete is inside the 5s undo window — hidden. */
pendingIds: SvelteSet<number>;
/** One-shot completion flashes / failure shakes (driven by SSE). */
flashIds: SvelteSet<number>;
shakeIds: SvelteSet<number>;
/** Ordered visible ids (oldest first) for ↑↓/J/K. */
orderedIds?: number[];
/** Shown under the filters (e.g. the workbench context bar). */
banner?: Snippet;
/** Inspector toggle when it is hidden (desktop) or an overlay (mid). */
inspectorToggle?: Snippet;
onSelect: (job: Job) => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onCancel: (job: Job) => void;
onDelete: (job: Job) => void;
}
let {
jobs,
workflowOf,
filter = $bindable(),
selectedId,
nowMs,
busyId,
loading,
pendingIds,
flashIds,
shakeIds,
orderedIds = $bindable(),
banner,
inspectorToggle,
onSelect,
onReuse,
onRetry,
onCancel,
onDelete,
}: Props = $props();
const FILTERS: Array<{ id: FeedFilter; key: string }> = [
{ id: 'all', key: 'desk.filterAll' },
{ id: 'image', key: 'desk.filterImage' },
{ id: 'video', key: 'desk.filterVideo' },
{ id: 'failed', key: 'desk.filterFailed' },
];
/** §五 分页: render the newest PAGE_SIZE takes; older ones load on demand. */
const PAGE_SIZE = 80;
let limit = $state(PAGE_SIZE);
let scroller = $state<HTMLElement | null>(null);
/** Oldest first so the newest take sits right above the composer. */
const shown = $derived(
jobs
.filter((j) => matchesFilter(j, filter) && !pendingIds.has(j.id))
.sort((a, b) => parseDbTs(a.created_at) - parseDbTs(b.created_at)),
);
const hiddenEarlier = $derived(Math.max(0, shown.length - limit));
const visible = $derived(hiddenEarlier > 0 ? shown.slice(-limit) : shown);
$effect(() => {
orderedIds = visible.map((j) => j.id);
});
const activeCount = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running').length);
const days = $derived.by(() => {
const out: Array<{ label: string; items: Job[] }> = [];
for (const job of visible) {
const label = dayLabel(parseDbTs(job.created_at), nowMs);
const last = out[out.length - 1];
if (last?.label === label) last.items.push(job);
else out.push({ label, items: [job] });
}
return out;
});
// Stick to the bottom as takes arrive, unless the user scrolled up to read.
let pinned = true;
function onScroll() {
if (!scroller) return;
pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80;
}
$effect(() => {
void visible.length;
void filter;
if (!scroller || !pinned) return;
const el = scroller;
requestAnimationFrame(() => (el.scrollTop = el.scrollHeight));
});
// ------------------------------------------------------------------
// Entrance bookkeeping: only takes that appear AFTER the first fill
// (or come back from an undo) play the 220ms rise.
// ------------------------------------------------------------------
const seenIds = new Set<number>();
const animateIds = new SvelteSet<number>();
let hydrated = false;
$effect(() => {
if (!hydrated) {
if (jobs.length) {
for (const j of jobs) seenIds.add(j.id);
hydrated = true;
}
return;
}
for (const j of jobs) {
if (seenIds.has(j.id)) continue;
seenIds.add(j.id);
animateIds.add(j.id);
}
});
export function markEnter(jobId: number) {
seenIds.add(jobId);
animateIds.add(jobId);
}
function reduced(): boolean {
return (
document.documentElement.dataset.reduce === '1' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
// ------------------------------------------------------------------
// Gold selection frame (HEL-648 slideMark): pointer picks only.
// ------------------------------------------------------------------
export function markRect(): DOMRect | null {
const el = scroller?.querySelector(`.take[data-job="${selectedId}"]`);
return el instanceof HTMLElement ? el.getBoundingClientRect() : null;
}
export function slideFrom(fromRect: DOMRect | null) {
if (!fromRect || reduced()) return;
const el = scroller?.querySelector(`.take[data-job="${selectedId}"]`);
const mark = el?.querySelector('.sel-mark');
if (!(el instanceof HTMLElement) || !(mark instanceof HTMLElement)) return;
const to = el.getBoundingClientRect();
if (Math.abs(fromRect.top - to.top) < 1 && Math.abs(fromRect.height - to.height) < 1) return;
mark.animate(
[
{ top: `${fromRect.top - to.top - 1}px`, bottom: `${to.bottom - fromRect.bottom - 1}px` },
{ top: '-1px', bottom: '-1px' },
],
{ duration: 220, easing: 'cubic-bezier(0.23, 1, 0.32, 1)' },
);
}
/** Bring a keyboard-selected take into view without animation. */
export function scrollToJob(jobId: number) {
const el = scroller?.querySelector(`.take[data-job="${jobId}"]`);
el?.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
// ------------------------------------------------------------------
// Row transitions: undo-pending rows collapse out (200ms 收起 + 补位),
// new/restored takes rise in (220ms). Everything else is instant.
// ------------------------------------------------------------------
function takeEnter(_node: HTMLElement, should: () => boolean) {
if (!should()) return { duration: 0 };
if (reduced()) return { duration: 120, css: (t: number) => `opacity: ${t}` };
return {
duration: 220,
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: translateY(${8 * (1 - t)}px)`,
};
}
function takeLeave(node: HTMLElement, isPending: () => boolean) {
if (!isPending()) return { duration: 0 };
if (reduced()) return { duration: 120, css: (t: number) => `opacity: ${t}` };
const h = node.offsetHeight;
const cs = getComputedStyle(node);
const num = (v: string) => parseFloat(v) || 0;
const padT = num(cs.paddingTop);
const padB = num(cs.paddingBottom);
const mb = num(cs.marginBottom);
return {
duration: 200,
easing: cubicOut,
css: (t: number, u: number) =>
`height:${h * t}px;padding-top:${padT * t}px;padding-bottom:${padB * t}px;` +
`margin-bottom:${mb * t}px;opacity:${t};transform:translateX(${-10 * u}px);overflow:hidden;`,
};
}
</script>
<div class="filters">
<div class="a-seg" role="group" aria-label={t('desk.filterAria')}>
{#each FILTERS as f (f.id)}
<button type="button" aria-pressed={filter === f.id} onclick={() => (filter = f.id)}>{t(f.key)}</button>
{/each}
</div>
<span class="count">
{t('desk.countTakes', { n: shown.length })}{#if activeCount}&nbsp;· {t('desk.countActive', { n: activeCount })}{/if}
</span>
{@render inspectorToggle?.()}
</div>
{@render banner?.()}
<div class="feed" bind:this={scroller} onscroll={onScroll}>
{#if loading && !jobs.length}
<p class="empty">{t('common.loading')}</p>
{:else if !shown.length}
<div class="empty">
<Icon name={filter === 'video' ? 'film' : filter === 'failed' ? 'check' : 'image'} size={20} />
<p>{filter === 'failed' ? t('desk.emptyFailed') : t('desk.emptyFeed')}</p>
</div>
{:else}
{#if hiddenEarlier > 0}
<button type="button" class="a-btn ghost sm earlier" onclick={() => (limit += PAGE_SIZE)}>
<Icon name="chevron-up" size={13} />
{t('desk.loadEarlier', { n: hiddenEarlier })}
</button>
{/if}
{#each days as day (day.label)}
<div class="day">{day.label}</div>
{#each day.items as job (job.id)}
<div
class="take-slot"
in:takeEnter={() => animateIds.has(job.id)}
out:takeLeave={() => pendingIds.has(job.id)}
>
<TakeCard
{job}
wf={workflowOf(job)}
selected={job.id === selectedId}
{nowMs}
busy={busyId === job.id}
enter={animateIds.has(job.id)}
flash={flashIds.has(job.id)}
shake={shakeIds.has(job.id)}
onSelect={() => onSelect(job)}
onReuse={() => onReuse(job)}
onRetry={() => onRetry(job)}
onCancel={() => onCancel(job)}
onDelete={() => onDelete(job)}
/>
</div>
{/each}
{/each}
{/if}
</div>
<style>
.filters {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-4) var(--sp-7);
border-bottom: 1px solid var(--line);
}
.count {
margin-left: auto;
color: var(--text-3);
font-size: var(--fs-aux);
white-space: nowrap;
}
.feed {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--sp-6) var(--sp-7) var(--sp-3);
}
.earlier {
display: flex;
margin: 0 auto var(--sp-4);
}
.day {
display: flex;
align-items: center;
gap: var(--sp-3);
margin: var(--sp-4) 0 var(--sp-3);
color: var(--text-3);
font-size: var(--fs-aux);
}
.day::after {
content: '';
flex: 1;
height: 1px;
background: var(--line);
}
.empty {
display: grid;
place-items: center;
gap: var(--sp-3);
margin: 0;
padding: var(--sp-8) var(--sp-6);
color: var(--text-3);
text-align: center;
font-size: var(--fs-aux);
}
.empty p {
margin: 0;
}
@media (max-width: 1179px) {
.filters {
padding: var(--sp-4) var(--sp-6);
}
.feed {
padding: var(--sp-5) var(--sp-6) var(--sp-3);
}
}
@media (max-width: 760px) {
.filters {
padding: var(--sp-3) var(--sp-5);
overflow-x: auto;
}
.count {
display: none;
}
.feed {
padding: var(--sp-5) var(--sp-5) var(--sp-2);
}
}
</style>
@@ -0,0 +1,277 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import ShellPop from '$lib/components/shell/ShellPop.svelte';
import { WORKFLOW_USAGES, type Workflow } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { slotSummary, usageLabel } from './schema';
/**
* 工作流选择 (spec-v2 §3.3 工作流面板): the composer's first chip opens a
* pop grouped by usage with search; each row shows what the workflow
* really takes (slot summary from its JSON), its connection and whether
* it last ran. Picking keeps the prompt and carried-over images.
* D 批 (HEL-654): the footer's「↑↓ 选择」is now literally true — an
* active row follows ↑↓ (and the pointer), Enter uses it, and the
* search's Enter picks the active row instead of always the first.
*/
interface Props {
workflows: Workflow[];
current: Workflow | null;
endpoints: Array<{ id: string; name: string }>;
onPick: (wf: Workflow) => void;
}
let { workflows, current, endpoints, onPick }: Props = $props();
let open = $state(false);
let query = $state('');
let activeId = $state<number | null>(null);
type Status = { tone: '' | 'ok' | 'warn' | 'err'; label: string };
function statusOf(wf: Workflow): Status {
if ((wf.warnings ?? []).some((w) => w.level === 'error')) return { tone: 'err', label: t('desk.wfBroken') };
const lt = wf.last_test;
if (!lt) return { tone: '', label: t('desk.wfUntested') };
if (lt.status === 'done' && lt.output_count > 0) return { tone: 'ok', label: t('desk.wfOk') };
if (lt.status === 'failed' || lt.status === 'done') return { tone: 'warn', label: t('desk.wfLastFailed') };
return { tone: '', label: t('desk.wfTesting') };
}
function endpointLabel(wf: Workflow): string {
const ep = endpoints.find((e) => e.id === wf.endpoint_id);
return ep ? ep.name : t('desk.wfDefaultConn');
}
const rows = $derived(
workflows.map((wf) => ({
wf,
summary: slotSummary(wf),
conn: endpointLabel(wf),
status: statusOf(wf),
usage: wf.usage ? usageLabel(wf.usage) : '',
})),
);
const groups = $derived.by(() => {
const q = query.trim().toLowerCase();
const hit = (r: (typeof rows)[number]) =>
!q || `${r.wf.name} ${r.usage} ${r.summary}`.toLowerCase().includes(q);
return WORKFLOW_USAGES.map((u) => ({
usage: u,
items: rows.filter((r) => r.wf.usage === u && hit(r)),
})).filter((g) => g.items.length);
});
/** Flattened rows in display order — the roving selection walks these. */
const flat = $derived(groups.flatMap((g) => g.items));
// Keep the active row real: default to the current workflow, clamp on
// filter changes, never point at a row that is no longer listed.
$effect(() => {
if (!open) return;
if (activeId != null && flat.some((r) => r.wf.id === activeId)) return;
activeId = flat.find((r) => r.wf.id === current?.id)?.wf.id ?? flat[0]?.wf.id ?? null;
});
function moveActive(delta: number) {
if (!flat.length) return;
let i = flat.findIndex((r) => r.wf.id === activeId);
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
activeId = flat[Math.max(0, Math.min(flat.length - 1, i))].wf.id;
}
function pickActive() {
const row = flat.find((r) => r.wf.id === activeId) ?? flat[0];
if (row) pick(row.wf);
}
function pick(wf: Workflow) {
open = false;
query = '';
if (wf.id !== current?.id) onPick(wf);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
pickActive();
}
}
</script>
<div class="picker">
<button
type="button"
id="desk-wf"
class="a-chip wf"
aria-haspopup="dialog"
aria-expanded={open}
title={current?.name ?? t('desk.pickWorkflow')}
onclick={() => (open = !open)}
>
<Icon name="playground" size={14} />
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
</button>
<ShellPop
bind:open
label={t('desk.pickWorkflow')}
placement="top"
width="520px"
arrowRoving={false}
onclose={() => (query = '')}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pop-body" onkeydown={onKeydown}>
<div class="search">
<Icon name="search" size={14} />
<input
class="search-input"
type="search"
bind:value={query}
placeholder={t('desk.wfSearchPh')}
aria-label={t('desk.wfSearchPh')}
/>
</div>
<div class="list" role="listbox" aria-label={t('desk.pickWorkflow')}>
{#each groups as g (g.usage)}
<div class="a-menu-group">{usageLabel(g.usage)} · {g.items.length}</div>
{#each g.items as r (r.wf.id)}
{@const cur = r.wf.id === current?.id}
<button
type="button"
class="a-menu-item row"
class:off={r.status.tone === 'err'}
class:active={r.wf.id === activeId}
role="option"
aria-current={cur ? 'true' : undefined}
aria-selected={r.wf.id === activeId ? 'true' : undefined}
onclick={() => pick(r.wf)}
onpointermove={() => (activeId = r.wf.id)}
>
<span class="main">
<span class="a-menu-label">{r.wf.name}{cur ? ` · ${t('desk.wfCurrent')}` : ''}</span>
<span class="sub">
<span class="a-tag">{r.summary}</span>
<span class="a-tag">{r.conn}</span>
</span>
</span>
<span class="a-tag {r.status.tone}">{r.status.label}</span>
</button>
{/each}
{:else}
<p class="empty">{t('desk.wfNoMatch')}</p>
{/each}
</div>
<div class="foot">
<a class="a-btn ghost sm" href="/connections">
<Icon name="upload" size={13} />
{t('desk.wfImport')}
</a>
<span class="keys">
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
</span>
</div>
</div>
</ShellPop>
</div>
<style>
.picker {
position: relative;
min-width: 0;
}
.pop-body {
display: flex;
flex-direction: column;
}
.search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4) var(--sp-4);
border-bottom: 1px solid var(--line);
color: var(--text-3);
}
.search-input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: var(--fs-section);
}
.search-input::placeholder {
color: var(--text-3);
}
/* Search and foot stay put; only the list scrolls inside the pop's max-height. */
.list {
max-height: min(380px, 50vh);
overflow-y: auto;
padding: var(--sp-1) 0;
}
.row {
align-items: center;
}
/* 键盘游走选中态(↑↓ 选择 · Enter 使用) */
.row.active {
background: var(--bg-4);
}
.main {
display: grid;
gap: 2px;
flex: 1;
min-width: 0;
}
.main .a-menu-label {
font-weight: 500;
}
.row.off .a-menu-label {
color: var(--text-3);
}
.sub {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
}
.empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
color: var(--text-3);
font-size: var(--fs-aux);
}
.foot {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-1) 0;
border-top: 1px solid var(--line);
}
.keys {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
}
.keys .a-kbd:not(:first-child) {
margin-left: var(--sp-2);
}
@media (max-width: 760px) {
.keys {
display: none;
}
}
</style>
@@ -0,0 +1,55 @@
import type { WorkbenchStageKey } from '$lib/api';
import type { DeskKind } from './schema';
/**
* 五段创作 → 生成台 handoff, carried in the URL so a reload keeps it.
* The desk only prefills from it; the playground generate API has no
* project/shot link, so results stay in the desk feed (no backfill).
*/
export interface DeskContext {
kind: DeskKind;
projectId: number;
stage: WorkbenchStageKey;
/** Human label of the source row, e.g. 镜头 S1 / 角色「林夏」. */
label: string;
prompt?: string;
negative?: string;
durationSec?: number;
}
const STAGES: WorkbenchStageKey[] = ['script', 'style', 'storyboard', 'assets', 'shot'];
export function deskHref(ctx: DeskContext): string {
const q = new URLSearchParams({
from: 'workbench',
project: String(ctx.projectId),
stage: ctx.stage,
label: ctx.label,
});
if (ctx.prompt) q.set('prompt', ctx.prompt);
if (ctx.negative) q.set('negative', ctx.negative);
if (ctx.durationSec != null) q.set('duration', String(ctx.durationSec));
return `/${ctx.kind}?${q.toString()}`;
}
export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
const q = url.searchParams;
if (q.get('from') !== 'workbench') return null;
const projectId = Number(q.get('project'));
const stage = q.get('stage') as WorkbenchStageKey | null;
if (!Number.isInteger(projectId) || projectId <= 0 || !stage || !STAGES.includes(stage)) return null;
const duration = Number(q.get('duration'));
return {
kind,
projectId,
stage,
label: q.get('label') ?? '',
prompt: q.get('prompt') ?? undefined,
negative: q.get('negative') ?? undefined,
durationSec: Number.isFinite(duration) && duration > 0 ? duration : undefined,
};
}
export function backHref(ctx: DeskContext): string {
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
}
@@ -0,0 +1,374 @@
import type { ComfyDynamicInput, Workflow, WorkflowUsage } from '$lib/comfy/types';
import { normalizeInputRole } from '$lib/comfy/parser';
import { ratioShort, resolutionSelectorSize } from '$lib/comfy/resolution';
import { t } from '$lib/i18n.svelte';
/**
* 生成台表单识别 (HEL-653, moved verbatim from StudioGenerate): slots and
* structured params come from what the workflow JSON actually declares —
* never from model names. No wiring → no slot.
*/
export type DeskKind = 'image' | 'video';
export type FormValue = string | number | string[];
export type FormValues = Record<string, FormValue>;
export interface SlotDef {
inp: ComfyDynamicInput;
label: string;
mediaKind: 'image' | 'video' | 'audio';
fromResults: boolean;
/** Required-slot message when left empty (ref-upload-spec §3). */
missing: string;
firstFrame: boolean;
lastFrame: boolean;
mask: boolean;
}
/** Why 生成 is disabled — the line next to the button (spec-v2 §1.2). */
export interface BlockReason {
text: string;
tone: 'warn' | 'err';
/** Element id to focus when the reason is clicked. */
target?: string;
/** Open the params tab instead of focusing. */
params?: boolean;
}
export interface DeskSchema {
prompt: ComfyDynamicInput | null;
negative: ComfyDynamicInput | null;
seed: ComfyDynamicInput | null;
width: ComfyDynamicInput | null;
height: ComfyDynamicInput | null;
resolution: ComfyDynamicInput | null;
duration: ComfyDynamicInput | null;
batch: ComfyDynamicInput | null;
/** ComfyUI ResolutionSelector: the ratio + megapixels pair decides width/height. */
ratio: ComfyDynamicInput | null;
megapixels: ComfyDynamicInput | null;
/** Single media inputs (input image, first/last frame, mask, ref video/audio). */
slots: SlotDef[];
/** Autogrow media lists actually wired (image / video / audio). */
lists: ComfyDynamicInput[];
/** Parameters shown as composer chips (比例 / 尺寸 / 分辨率 / 时长 / 种子). */
common: ComfyDynamicInput[];
/** Everything else that is not media and not the prompt — folded. */
advanced: ComfyDynamicInput[];
hasH3Hint: boolean;
}
export function inputRole(inp: ComfyDynamicInput): string | null {
return normalizeInputRole(inp.role ?? null);
}
function hay(inp: ComfyDynamicInput): string {
return `${inp.role ?? ''} ${inp.label}`.toLowerCase();
}
export function isMedia(inp: ComfyDynamicInput): boolean {
return (
inp.kind === 'image' ||
inp.kind === 'image_url' ||
inp.kind === 'video' ||
inp.kind === 'audio' ||
inp.kind === 'image_list' ||
inp.kind === 'video_list' ||
inp.kind === 'audio_list'
);
}
function isList(inp: ComfyDynamicInput): boolean {
return inp.kind === 'image_list' || inp.kind === 'video_list' || inp.kind === 'audio_list';
}
function isMask(inp: ComfyDynamicInput): boolean {
return inputRole(inp) === 'mask' || /mask|遮罩/.test(hay(inp));
}
// Auto-detected slots carry their role from the wiring; tagged (Input:…)
// workflows still name frames only in the title.
export function isFirstFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
return inputRole(inp) === 'first_frame' || (!inp.auto && kind === 'video' && /first|start|首帧/.test(hay(inp)));
}
export function isLastFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
return inputRole(inp) === 'last_frame' || (!inp.auto && kind === 'video' && /last|end|尾帧/.test(hay(inp)));
}
export function listKind(entry: ComfyDynamicInput): 'image' | 'video' | 'audio' {
if (entry.kind === 'video_list') return 'video';
if (entry.kind === 'audio_list') return 'audio';
return 'image';
}
export function listPaths(values: FormValues, entry: ComfyDynamicInput): string[] {
const v = values[entry.nodeId];
if (Array.isArray(v)) return v;
return typeof v === 'string' && v ? [v] : [];
}
export function classifySchema(schema: ComfyDynamicInput[], kind: DeskKind): DeskSchema {
const plain = (pred: (i: ComfyDynamicInput) => boolean) =>
schema.find((i) => !isMedia(i) && pred(i)) ?? null;
const byRole = (r: string) => plain((i) => inputRole(i) === r);
const prompt = byRole('prompt');
const negative = byRole('negative');
const seed = byRole('seed');
const width = byRole('width');
const height = byRole('height');
const resolution = byRole('resolution');
const duration = byRole('duration');
const batch = plain((i) => /批次|batch/.test(hay(i)));
const ratio =
schema.find((i) => i.kind === 'select' && inputRole(i) === 'aspect_ratio' && i.resolution) ?? null;
const megapixels = ratio ? (schema.find((i) => i.nodeId === ratio.resolution!.megapixels) ?? null) : null;
const media = schema.filter((i) => isMedia(i) && !isList(i));
const plainImages = media.filter(
(i) => i.kind === 'image' && !isMask(i) && !isFirstFrame(i, kind) && !isLastFrame(i, kind),
);
const editInput = kind === 'image' && plainImages.length === 1 ? plainImages[0] : null;
const slots: SlotDef[] = [];
let refIdx = 0;
for (const inp of media) {
const base = { inp, missing: '', firstFrame: false, lastFrame: false, mask: false };
if (inp.kind === 'video') {
slots.push({ ...base, label: t('studio.slotRefVideo'), mediaKind: 'video', fromResults: false });
} else if (inp.kind === 'audio') {
slots.push({ ...base, label: t('studio.slotAudio'), mediaKind: 'audio', fromResults: false });
} else if (isMask(inp)) {
slots.push({ ...base, mask: true, label: t('studio.slotMask'), mediaKind: 'image', fromResults: false });
} else if (isFirstFrame(inp, kind)) {
slots.push({
...base,
firstFrame: true,
label: t('studio.slotFirst'),
mediaKind: 'image',
fromResults: true,
missing: t('studio.errNeedFirstFrame'),
});
} else if (isLastFrame(inp, kind)) {
slots.push({ ...base, lastFrame: true, label: t('studio.slotLast'), mediaKind: 'image', fromResults: true });
} else if (inp === editInput) {
slots.push({
...base,
label: t('studio.slotInput'),
mediaKind: 'image',
fromResults: true,
missing: t('studio.errNeedInputImage'),
});
} else {
refIdx += 1;
const label = inp.auto
? inp.label
: refIdx === 1 && media.length === 1
? t('studio.slotRef')
: t('studio.slotRefN', { n: refIdx });
slots.push({
...base,
label,
mediaKind: 'image',
fromResults: true,
missing: t('studio.errUpload', { label }),
});
}
}
const lists = schema.filter(isList);
const common = [ratio, width, height, resolution, duration, seed].filter(
(i): i is ComfyDynamicInput => !!i,
);
const commonIds = new Set(common.map((i) => i.nodeId));
const advanced = schema.filter((i) => !isMedia(i) && i !== prompt && !commonIds.has(i.nodeId));
return {
prompt,
negative,
seed,
width,
height,
resolution,
duration,
batch,
ratio,
megapixels,
slots,
lists,
common,
advanced,
hasH3Hint: lists.some((e) => e.media_hint === 'minimax_h3_ref2va'),
};
}
/** Workflow JSON defaults — the 原值 every parameter falls back to. */
export function schemaDefaults(schema: ComfyDynamicInput[]): FormValues {
const out: FormValues = {};
for (const inp of schema) {
if (inp.defaultValue !== undefined && inp.defaultValue !== '') out[inp.nodeId] = inp.defaultValue;
}
return out;
}
export function isChanged(inp: ComfyDynamicInput, values: FormValues): boolean {
const v = values[inp.nodeId];
const orig = inp.defaultValue;
if (v === undefined || v === '') return orig !== undefined && orig !== '';
if (orig === undefined || orig === '') return true;
return String(v) !== String(orig);
}
export function changedParams(ds: DeskSchema, values: FormValues): ComfyDynamicInput[] {
return [...ds.common, ...ds.advanced].filter((i) => isChanged(i, values));
}
export function outputSize(ds: DeskSchema, values: FormValues): { width: number; height: number } | null {
if (!ds.ratio?.resolution) return null;
const mp = Number(values[ds.megapixels?.nodeId ?? ''] ?? ds.megapixels?.defaultValue);
return resolutionSelectorSize(
String(values[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
mp,
ds.ratio.resolution.multiple,
);
}
export function batchCount(ds: DeskSchema, values: FormValues): number {
if (!ds.batch) return 1;
const n = Number(values[ds.batch.nodeId]);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
}
/** H3 Ref2VA official limits (soft hint, keyed off the node class only). */
export function h3Warnings(ds: DeskSchema, values: FormValues): string[] {
if (!ds.hasH3Hint) return [];
const warns: string[] = [];
const counts = { image: 0, video: 0, audio: 0 };
for (const g of ds.lists) counts[listKind(g)] += listPaths(values, g).length;
const total = counts.image + counts.video + counts.audio;
if (total > 12) warns.push(t('wfMedia.h3TotalOver', { n: total }));
if (counts.audio > 0 && counts.image === 0 && counts.video === 0) warns.push(t('wfMedia.h3AudioAlone'));
return warns;
}
/** Same rules the backend enforces before queueing (comfyui/validate.py). */
export function valueError(inp: ComfyDynamicInput, raw: string | number): string | null {
const label = inp.label;
if (inp.kind === 'select') {
return inp.options?.length && !inp.options.includes(String(raw)) ? t('studio.errOption', { label }) : null;
}
if (inp.kind !== 'number') return null;
const n = Number(raw);
if (!Number.isFinite(n)) return t('studio.errNumber', { label });
if (inp.integer && !Number.isInteger(n)) return t('studio.errInteger', { label });
if (inp.min != null && n < inp.min) return t('studio.errMin', { label, v: inp.min });
if (inp.max != null && n > inp.max) return t('studio.errMax', { label, v: inp.max });
return null;
}
/** Field errors keyed by nodeId (spec §6.2 wording); '' keys never block. */
export function validateForm(
schema: ComfyDynamicInput[],
ds: DeskSchema,
values: FormValues,
): Record<string, string> {
const errs: Record<string, string> = {};
if (ds.prompt?.required) {
const v = String(values[ds.prompt.nodeId] ?? '');
if (!v.trim()) errs[ds.prompt.nodeId] = t('studio.errPrompt');
else if (v.length > 2000) errs[ds.prompt.nodeId] = t('studio.errPromptLong');
}
const first = ds.slots.find((s) => s.firstFrame);
const last = ds.slots.find((s) => s.lastFrame);
if (first && last) {
const a = String(values[first.inp.nodeId] ?? '');
const b = String(values[last.inp.nodeId] ?? '');
// 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] = ' ';
}
}
for (const s of ds.slots) {
if (!s.inp.required) continue;
if (!String(values[s.inp.nodeId] ?? '').trim()) {
errs[s.inp.nodeId] = s.missing || t('studio.errUpload', { label: s.label });
}
}
for (const g of ds.lists) {
const n = listPaths(values, g).length;
const max = g.max ?? 9;
if (n < (g.min ?? 1)) {
errs[g.nodeId] =
g.kind === 'image_list'
? t('studio.errNeedRef')
: t('studio.errNeedRefMedia', { n: g.min ?? 1, label: g.label });
} else if (n > max) {
errs[g.nodeId] = t('studio.errRefMax', { max, n, k: n - max });
}
}
for (const inp of [...ds.common, ...ds.advanced]) {
if (inp.required && !String(values[inp.nodeId] ?? '').trim()) {
errs[inp.nodeId] = t('studio.errFill', { label: inp.label });
}
}
for (const inp of schema) {
const raw = values[inp.nodeId];
if (raw === undefined || Array.isArray(raw) || String(raw).trim() === '' || errs[inp.nodeId]) continue;
const msg = valueError(inp, raw);
if (msg) errs[inp.nodeId] = msg;
}
return errs;
}
/** Media a slot still waits for — the 「还缺:…」 hint next to 生成. */
export function missingMedia(ds: DeskSchema, values: FormValues): Array<{ nodeId: string; label: string }> {
const out: Array<{ nodeId: string; label: string }> = [];
for (const s of ds.slots) {
if (s.inp.required && !String(values[s.inp.nodeId] ?? '').trim()) out.push({ nodeId: s.inp.nodeId, label: s.label });
}
for (const g of ds.lists) {
if (listPaths(values, g).length < (g.min ?? 1)) out.push({ nodeId: g.nodeId, label: g.label });
}
return out;
}
export function usageLabel(u: WorkflowUsage): string {
return t(`usage.${u}`);
}
/** Mode badge: the stored usage when we have one, else the wiring shape. */
export function modeLabel(wf: Workflow | null, ds: DeskSchema, kind: DeskKind): string {
if (wf?.usage) return usageLabel(wf.usage);
if (ds.lists.some((g) => g.kind === 'image_list')) return t('studio.modeRefToVideo');
if (ds.slots.some((s) => s.firstFrame)) return t('studio.modeImageToVideo');
if (kind === 'image' && ds.slots.some((s) => s.mediaKind === 'image' && !s.mask))
return t('studio.modeImageEdit');
if (ds.slots.length) return ds.slots.map((s) => s.label).join(' · ');
return kind === 'image' ? t('studio.modeTextToImage') : t('studio.modeTextToVideo');
}
/** Picker row summary of a workflow's real media inputs, e.g. 参考图 1–9 张. */
export function slotSummary(wf: Workflow): string {
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
const parts = [
...ds.slots.map((s) => t('desk.slotOne', { label: s.label })),
...ds.lists.map((g) => t('desk.slotRange', { label: g.label, min: g.min ?? 1, max: g.max ?? 9 })),
];
return parts.length ? parts.join(' · ') : t('desk.promptOnly');
}
/** Short chip text for one common parameter value. */
export function chipValue(inp: ComfyDynamicInput, values: FormValues, ds: DeskSchema): string {
const raw = values[inp.nodeId] ?? inp.defaultValue ?? '';
const v = Array.isArray(raw) ? raw.join(',') : String(raw);
if (inp === ds.ratio) {
const size = outputSize(ds, values);
return size ? `${ratioShort(v)} · ${size.width}×${size.height}` : ratioShort(v);
}
if (inp === ds.duration) return v ? t('desk.chipSeconds', { v }) : t('studio.durationLabel');
if (inp === ds.seed) return t('desk.chipSeed', { v: v.length > 8 ? `${v.slice(0, 8)}…` : v || '—' });
if (inp === ds.resolution) return t('desk.chipResolution', { v: v || '—' });
return v || inp.label;
}
@@ -0,0 +1,120 @@
import { SvelteSet } from 'svelte/reactivity';
import type { QueryClient } from '@tanstack/svelte-query';
import { apiErrorMessage, playgroundApi } from '$lib/api';
import type { Job } from '$lib/comfy/types';
import { showToast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
/**
* 撤销删除 (spec-v2 §四.4 删除两级制 · HEL-654 D 批): deleting a single
* take/task never opens a dialog. The row leaves the view right away, a
* 5-second Toast offers 撤销, and the backend DELETE is only sent when
* that window closes without an undo — the backend removes result files,
* so an undo after the call would be impossible. Leaving the page (SPA
* route change or hard unload) flushes pending deletes instead of
* dropping them.
*/
export interface TakeDeleteHandlers {
/** Localized toast message for this take. */
message: (job: Job) => string;
/** Undo: the row comes back — reselect / replay its entrance here. */
onUndo?: (job: Job) => void;
/** Called after the delete really hit the backend. */
onCommitted?: (job: Job) => void;
}
export interface TakeDeleteQueue {
/** Ids whose rows should be hidden (commit pending). */
readonly pending: SvelteSet<number>;
request: (job: Job, handlers: TakeDeleteHandlers) => void;
destroy: () => void;
}
const UNDO_MS = 5000;
export function createTakeDeleteQueue(client: QueryClient): TakeDeleteQueue {
const pending = new SvelteSet<number>();
const entries = new Map<
number,
{
job: Job;
handlers: TakeDeleteHandlers;
timer: ReturnType<typeof setTimeout>;
}
>();
let destroyed = false;
async function invalidate() {
await Promise.allSettled([
client.invalidateQueries({ queryKey: ['playground-jobs'] }),
client.invalidateQueries({ queryKey: ['jobs'] }),
]);
}
async function commit(id: number) {
const entry = entries.get(id);
if (!entry) return;
entries.delete(id);
try {
await playgroundApi.deleteJob(id);
pending.delete(id);
entry.handlers.onCommitted?.(entry.job);
} catch (err) {
// Delete failed: the take stays — bring the row back honestly.
pending.delete(id);
showToast(
t('studio.deleteFailedToast', { detail: apiErrorMessage(err) }),
'error',
);
}
await invalidate();
}
/** Hard unload (tab close / external nav): fire-and-forget with keepalive. */
function onPageHide() {
for (const [id] of entries) {
void fetch(`/api/playground/jobs/${id}`, {
method: 'DELETE',
keepalive: true,
});
}
}
function destroy() {
if (destroyed) return;
destroyed = true;
if (inBrowser) window.removeEventListener('pagehide', onPageHide);
// SPA route change: the JS context survives, normal commits are fine.
for (const [id] of entries) {
const entry = entries.get(id)!;
clearTimeout(entry.timer);
void commit(id);
}
}
const inBrowser = typeof window !== 'undefined';
if (inBrowser) window.addEventListener('pagehide', onPageHide);
return {
pending,
request(job, handlers) {
if (destroyed || pending.has(job.id)) return;
pending.add(job.id);
const timer = setTimeout(() => void commit(job.id), UNDO_MS);
entries.set(job.id, { job, handlers, timer });
showToast(handlers.message(job), 'info', UNDO_MS, {
label: t('toast.undo'),
run: () => {
const e = entries.get(job.id);
if (!e) return;
clearTimeout(e.timer);
entries.delete(job.id);
pending.delete(job.id);
handlers.onUndo?.(job);
},
});
},
destroy,
};
}
@@ -0,0 +1,129 @@
import type { Job, Workflow } from '$lib/comfy/types';
import { parseDbTs } from '$lib/dbtime';
import { jobStatusInfo } from '$lib/jobStatus';
import { resolutionSelectorSize } from '$lib/comfy/resolution';
import { t } from '$lib/i18n.svelte';
import { classifySchema, inputRole, isFirstFrame, isLastFrame, isMedia } from './schema';
/** Read-only views of a finished / running take (one playground job). */
export type FeedFilter = 'all' | 'image' | 'video' | 'failed';
export function jobValues(job: Job): Record<string, unknown> {
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
}
export function jobValueByRole(job: Job, wf: Workflow | undefined, ...roles: string[]): string | null {
if (!wf) return null;
const vals = jobValues(job);
for (const inp of wf.input_schema ?? []) {
const r = inputRole(inp);
if (r && roles.includes(r)) {
const v = vals[inp.nodeId];
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v);
}
}
return null;
}
export function promptOf(job: Job, wf: Workflow | undefined): string {
return jobValueByRole(job, wf, 'prompt') ?? '';
}
export interface TakeInput {
label: string;
path: string;
kind: 'image' | 'video' | 'audio';
}
/** Media the take was generated from, in workflow order. */
export function inputsOf(job: Job, wf: Workflow | undefined): TakeInput[] {
if (!wf) return [];
const kind = wf.kind;
const vals = jobValues(job);
const out: TakeInput[] = [];
let refIdx = 0;
for (const inp of wf.input_schema ?? []) {
if (!isMedia(inp)) continue;
const v = vals[inp.nodeId];
const list = Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : [];
const mk = inp.kind.startsWith('video') ? 'video' : inp.kind.startsWith('audio') ? 'audio' : 'image';
list.forEach((p, i) => {
if (typeof p !== 'string' || !p) return;
let label = inp.label;
if (inp.kind === 'image_list') label = t('studio.slotRefN', { n: i + 1 });
else if (isFirstFrame(inp, kind)) label = t('studio.slotFirst');
else if (isLastFrame(inp, kind)) label = t('studio.slotLast');
else if (inp.kind === 'image' && inputRole(inp) !== 'mask') label = t('studio.slotRefN', { n: ++refIdx });
out.push({ label, path: p, kind: mk });
});
}
return out;
}
/** 888×1184 · 种子 42 · 5 秒 — values that differ per take. */
export function specOf(job: Job, wf: Workflow | undefined): Array<{ label: string; value: string }> {
const out: Array<{ label: string; value: string }> = [];
if (!wf) return out;
const w = jobValueByRole(job, wf, 'width');
const h = jobValueByRole(job, wf, 'height');
if (w && h) out.push({ label: t('desk.specSize'), value: `${w}×${h}` });
else {
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
if (ds.ratio?.resolution) {
const vals = jobValues(job);
const size = resolutionSelectorSize(
String(vals[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
Number(vals[ds.ratio.resolution.megapixels] ?? ds.megapixels?.defaultValue),
ds.ratio.resolution.multiple,
);
if (size) out.push({ label: t('desk.specSize'), value: `${size.width}×${size.height}` });
}
}
const seed = jobValueByRole(job, wf, 'seed');
if (seed) out.push({ label: t('studio.seedLabel'), value: seed });
const dur = jobValueByRole(job, wf, 'duration');
if (dur) out.push({ label: t('studio.durationLabel'), value: t('desk.chipSeconds', { v: dur }) });
return out;
}
export function isVideoOutput(job: Job, path: string): boolean {
return job.kind === 'video' && !/\.(png|jpe?g|webp)$/i.test(path);
}
export function elapsedSec(job: Job, nowMs: number): number | null {
const from = job.started_at ? parseDbTs(job.started_at) : parseDbTs(job.created_at);
if (!Number.isFinite(from)) return null;
const to = job.completed_at ? parseDbTs(job.completed_at) : nowMs;
return Math.max(0, (to - from) / 1000);
}
export function fmtDur(sec: number | null): string {
if (sec == null) return '—';
if (sec < 60) return t('desk.durSec', { s: Math.floor(sec) });
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return t('desk.durMin', { m, s: String(s).padStart(2, '0') });
}
export function fmtClock(ms: number): string {
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/** 今天 / 昨天 / 9月28日 heading for the feed. */
export function dayLabel(ms: number, nowMs: number): string {
const d = new Date(ms);
const today = new Date(nowMs);
const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const diff = Math.round((startOf(today) - startOf(d)) / 86_400_000);
if (diff === 0) return t('desk.today');
if (diff === 1) return t('desk.yesterday');
return t('desk.dayDate', { m: d.getMonth() + 1, d: d.getDate() });
}
export function matchesFilter(job: Job, filter: FeedFilter): boolean {
if (filter === 'all') return true;
if (filter === 'failed') return jobStatusInfo(job).tone === 'fail';
return job.kind === filter;
}
@@ -52,7 +52,12 @@
'var(--avatar-4)',
'var(--avatar-5)',
];
const placeholder = $derived(PLACEHOLDER_GRADIENTS[project.id % PLACEHOLDER_GRADIENTS.length]);
const placeholderIdx = $derived(project.id % PLACEHOLDER_GRADIENTS.length);
const placeholder = $derived(PLACEHOLDER_GRADIENTS[placeholderIdx]);
// HEL-680 灰阶封面占位:1–2 亮阶配深图标,3–5 暗阶配浅图标
const placeholderIconColor = $derived(
placeholderIdx <= 1 ? 'var(--accent-ink)' : 'var(--text)',
);
const coverUrl = $derived(assetUrl(project.cover_path));
@@ -174,7 +179,12 @@
onerror={() => (coverFailed = true)}
/>
{:else}
<span class="cover-ph" style:background={placeholder} aria-hidden="true">
<span
class="cover-ph"
style:background={placeholder}
style:color={placeholderIconColor}
aria-hidden="true"
>
<Icon name="film" size={22} />
</span>
{/if}
@@ -381,7 +391,7 @@
white-space: nowrap;
}
.arrow {
color: var(--accent);
color: var(--text-2);
font-size: 13px;
opacity: 0;
transform: translateX(-6px);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,218 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { projects } from '$lib/api';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import type { IconName } from '$lib/components/ui/icons';
import { NAV } from './nav';
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
let { open = $bindable(false) }: { open?: boolean } = $props();
interface Entry {
key: string;
group: 'pages' | 'projects';
label: string;
icon: IconName;
href: string;
}
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
let query = $state('');
let activeIndex = $state(0);
let list = $state<HTMLDivElement | null>(null);
const projectsQuery = createQuery(
toStore(() => ({
queryKey: ['projects'],
queryFn: projects.list,
enabled: open,
staleTime: 30_000,
})),
);
const entries = $derived.by<Entry[]>(() => {
const pages: Entry[] = NAV.map((item) => ({
key: `page-${item.id}`,
group: 'pages',
label: t(item.labelKey),
icon: item.icon,
href: item.href,
}));
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
key: `project-${p.id}`,
group: 'projects',
label: p.title,
icon: 'story',
href: `/workbench/${p.id}`,
}));
const q = query.trim().toLowerCase();
const all = [...pages, ...projectEntries];
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
});
$effect(() => {
void query;
activeIndex = 0;
});
$effect(() => {
if (!open) query = '';
});
function optionId(index: number) {
return `${listId}-${index}`;
}
function go(entry: Entry) {
open = false;
void goto(entry.href);
}
function move(step: number) {
if (entries.length === 0) return;
activeIndex = (activeIndex + step + entries.length) % entries.length;
list?.querySelector(`#${optionId(activeIndex)}`)?.scrollIntoView({ block: 'nearest' });
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
move(1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
move(-1);
} else if (event.key === 'Enter') {
const entry = entries[activeIndex];
if (entry) {
event.preventDefault();
go(entry);
}
}
}
function focusOnMount(node: HTMLInputElement) {
requestAnimationFrame(() => node.focus());
}
</script>
<Modal bind:open title={t('shell.searchTitle')}>
<div class="palette">
<div class="palette-search">
<Icon name="search" size={16} />
<input
use:focusOnMount
class="palette-input"
type="text"
bind:value={query}
placeholder={t('shell.searchPlaceholder')}
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={entries.length ? optionId(activeIndex) : undefined}
aria-label={t('shell.searchTitle')}
onkeydown={handleKeydown}
/>
</div>
<div class="palette-list" id={listId} role="listbox" bind:this={list} aria-label={t('shell.searchTitle')}>
{#if entries.length === 0}
<p class="palette-empty">{t('shell.searchEmpty', { q: query.trim() })}</p>
{/if}
{#each entries as entry, i (entry.key)}
{#if i === 0 || entries[i - 1].group !== entry.group}
<div class="a-menu-group" role="presentation">
{entry.group === 'pages' ? t('shell.searchGroupPages') : t('shell.searchGroupProjects')}
</div>
{/if}
<div
id={optionId(i)}
class="a-menu-item"
class:active={i === activeIndex}
role="option"
tabindex="-1"
aria-selected={i === activeIndex}
onpointermove={() => (activeIndex = i)}
onmousedown={(e) => e.preventDefault()}
onclick={() => go(entry)}
onkeydown={handleKeydown}
>
<Icon name={entry.icon} size={15} />
<span class="a-menu-label">{entry.label}</span>
<span class="a-menu-meta mono">{entry.href}</span>
</div>
{/each}
</div>
<p class="palette-hint">
<span class="a-kbd">↑↓</span>{t('shell.searchHintMove')}
<span class="a-kbd">Enter</span>{t('shell.searchHintOpen')}
<span class="a-kbd">Esc</span>{t('shell.searchHintClose')}
</p>
</div>
</Modal>
<style>
.palette {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.palette-search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 0 var(--sp-5);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
transition: border-color var(--d-state) ease;
}
.palette-search:focus-within {
border-color: var(--accent-line);
}
.palette-input {
flex: 1;
min-width: 0;
height: 40px;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: var(--fs-section);
}
.palette-input::placeholder {
color: var(--text-3);
}
.palette-list {
max-height: min(360px, 50vh);
overflow-y: auto;
}
.palette-empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
text-align: center;
}
.palette-hint {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
font-size: var(--fs-mini);
color: var(--text-3);
}
.palette-hint .a-kbd:not(:first-child) {
margin-left: var(--sp-3);
}
@media (hover: none) {
.palette-hint {
display: none;
}
}
</style>
@@ -0,0 +1,127 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { projects } from '$lib/api';
import { parseDbTs } from '$lib/dbtime';
import { t } from '$lib/i18n.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import ShellPop from './ShellPop.svelte';
/**
* 顶栏范围胶囊 — the project picker that replaced the old sidebar project
* entry: shows the project the page belongs to (or 全部项目) and lists
* recent projects, each opening its five-stage workbench.
*/
interface Props {
projectId: number | null;
onProjectsList: boolean;
}
let { projectId, onProjectsList }: Props = $props();
const RECENT_LIMIT = 8;
let open = $state(false);
const projectsQuery = createQuery(
toStore(() => ({
queryKey: ['projects'],
queryFn: projects.list,
enabled: open || projectId != null,
staleTime: 30_000,
})),
);
const all = $derived($projectsQuery.data ?? []);
const current = $derived(projectId == null ? null : (all.find((p) => p.id === projectId) ?? null));
const recent = $derived(
[...all]
.sort((a, b) => (parseDbTs(b.updated_at) || 0) - (parseDbTs(a.updated_at) || 0))
.slice(0, RECENT_LIMIT),
);
</script>
<div class="scope">
<button
type="button"
class="status-chip scope-btn"
aria-haspopup="dialog"
aria-expanded={open}
title={t('shell.scopeHint')}
onclick={() => (open = !open)}
>
<span class="scope-label">{current?.title ?? t('shell.scopeAll')}</span>
<Icon name="chevron-down" size={12} />
</button>
<ShellPop bind:open label={t('shell.scopeHint')} width="300px">
<a
class="a-menu-item"
href="/"
aria-current={onProjectsList ? 'page' : undefined}
onclick={() => (open = false)}
>
<Icon name="folder" size={15} />
<span class="a-menu-label">{t('shell.scopeAllList')}</span>
{#if all.length > 0}<span class="a-menu-meta mono">{all.length}</span>{/if}
</a>
<div class="a-menu-group">{t('shell.scopeRecent')}</div>
{#if $projectsQuery.isPending}
<p class="scope-note">{t('common.loading')}…</p>
{:else if $projectsQuery.isError}
<p class="scope-note err">{t('shell.scopeLoadFailed')}</p>
{:else if recent.length === 0}
<a class="a-menu-item" href="/" onclick={() => (open = false)}>
<Icon name="plus" size={15} />
<span class="a-menu-label">{t('shell.scopeEmpty')}</span>
</a>
{:else}
{#each recent as project (project.id)}
<a
class="a-menu-item"
href="/workbench/{project.id}"
aria-current={project.id === projectId ? 'true' : undefined}
onclick={() => (open = false)}
>
<Icon name="story" size={15} />
<span class="a-menu-label">{project.title}</span>
{#if project.stats}
<span class="a-menu-meta">
{t('shell.scopeStage', { n: project.stats.stage_confirmed })}
</span>
{/if}
</a>
{/each}
{/if}
</ShellPop>
</div>
<style>
.scope {
position: relative;
display: flex;
flex-shrink: 0;
}
.scope-btn {
max-width: 220px;
gap: var(--sp-1);
}
.scope-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.scope-note {
margin: 0;
padding: var(--sp-3) var(--sp-4);
font-size: var(--fs-aux);
color: var(--text-3);
}
.scope-note.err {
color: var(--err);
}
@media (max-width: 760px) {
.scope-btn {
max-width: 132px;
}
}
</style>
@@ -0,0 +1,164 @@
<script lang="ts">
import { createQuery } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { workflows, type Job } from '$lib/api';
import { parseDbTs } from '$lib/dbtime';
import { t } from '$lib/i18n.svelte';
import { jobSourceLabel } from '$lib/jobLabels';
import { jobStatusInfo, type JobStatusTone } from '$lib/jobStatus';
import ShellPop from './ShellPop.svelte';
/**
* 顶栏队列胶囊 + 任务托盘 (§二). The dot is cyan only while something is
* actually generating — queued work stays gray, as on the task page.
*/
let { jobs }: { jobs: Job[] } = $props();
const TRAY_LIMIT = 6;
const TAG_CLASS: Record<JobStatusTone, string> = { ok: 'ok', run: 'run', queue: '', fail: 'err' };
let open = $state(false);
let capsule = $state<HTMLButtonElement | null>(null);
const workflowsQuery = createQuery(
toStore(() => ({
queryKey: ['workflows'],
queryFn: workflows.list,
enabled: open,
staleTime: 60_000,
})),
);
const active = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running'));
const running = $derived(active.filter((j) => jobStatusInfo(j).tone === 'run').length);
const queued = $derived(active.length - running);
const recent = $derived([...jobs].sort((a, b) => b.id - a.id).slice(0, TRAY_LIMIT));
const label = $derived.by(() => {
const parts: string[] = [];
if (running > 0) parts.push(t('shell.queueRunning', { n: running }));
if (queued > 0) parts.push(t('shell.queueQueued', { n: queued }));
return parts.length ? parts.join(' · ') : t('shell.queueIdle');
});
// 任务数变化时胶囊轻弹一次(320ms,只播一次).
let lastCount: number | null = null;
$effect(() => {
const count = active.length;
if (lastCount !== null && count !== lastCount && capsule) {
capsule.classList.remove('bump');
void capsule.offsetWidth;
capsule.classList.add('bump');
}
lastCount = count;
});
function workflowName(job: Job): string {
return (
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)?.name ??
t('studio.jobName', { id: job.id })
);
}
function timeOf(job: Job): string {
const ts = parseDbTs(job.created_at);
if (!Number.isFinite(ts)) return '—';
const d = new Date(ts);
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
</script>
<div class="tray">
<button
bind:this={capsule}
type="button"
class="status-chip queue-btn"
aria-haspopup="dialog"
aria-expanded={open}
aria-label={label}
title={label}
onclick={() => (open = !open)}
onanimationend={() => capsule?.classList.remove('bump')}
>
<span class="status-chip-dot {running > 0 ? 'run' : 'idle'}" aria-hidden="true"></span>
<span class="queue-label">{label}</span>
<span class="queue-count mono">{active.length}</span>
</button>
<ShellPop bind:open label={t('shell.trayTitle')} align="end" width="340px">
<div class="a-menu-group">{t('shell.trayRecent')}</div>
{#if recent.length === 0}
<p class="tray-note">{t('shell.trayEmpty')}</p>
{:else}
{#each recent as job (job.id)}
{@const info = jobStatusInfo(job)}
<a class="a-menu-item tray-row" href="/tasks" title={info.detail} onclick={() => (open = false)}>
<span class="tray-copy">
<span class="a-menu-label">{workflowName(job)}</span>
<span class="tray-meta">{jobSourceLabel(job)} · <span class="mono">{timeOf(job)}</span></span>
</span>
<span class="a-tag {TAG_CLASS[info.tone]}">{info.label}</span>
</a>
{/each}
{/if}
<a class="a-menu-item tray-all" href="/tasks" onclick={() => (open = false)}>
<span class="a-menu-label">{t('shell.trayAll')}</span>
</a>
</ShellPop>
</div>
<style>
.tray {
position: relative;
flex-shrink: 0;
}
.queue-btn:global(.bump) {
animation: queue-bump 320ms var(--ease-out);
}
@keyframes queue-bump {
40% {
transform: scale(1.06);
}
}
.queue-count {
display: none;
}
@media (max-width: 1179px) {
.queue-label {
display: none;
}
.queue-count {
display: inline;
}
}
.tray-row {
align-items: center;
}
.tray-copy {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
line-height: 1.4;
}
.tray-meta {
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tray-note {
margin: 0;
padding: var(--sp-3) var(--sp-4) var(--sp-5);
font-size: var(--fs-aux);
color: var(--text-3);
}
.tray-all {
margin-top: var(--sp-1);
border-top: 1px solid var(--line);
border-radius: 0 0 var(--r-md) var(--r-md);
justify-content: center;
}
</style>
@@ -0,0 +1,199 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cubicOut, quintOut } from 'svelte/easing';
/**
* Anchored pop for the shell (§四.3 pop): opens 180ms from its trigger,
* closes on Esc / outside press, Tab cycles inside, ↑↓ move between rows.
* At ≤760px it becomes a bottom sheet. Render it next to its trigger
* inside a `position: relative` wrapper — that wrapper counts as inside.
*/
interface Props {
open?: boolean;
label: string;
align?: 'start' | 'end';
/** `top` opens above the trigger (triggers near the bottom edge, e.g. the composer). */
placement?: 'bottom' | 'top';
width?: string;
/** Set false when the content runs its own ↑↓ roving selection. */
arrowRoving?: boolean;
onclose?: () => void;
children: Snippet;
}
let {
open = $bindable(false),
label,
align = 'start',
placement = 'bottom',
width = '300px',
arrowRoving = true,
onclose,
children,
}: Props = $props();
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const SHEET_QUERY = '(max-width: 760px)';
let panel = $state<HTMLDivElement | null>(null);
let scrim = $state<HTMLDivElement | null>(null);
function focusables(): HTMLElement[] {
if (!panel) return [];
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.getClientRects().length > 0,
);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
open = false;
return;
}
const list = focusables();
if (list.length === 0) return;
const current = document.activeElement as HTMLElement | null;
const index = current ? list.indexOf(current) : -1;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (!arrowRoving || current?.tagName === 'SELECT') return;
event.preventDefault();
const step = event.key === 'ArrowDown' ? 1 : -1;
const next = index < 0 ? (step > 0 ? 0 : list.length - 1) : index + step;
list[(next + list.length) % list.length].focus();
} else if (event.key === 'Tab') {
if (event.shiftKey && index <= 0) {
event.preventDefault();
list[list.length - 1].focus();
} else if (!event.shiftKey && (index < 0 || index === list.length - 1)) {
event.preventDefault();
list[0].focus();
}
}
}
function handlePointerDown(event: PointerEvent) {
const host = panel?.parentElement;
const target = event.target as Node;
if (!host) return;
if (!host.contains(target) || target === scrim) open = false;
}
$effect(() => {
if (!open) return;
const invoker = document.activeElement as HTMLElement | null;
document.addEventListener('keydown', handleKeydown, true);
document.addEventListener('pointerdown', handlePointerDown, true);
requestAnimationFrame(() => focusables()[0]?.focus());
return () => {
document.removeEventListener('keydown', handleKeydown, true);
document.removeEventListener('pointerdown', handlePointerDown, true);
const focusWasInside =
document.activeElement === document.body || !!panel?.contains(document.activeElement);
if (focusWasInside && invoker?.isConnected) invoker.focus();
onclose?.();
};
});
function cssMs(name: string, fallback: number): number {
const raw = getComputedStyle(document.documentElement).getPropertyValue(name);
const ms = parseFloat(raw);
return Number.isFinite(ms) ? ms : fallback;
}
function popIn(_node: Element) {
const reduced = document.documentElement.dataset.reduce === '1';
const sheet = window.matchMedia(SHEET_QUERY).matches;
if (reduced) {
return { duration: cssMs('--d-state', 120), css: (t: number) => `opacity: ${t}` };
}
if (sheet) {
return {
duration: cssMs('--d-drawer', 280),
easing: quintOut,
css: (t: number) => `transform: translateY(${(1 - t) * 100}%)`,
};
}
return {
duration: cssMs('--d-pop', 180),
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: scale(${0.96 + 0.04 * t})`,
};
}
function fadeScrim(_node: Element) {
return { duration: cssMs('--d-pop', 180), css: (t: number) => `opacity: ${t}` };
}
</script>
{#if open}
<div class="pop-scrim" bind:this={scrim} aria-hidden="true" transition:fadeScrim></div>
<div
bind:this={panel}
class="shell-pop a-float align-{align} place-{placement}"
style:--pop-w={width}
role="dialog"
aria-label={label}
transition:popIn
>
{@render children()}
</div>
{/if}
<style>
.shell-pop {
position: absolute;
top: calc(100% + var(--sp-2));
z-index: 60;
width: var(--pop-w);
max-width: calc(100vw - 2 * var(--sp-5));
max-height: min(520px, 70vh);
overflow-y: auto;
padding: var(--sp-2);
box-sizing: border-box;
transform-origin: top left;
}
.shell-pop.align-start {
left: 0;
}
.shell-pop.align-end {
right: 0;
transform-origin: top right;
}
.shell-pop.place-top {
top: auto;
bottom: calc(100% + var(--sp-2));
transform-origin: bottom left;
}
.shell-pop.place-top.align-end {
transform-origin: bottom right;
}
.pop-scrim {
display: none;
}
@media (max-width: 760px) {
.pop-scrim {
display: block;
position: fixed;
inset: 0;
z-index: 59;
background: var(--overlay);
}
.shell-pop,
.shell-pop.place-top {
position: fixed;
top: auto;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
max-width: none;
max-height: 78vh;
border-radius: var(--r-lg) var(--r-lg) 0 0;
padding: var(--sp-3) var(--sp-3) calc(var(--sp-3) + env(safe-area-inset-bottom));
}
}
</style>
@@ -0,0 +1,72 @@
import type { IconName } from '$lib/components/ui/icons';
export type NavId =
| 'projects'
| 'workbench'
| 'image'
| 'video'
| 'tasks'
| 'agents'
| 'connections'
| 'models'
| 'library'
| 'settings';
export interface NavItem {
id: NavId;
labelKey: string;
icon: IconName;
href: string;
}
// Order is fixed by spec-v2 §3: 项目 / 创作工作台 / 生图 / 生视频 / 生成任务 /
// 智能体 / ComfyUI 连接 / 模型设置 / 设置. HEL-649 §二 puts the connection
// and settings entries at the foot of the 60px rail.
export const NAV_MAIN: NavItem[] = [
{ id: 'projects', labelKey: 'shell.navProjects', icon: 'home', href: '/' },
{ id: 'workbench', labelKey: 'shell.navWorkbench', icon: 'story', href: '/workbench' },
{ id: 'image', labelKey: 'shell.navImage', icon: 'image', href: '/image' },
{ id: 'video', labelKey: 'shell.navVideo', icon: 'film', href: '/video' },
{ id: 'tasks', labelKey: 'shell.navTasks', icon: 'queue', href: '/tasks' },
{ id: 'agents', labelKey: 'shell.navAgents', icon: 'sparkle', href: '/agents' },
];
export const NAV_FOOT: NavItem[] = [
{ id: 'connections', labelKey: 'shell.navConnections', icon: 'link', href: '/connections' },
{ id: 'models', labelKey: 'shell.navModels', icon: 'assets', href: '/models' },
{ id: 'library', labelKey: 'shell.navLibrary', icon: 'folder', href: '/library' },
{ id: 'settings', labelKey: 'shell.navSettings', icon: 'settings', href: '/settings' },
];
export const NAV: NavItem[] = [...NAV_MAIN, ...NAV_FOOT];
export interface TabItem {
id: 'projects' | 'workbench' | 'generate';
labelKey: string;
icon: IconName;
href: string;
/** Pages that light this tab up. */
covers: NavId[];
}
/** ≤760px bottom tab bar (§二): 项目 / 创作 / 生成 / 更多. */
export const TABS: TabItem[] = [
{ id: 'projects', labelKey: 'shell.tabProjects', icon: 'home', href: '/', covers: ['projects'] },
{ id: 'workbench', labelKey: 'shell.tabCreate', icon: 'story', href: '/workbench', covers: ['workbench'] },
{
id: 'generate',
labelKey: 'shell.tabGenerate',
icon: 'image',
href: '/image',
covers: ['image', 'video'],
},
];
/**
* Every page a tab does not link to directly lives in the 更多 sheet. 生视频
* is still its own route, so it is listed there too until the two
* generation pages merge.
*/
export const MORE_ITEMS: NavItem[] = NAV.filter(
(item) => !TABS.some((tab) => tab.href === item.href),
);
@@ -1,37 +1,33 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import { assetUrl } from '$lib/api';
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
import { t } from '$lib/i18n.svelte';
/**
* A-style media upload slot (spec-v2 §5 上传槽, ref-upload-spec §2):
* empty = 1.5px dashed border + "click or drop" + format line, drag-hover
* = amber dashed, filled = solid border + thumb + name + size + 替换 + ✕,
* error = red border + red reason.
* Single media input slot as a composer tile (spec-v2 §3.3 输入槽):
* empty = dashed + "+", drag-hover = gold, uploading = ring, filled = thumb
* (click to replace) with a ✕ on hover/focus. `need` = required but empty
* after a blocked 生成 (amber); `error` = rejected file / invalid (red).
*/
interface Props {
label: string;
/** Hint on the right of the label, e.g. 必填 · 1 张 / 可从生图结果送入. */
/** Hint after the label, e.g. 必填 · 1 张. */
hint?: string;
required?: boolean;
/** Current value: a server-side path ('' = empty). */
value: string;
kind: 'image' | 'video' | 'audio';
/** Thumb aspect: wide = 16:9 (first/last frame slots). */
aspect?: 'square' | 'wide';
/** Empty-state main line (defaults to 点击或拖入). */
emptyText?: string;
/** Empty-state mono line, e.g. accepted formats and size limit. */
emptySub?: string;
/** Byte size of the current file when known. */
size?: number;
uploading?: boolean;
uploadName?: string;
need?: boolean;
error?: string;
/** Show the "pick from image results" link (video-page image slots). */
/** Show the 从结果选 action (image slots fed from finished takes). */
allowFromResults?: boolean;
/** Anchor id so 「还缺:…」 can jump focus here. */
anchorId?: string;
onFile: (file: File) => void;
onFromResults?: () => void;
onClear: () => void;
@@ -43,14 +39,13 @@
required = false,
value,
kind,
aspect = 'square',
emptyText,
emptySub,
size,
uploading = false,
uploadName,
need = false,
error,
allowFromResults = false,
anchorId,
onFile,
onFromResults,
onClear,
@@ -66,7 +61,10 @@
dims = '';
});
const meta = $derived([dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '));
const filled = $derived(!!value && !broken);
const meta = $derived(
[uploadBaseName(value), dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '),
);
function accept(): string {
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
@@ -87,17 +85,34 @@
function onDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragDepth = 0;
if (uploading) return;
const file = e.dataTransfer?.files?.[0];
if (file) onFile(file);
}
const dragHandlers = {
ondragenter: (e: DragEvent) => {
e.preventDefault();
dragDepth += 1;
},
ondragleave: () => (dragDepth = Math.max(0, dragDepth - 1)),
ondragover: (e: DragEvent) => e.preventDefault(),
ondrop: onDrop,
};
</script>
<div class="media-slot" class:wide={aspect === 'wide'}>
<div class="slot-label">
<span class="lbl">{label}{#if required}<em>*</em>{/if}</span>
{#if hint}<span class="slot-hint">{hint}</span>{/if}
<div class="a-slot">
<div class="a-slot-label">
<span>{label}</span>
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
{#if hint}<span>{hint}</span>{/if}
{#if allowFromResults && onFromResults}
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
</button>
{/if}
</div>
<input
@@ -113,110 +128,61 @@
}}
/>
{#if value && !broken}
<div class="slot-filled" class:invalid={!!error}>
<div class="slot-thumb">
{#if uploading}
<Spinner size="sm" />
{:else if kind === 'image'}
<img src={assetUrl(value)} alt={label} onload={noteDims} onerror={() => (broken = true)} />
{:else if kind === 'video'}
<video src={assetUrl(value)} preload="metadata" muted></video>
<span class="kind-badge" aria-hidden="true"><Icon name="film" size={12} /></span>
{:else}
<span class="kind-badge" aria-hidden="true"><Icon name="music" size={12} /></span>
{/if}
</div>
<div class="slot-meta">
<span class="name" title={uploading ? uploadName : uploadBaseName(value)}>
{uploading ? uploadName : uploadBaseName(value)}
</span>
{#if meta && !uploading}<span class="mono dims">{meta}</span>{/if}
</div>
<div class="slot-actions">
{#if allowFromResults && onFromResults}
<button type="button" class="mini-link" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
</button>
{/if}
<button type="button" class="mini-link" disabled={uploading} onclick={pick}>
{t('studio.refReplace')}
</button>
<div class="a-slot-row">
{#if filled}
<div class="a-tile" class:drag={dragDepth > 0} class:bad={!!error} {...dragHandlers}>
<button
type="button"
class="slot-clear"
aria-label={t('studio.deleteBtn')}
id={anchorId}
class="a-tile-hit"
title={`${t('studio.refReplace')} · ${meta}`}
aria-label={`${t('studio.refReplace')} ${label}`}
disabled={uploading}
onclick={onClear}
onclick={pick}
>
<Icon name="close" size={12} />
{#if kind === 'image'}
<img src={assetUrl(value)} alt="" onload={noteDims} onerror={() => (broken = true)} />
{:else if kind === 'video'}
<video src={assetUrl(value)} preload="metadata" muted playsinline></video>
{:else}
<Icon name="music" size={18} />
{/if}
</button>
{#if uploading}
<span class="a-tile-ring" title={uploadName}><i></i></span>
{:else}
<button type="button" class="a-tile-x" aria-label={`${t('studio.deleteBtn')} ${label}`} onclick={onClear}>
<Icon name="close" size={10} />
</button>
{/if}
</div>
</div>
{:else}
<button
type="button"
class="slot-empty"
class:drag={dragDepth > 0}
class:invalid={!!error}
class:wide={aspect === 'wide'}
disabled={uploading}
aria-busy={uploading || undefined}
onclick={pick}
ondragenter={(e) => {
e.preventDefault();
dragDepth += 1;
}}
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
ondragover={(e) => e.preventDefault()}
ondrop={onDrop}
>
{#if uploading}
<Spinner size="sm" />
<span class="pick-text">{uploadName ?? t('studio.slotPick')}</span>
{:else}
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'image'} size={16} />
<span class="pick-text">{emptyText ?? t('studio.slotPick')}</span>
{#if emptySub}<span class="pick-sub mono">{emptySub}</span>{/if}
{/if}
</button>
{#if allowFromResults && onFromResults}
<button type="button" class="from-results-link" onclick={onFromResults}>
{t('studio.slotFromResults')}
{:else}
<button
type="button"
id={anchorId}
class="a-tile add"
class:drag={dragDepth > 0}
class:need={need && !error}
class:bad={!!error}
title={t('studio.slotPick')}
aria-label={`${label} · ${t('studio.slotPick')}`}
aria-busy={uploading || undefined}
disabled={uploading}
onclick={pick}
{...dragHandlers}
>
{#if uploading}
<span class="a-tile-ring" title={uploadName}><i></i></span>
{:else}
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'plus'} size={18} />
{/if}
</button>
{/if}
{/if}
{#if error}<span class="slot-error" role="alert">{error}</span>{/if}
</div>
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span>{/if}
</div>
<style>
.media-slot {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.slot-label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lbl {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
}
.lbl em {
color: var(--error);
font-style: normal;
margin-left: 2px;
}
.slot-hint {
font-size: 10.5px;
color: var(--text-3);
white-space: nowrap;
}
.sr-file {
position: absolute;
width: 1px;
@@ -226,164 +192,4 @@
white-space: nowrap;
border: 0;
}
.slot-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 86px;
border: 1.5px dashed var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
color: var(--text-3);
font-family: inherit;
font-size: 11.5px;
cursor: pointer;
transition:
border-color 150ms,
color 150ms;
}
.media-slot.wide .slot-empty {
aspect-ratio: 16 / 9;
min-height: 0;
width: 100%;
}
.slot-empty:hover:not(:disabled) {
color: var(--text-2);
border-color: rgba(255, 255, 255, 0.28);
}
.slot-empty.drag {
border-color: var(--accent);
color: var(--accent);
}
.slot-empty.invalid {
border-color: var(--err-line);
}
.slot-empty:disabled {
cursor: default;
opacity: 0.7;
}
.pick-text {
font-size: 12.5px;
color: var(--text-2);
}
.pick-sub {
font-size: 11px;
color: var(--text-3);
}
.from-results-link {
align-self: flex-end;
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11px;
color: var(--accent);
cursor: pointer;
}
.from-results-link:hover {
text-decoration: underline;
}
.slot-filled {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
padding: 8px;
}
.slot-filled.invalid {
border-color: var(--error);
}
.slot-thumb {
width: 56px;
height: 56px;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
background: var(--bg);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.media-slot.wide .slot-thumb {
width: 88px;
height: 50px;
}
.slot-thumb img,
.slot-thumb video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.slot-thumb .kind-badge {
color: var(--text-3);
}
.slot-meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.name {
font-size: 12.5px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dims {
font-size: 10.5px;
color: var(--text-3);
white-space: nowrap;
}
.slot-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.mini-link {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11.5px;
color: var(--text-2);
text-decoration: underline;
cursor: pointer;
}
.mini-link:hover:not(:disabled) {
color: var(--accent);
}
.mini-link:disabled {
opacity: 0.5;
cursor: default;
}
.slot-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 6px;
background: var(--surface-3);
color: var(--text-3);
cursor: pointer;
}
.slot-clear:hover {
color: var(--error);
background: var(--error-dim);
}
.slot-error {
font-size: 11.5px;
color: var(--error);
}
</style>
@@ -1,6 +1,5 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import Spinner from '$lib/components/ui/Spinner.svelte';
import { assetUrl } from '$lib/api';
import {
IMAGE_SLOT_ACCEPT,
@@ -12,11 +11,10 @@
import { t } from '$lib/i18n.svelte';
/**
* Reference-media grid (ref-upload-spec §2 `.refgrid`, generalized to
* image/video/audio in HEL-643): numbered cells 参考图/参考视频/参考音频
* 1…N in the order they reach the workflow, each with replace and ✕; a
* dashed add cell while below `max`; rejected files show as error cells
* that take no place in the list.
* Reference-media list as numbered composer tiles (spec-v2 §3.3 参考图):
* 1…N in the order they reach the workflow — click a tile to replace it,
* ✕ to remove; a dashed add tile while below `max`; rejected files show
* as red tiles that take no place in the list.
*/
interface Reject {
name: string;
@@ -35,8 +33,12 @@
/** Index being replaced while uploading (-1 = adding). */
uploadingIndex?: number;
rejects?: Reject[];
/** Below the minimum after a blocked 生成 (amber add tile). */
need?: boolean;
error?: string;
allowFromResults?: boolean;
/** Anchor id on the add tile so 「还缺:…」 can jump focus here. */
anchorId?: string;
onAdd: (files: File[]) => void;
onReplace: (index: number, file: File) => void;
onRemove: (index: number) => void;
@@ -53,8 +55,10 @@
uploadingName,
uploadingIndex = -1,
rejects = [],
need = false,
error,
allowFromResults = false,
anchorId,
onAdd,
onReplace,
onRemove,
@@ -78,16 +82,27 @@
? t('studio.refHintMedia', { n: paths.length, max })
: t('studio.refHint', { n: paths.length, max }),
);
function durationOf(path: string): string {
const d = durations[path];
if (d == null || !Number.isFinite(d)) return '';
return `${d.toFixed(1)}s`;
}
function noteDuration(path: string, e: Event): void {
const el = e.currentTarget as HTMLVideoElement | HTMLAudioElement | null;
if (el && Number.isFinite(el.duration)) {
durations = { ...durations, [path]: el.duration };
}
if (el && Number.isFinite(el.duration)) durations = { ...durations, [path]: el.duration };
}
function noteDims(path: string, e: Event) {
const img = e.currentTarget as HTMLImageElement;
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
}
function metaOf(path: string): string {
const d = durations[path];
return [
uploadBaseName(path),
dims[path],
d != null && Number.isFinite(d) ? `${d.toFixed(1)}s` : null,
sizes[path] != null ? formatBytes(sizes[path]) : null,
]
.filter(Boolean)
.join(' · ');
}
function pickAdd() {
@@ -105,28 +120,24 @@
function onDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragDepth = 0;
if (uploading) return;
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) onAdd(files);
}
function noteDims(path: string, e: Event) {
const img = e.currentTarget as HTMLImageElement;
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
}
function metaOf(path: string): string {
return [dims[path], durationOf(path), sizes[path] != null ? formatBytes(sizes[path]) : null]
.filter(Boolean)
.join(' · ');
}
</script>
<div class="refgrid-field">
<div class="slot-label">
<span class="lbl">{label}<em>*</em></span>
<span class="slot-hint">{hintLabel}</span>
<div class="a-slot">
<div class="a-slot-label">
<span>{label}</span>
<span class="req" aria-hidden="true">*</span>
<span class="mono">{hintLabel}</span>
{#if allowFromResults && onFromResults && remaining > 0}
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
{t('studio.slotFromResultsShort')}
</button>
{/if}
</div>
<input
@@ -135,7 +146,7 @@
class="sr-file"
tabindex="-1"
aria-hidden="true"
accept={accept}
{accept}
multiple
onchange={(e) => {
const files = Array.from(e.currentTarget.files ?? []);
@@ -148,28 +159,26 @@
class="sr-file"
tabindex="-1"
aria-hidden="true"
accept={accept}
{accept}
onchange={(e) => {
const f = e.currentTarget.files?.[0];
if (f && replaceIndex >= 0) onReplace(replaceIndex, f);
}}
/>
<div class="refgrid" class:audio={mediaKind === 'audio'}>
<div class="a-slot-row">
{#each paths as path, i (path + i)}
<div class="ref-cell" class:busy={uploadingIndex === i}>
<div class="a-tile">
<button
type="button"
class="ref-thumb"
title={t('studio.refReplace')}
class="a-tile-hit"
title={`${t('studio.refReplace')} · ${metaOf(path)}`}
aria-label={`${t('studio.refReplace')} ${label} ${i + 1}`}
disabled={uploading}
onclick={() => pickReplace(i)}
>
{#if mediaKind === 'image'}
<img
src={assetUrl(path)}
alt={t('studio.slotRefN', { n: i + 1 })}
onload={(e) => noteDims(path, e)}
/>
<img src={assetUrl(path)} alt="" onload={(e) => noteDims(path, e)} />
{:else if mediaKind === 'video'}
<video
src={assetUrl(path)}
@@ -177,64 +186,59 @@
muted
playsinline
onloadedmetadata={(e) => noteDuration(path, e)}
title={uploadBaseName(path)}
></video>
{:else}
<span class="audio-glyph"><Icon name="music" size={18} /></span>
<audio
src={assetUrl(path)}
preload="metadata"
onloadedmetadata={(e) => noteDuration(path, e)}
title={uploadBaseName(path)}
<Icon name="music" size={18} />
<audio src={assetUrl(path)} preload="metadata" onloadedmetadata={(e) => noteDuration(path, e)}
></audio>
{/if}
<span class="ref-no mono">{i + 1}</span>
{#if uploadingIndex === i}
<span class="ref-busy"><Spinner size="sm" /></span>
{/if}
</button>
<button
type="button"
class="ref-x"
aria-label={isMedia ? t('studio.refRemoveMedia', { kind: label, n: i + 1 }) : t('studio.refRemove', { n: i + 1 })}
onclick={() => onRemove(i)}
>
<Icon name="close" size={11} />
</button>
<span class="ref-name" title={uploadBaseName(path)}>{uploadBaseName(path)}</span>
<span class="ref-meta mono">
{metaOf(path)}
<button type="button" class="mini-link" onclick={() => pickReplace(i)}>{t('studio.refReplace')}</button>
</span>
<span class="a-tile-idx">{i + 1}</span>
{#if uploadingIndex === i}
<span class="a-tile-ring" title={uploadingName}><i></i></span>
{:else}
<button
type="button"
class="a-tile-x"
aria-label={isMedia
? t('studio.refRemoveMedia', { kind: label, n: i + 1 })
: t('studio.refRemove', { n: i + 1 })}
onclick={() => onRemove(i)}
>
<Icon name="close" size={10} />
</button>
{/if}
</div>
{/each}
{#each rejects as rej, i (rej.name + i)}
<div class="ref-cell rejected" role="alert">
<div class="ref-thumb error">
<Icon name="close" size={16} />
<span class="mono">{t('studio.refRejectedShort')}</span>
</div>
<div class="a-tile bad" title={`${rej.name} · ${rej.message}`} role="img" aria-label={rej.name}>
<Icon name="alert" size={16} />
<button
type="button"
class="ref-x"
aria-label={t('studio.deleteBtn')}
class="a-tile-x"
aria-label={`${t('studio.deleteBtn')} ${rej.name}`}
onclick={() => onDismissReject(i)}
>
<Icon name="close" size={11} />
<Icon name="close" size={10} />
</button>
<span class="ref-name err" title={rej.name}>{rej.name}</span>
</div>
{/each}
{#if remaining > 0}
<button
type="button"
class="ref-add"
id={anchorId}
class="a-tile add"
class:drag={dragDepth > 0}
class:invalid={!!error}
disabled={uploading}
class:need={need && !error?.trim()}
class:bad={!!error?.trim()}
title={isMedia
? t('studio.refAddSubMedia', { n: remaining, max })
: t('studio.refAddSub', { n: remaining, max })}
aria-label={isMedia ? t('studio.refAddMedia', { kind: label }) : t('studio.refAdd')}
aria-busy={uploading || undefined}
disabled={uploading}
onclick={pickAdd}
ondragenter={(e) => {
e.preventDefault();
@@ -245,83 +249,24 @@
ondrop={onDrop}
>
{#if uploading && uploadingIndex < 0}
<Spinner size="sm" />
<span class="add-main">{uploadingName}</span>
<span class="a-tile-ring" title={uploadingName}><i></i></span>
{:else}
<Icon name="plus" size={16} />
<span class="add-main">{isMedia ? t('studio.refAddMedia', { kind: label }) : t('studio.refAdd')}</span>
<span class="add-sub mono">{isMedia ? t('studio.refAddSubMedia', { n: remaining, max }) : t('studio.refAddSub', { n: remaining, max })}</span>
<Icon name="plus" size={18} />
{/if}
</button>
{/if}
</div>
{#if allowFromResults && onFromResults && remaining > 0}
<button type="button" class="from-results-link" onclick={onFromResults}>
{t('studio.slotFromResults')}
</button>
{/if}
{#if remaining === 0}
<span class="full-note">{isMedia ? t('studio.refFullMedia', { max }) : t('studio.refFull', { max })}</span>
<span class="a-slot-hint">{isMedia ? t('studio.refFullMedia', { max }) : t('studio.refFull', { max })}</span>
{/if}
{#each rejects as rej, i (rej.name + i)}
<span class="slot-error">{rej.message}</span>
<span class="a-slot-hint err" role="alert">{rej.message}</span>
{/each}
{#if error?.trim()}<span class="slot-error" role="alert">{error}</span>{/if}
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span>{/if}
</div>
<style>
.refgrid-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.refgrid.audio {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.refgrid.audio .ref-thumb {
aspect-ratio: auto;
min-height: 64px;
flex-direction: column;
gap: 2px;
padding: 8px 4px;
}
.refgrid.audio audio {
width: 90%;
height: 28px;
}
.refgrid video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.audio-glyph {
color: var(--text-3);
display: inline-flex;
}
.slot-label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lbl {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
}
.lbl em {
color: var(--error);
font-style: normal;
margin-left: 2px;
}
.slot-hint {
font-size: 10.5px;
color: var(--text-3);
white-space: nowrap;
}
.sr-file {
position: absolute;
width: 1px;
@@ -331,194 +276,7 @@
white-space: nowrap;
border: 0;
}
.refgrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ref-cell {
position: relative;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding: 5px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
}
.ref-cell.rejected {
border-color: var(--error);
}
.ref-thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
background: var(--bg);
cursor: pointer;
}
.ref-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ref-thumb.error {
flex-direction: column;
gap: 4px;
cursor: default;
color: var(--error);
font-size: 10px;
}
.ref-no {
position: absolute;
left: 4px;
top: 4px;
min-width: 16px;
padding: 0 4px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.62);
color: var(--text);
font-size: 10px;
line-height: 16px;
text-align: center;
}
.ref-busy {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
}
.ref-x {
position: absolute;
top: 9px;
right: 9px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 5px;
background: rgba(0, 0, 0, 0.62);
color: var(--text);
cursor: pointer;
}
.ref-x:hover {
color: var(--error);
}
.ref-name {
font-size: 11px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ref-name.err {
color: var(--error);
}
.ref-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
font-size: 9.5px;
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
}
.mini-link {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 10.5px;
color: var(--text-2);
text-decoration: underline;
cursor: pointer;
flex-shrink: 0;
}
.mini-link:hover {
color: var(--accent);
}
.ref-add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 110px;
padding: 8px 4px;
border: 1.5px dashed var(--border-strong);
border-radius: 8px;
background: var(--surface-2);
color: var(--text-3);
font-family: inherit;
cursor: pointer;
transition:
border-color 150ms,
color 150ms;
}
.ref-add:hover:not(:disabled) {
color: var(--text-2);
}
.ref-add.drag {
border-color: var(--accent);
color: var(--accent);
}
.ref-add.invalid {
border-color: var(--error);
}
.ref-add:disabled {
cursor: default;
opacity: 0.7;
}
.add-main {
font-size: 12px;
color: var(--text-2);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.add-sub {
font-size: 10px;
text-align: center;
}
.from-results-link {
align-self: flex-end;
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11px;
color: var(--accent);
cursor: pointer;
}
.from-results-link:hover {
text-decoration: underline;
}
.full-note {
font-size: 11px;
color: var(--text-3);
}
.slot-error {
font-size: 11.5px;
color: var(--error);
line-height: 1.5;
}
@media (max-width: 1023px) {
.refgrid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
audio {
display: none;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -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>
@@ -132,9 +132,7 @@ let {
min-height: 0;
width: 100%;
height: 100%;
background:
radial-gradient(ellipse at center, rgba(139, 92, 246, 0.08), transparent 55%),
#050508;
background: var(--stage-bg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
@@ -79,14 +79,14 @@
let criticNotes = $state<string[]>([]);
const preview = createMutation({
mutationFn: async (vars?: { force?: boolean }) => {
mutationFn: async (vars: { force?: boolean } | void) => {
if (!clip && !scene) throw new Error(t('promptPreview.noClipSelected'));
return jobsApi.previewPrompt(projectId, {
clip_id: clip?.id,
scene_id: clip ? undefined : scene?.id,
workflow_id: workflow?.id,
input_values: compactInputValues(inputValues),
force: Boolean(vars?.force),
force: Boolean((vars as { force?: boolean } | undefined)?.force),
});
},
onSuccess: (data) => {
@@ -203,7 +203,7 @@ title={t('filmstrip.clipTitle', {
inset: 0 0 auto 0;
height: 3px;
z-index: 1;
background: #3f3f46;
background: var(--bg-4);
}
.clip.status-done .bar {
@@ -4,10 +4,13 @@
import Icon from '$lib/components/ui/Icon.svelte';
import JobChip from './JobChip.svelte';
import PromptBlock from './PromptBlock.svelte';
import { deskHref } from '$lib/components/generate/context';
import { t } from '$lib/i18n.svelte';
interface Props {
stage: WorkbenchStage;
/** Enables the 在生成台调参 link (desk opens with this asset's context). */
projectId?: number;
onSave?: (content: Record<string, unknown>) => void;
onRun?: (instruction?: string) => void;
onGenerate?: (payload: {
@@ -20,7 +23,7 @@
busy?: boolean;
}
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
let { stage, projectId, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state<WorkbenchAsset[]>([]);
@@ -78,12 +81,12 @@
{#each draft as asset (asset.id)}
<div class="asset-edit">
<div class="asset-edit-head">
<select class="wb-input w-kind" bind:value={asset.kind}>
<select class="a-input w-kind" bind:value={asset.kind}>
<option value="character">{t('workbench.asset.kindCharacter')}</option>
<option value="location">{t('workbench.asset.kindLocation')}</option>
<option value="prop">{t('workbench.asset.kindProp')}</option>
</select>
<input class="wb-input name" bind:value={asset.name} placeholder={t('workbench.asset.namePh')} />
<input class="a-input name" bind:value={asset.name} placeholder={t('workbench.asset.namePh')} />
<button
type="button"
class="icon-btn danger"
@@ -93,15 +96,15 @@
<Icon name="trash" size={13} />
</button>
</div>
<input class="wb-input" bind:value={asset.description} placeholder={t('workbench.asset.descPh')} />
<input class="a-input" bind:value={asset.description} placeholder={t('workbench.asset.descPh')} />
<textarea
class="wb-input mono"
class="a-input mono"
rows="2"
bind:value={asset.prompt}
placeholder={t('workbench.asset.promptPh')}
></textarea>
<input
class="wb-input mono"
class="a-input mono"
bind:value={asset.negative_prompt}
placeholder={t('workbench.style.negativePh')}
/>
@@ -109,17 +112,17 @@
{/each}
</div>
<div class="editor-foot">
<button type="button" class="btn ghost" onclick={addAsset}>
<button type="button" class="a-btn ghost" onclick={addAsset}>
<Icon name="plus" size={13} />
{t('workbench.asset.add')}
</button>
<div class="foot-btns">
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
{t('workbench.cancelEdit')}
</button>
<button
type="button"
class="btn primary"
class="a-btn primary"
disabled={!draft.some((a) => a.name.trim() && a.prompt.trim())}
onclick={save}
>
@@ -132,7 +135,7 @@
<div class="todo-card">
<p class="todo-title">{t('workbench.asset.todoTitle')}</p>
<p class="todo-body">{t('workbench.asset.todoBody')}</p>
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.asset.generate')}
</button>
@@ -168,9 +171,28 @@
<PromptBlock label={t('workbench.style.negativeLabel')} text={asset.negative_prompt} />
{/if}
<div class="asset-foot">
{#if projectId}
<a
class="a-btn ghost sm"
href={deskHref({
kind: 'image',
projectId,
stage: 'assets',
label: t('desk.ctxAsset', {
kind: t(KIND_LABEL[asset.kind] ?? 'workbench.asset.kindProp'),
name: asset.name,
}),
prompt: asset.prompt,
negative: asset.negative_prompt || undefined,
})}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
</a>
{/if}
<button
type="button"
class="btn ghost gen"
class="a-btn ghost sm"
disabled={busy || jobActive(asset.job)}
title={t('workbench.asset.generateHint')}
onclick={() =>
@@ -192,11 +214,11 @@
<span class="hint">{t('workbench.asset.summary', { n: assets.length })}</span>
<div class="foot-btns">
{#if stage.status !== 'running'}
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
<Icon name="edit" size={13} />
{t('workbench.editDraft')}
</button>
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
<Icon name="retry" size={13} />
{t('workbench.regenerate')}
</button>
@@ -294,7 +316,9 @@
}
.asset-foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--sp-2);
}
.retry-link {
@@ -354,34 +378,14 @@
gap: 8px;
}
.wb-input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 12.5px;
line-height: 1.6;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: 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;
}
@@ -408,44 +412,4 @@
color: var(--error);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
opacity 150ms;
}
.btn.primary {
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text-2);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: 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>
@@ -4,10 +4,13 @@
import Icon from '$lib/components/ui/Icon.svelte';
import JobChip from './JobChip.svelte';
import PromptBlock from './PromptBlock.svelte';
import { deskHref } from '$lib/components/generate/context';
import { t } from '$lib/i18n.svelte';
interface Props {
stage: WorkbenchStage;
/** Enables the 在生成台调参 link (desk opens with this shot's context). */
projectId?: number;
onSave?: (content: Record<string, unknown>) => void;
onRun?: (instruction?: string) => void;
onGenerate?: (payload: {
@@ -20,7 +23,7 @@
busy?: boolean;
}
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
let { stage, projectId, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
let editing = $state(false);
let draft = $state<VideoShot[]>([]);
@@ -71,9 +74,9 @@
{#each draft as shot (shot.id)}
<div class="vshot-edit">
<div class="vshot-edit-head">
<input class="wb-input w-no mono" bind:value={shot.shot_no} placeholder={t('workbench.vshot.noPh')} />
<input class="a-input w-no mono" bind:value={shot.shot_no} placeholder={t('workbench.vshot.noPh')} />
<input
class="wb-input w-dur mono"
class="a-input w-dur mono"
type="number"
min="1"
step="1"
@@ -89,9 +92,9 @@
<Icon name="trash" size={13} />
</button>
</div>
<input class="wb-input" bind:value={shot.description} placeholder={t('workbench.vshot.descPh')} />
<input class="a-input" bind:value={shot.description} placeholder={t('workbench.vshot.descPh')} />
<textarea
class="wb-input mono"
class="a-input mono"
rows="3"
bind:value={shot.prompt}
placeholder={t('workbench.vshot.promptPh')}
@@ -100,17 +103,17 @@
{/each}
</div>
<div class="editor-foot">
<button type="button" class="btn ghost" onclick={addShot}>
<button type="button" class="a-btn ghost" onclick={addShot}>
<Icon name="plus" size={13} />
{t('workbench.vshot.add')}
</button>
<div class="foot-btns">
<button type="button" class="btn ghost" onclick={() => (editing = false)}>
<button type="button" class="a-btn ghost" onclick={() => (editing = false)}>
{t('workbench.cancelEdit')}
</button>
<button
type="button"
class="btn primary"
class="a-btn primary"
disabled={!draft.some((s) => s.prompt.trim())}
onclick={save}
>
@@ -123,7 +126,7 @@
<div class="todo-card">
<p class="todo-title">{t('workbench.vshot.todoTitle')}</p>
<p class="todo-body">{t('workbench.vshot.todoBody')}</p>
<button type="button" class="btn primary" disabled={busy} onclick={() => onRun?.()}>
<button type="button" class="a-btn primary" disabled={busy} onclick={() => onRun?.()}>
<Icon name="sparkle" size={14} />
{t('workbench.vshot.generate')}
</button>
@@ -159,9 +162,25 @@
{/if}
<PromptBlock label={t('workbench.vshot.promptLabel')} text={shot.prompt} />
<div class="vshot-foot">
{#if projectId}
<a
class="a-btn ghost sm"
href={deskHref({
kind: 'video',
projectId,
stage: 'shot',
label: t('desk.ctxShot', { no: shot.shot_no }),
prompt: shot.prompt,
durationSec: shot.duration_sec,
})}
title={t('desk.openInDeskHint')}
>
{t('desk.openInDesk')}
</a>
{/if}
<button
type="button"
class="btn ghost gen"
class="a-btn ghost sm"
disabled={busy || jobActive(shot.job)}
title={t('workbench.vshot.generateHint')}
onclick={() =>
@@ -183,11 +202,11 @@
<span class="hint">{t('workbench.vshot.summary', { n: shots.length })}</span>
<div class="foot-btns">
{#if stage.status !== 'running'}
<button type="button" class="btn ghost" disabled={busy} onclick={startEdit}>
<button type="button" class="a-btn ghost" disabled={busy} onclick={startEdit}>
<Icon name="edit" size={13} />
{t('workbench.editDraft')}
</button>
<button type="button" class="btn ghost" disabled={busy} onclick={() => onRun?.()}>
<button type="button" class="a-btn ghost" disabled={busy} onclick={() => onRun?.()}>
<Icon name="retry" size={13} />
{t('workbench.regenerate')}
</button>
@@ -241,9 +260,10 @@
flex-wrap: wrap;
}
.vshot-no {
font-size: 13px;
font-family: var(--mono);
font-size: var(--fs-body);
font-weight: 700;
color: var(--accent);
color: var(--accent-lo);
font-variant-numeric: tabular-nums;
}
.vshot-dur {
@@ -260,13 +280,15 @@
.vshot-video {
width: 100%;
max-height: 300px;
border-radius: 8px;
border: 1px solid var(--border);
background: #000;
border-radius: var(--r-md);
border: 1px solid var(--line);
background: var(--media-scrim);
}
.vshot-foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--sp-2);
}
.retry-link {
@@ -326,35 +348,15 @@
gap: 8px;
}
.wb-input {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 12.5px;
line-height: 1.6;
transition: border-color 150ms;
}
.wb-input:focus {
outline: none;
border-color: 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;
}
@@ -382,44 +384,4 @@
color: var(--error);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
transition:
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
border-color 150ms,
opacity 150ms;
}
.btn.primary {
background: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn.primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn.ghost {
background: var(--surface-2);
border-color: var(--border-strong);
color: var(--text-2);
}
.btn.ghost:hover:not(:disabled) {
color: var(--text);
border-color: 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;
@@ -195,7 +203,7 @@
color: var(--warn);
}
.st-edited {
color: var(--info);
color: var(--warn);
}
.st-confirmed {
color: var(--success);
@@ -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>
+180 -7
View File
@@ -64,6 +64,7 @@ export const de: Dict = {
// --- Toast ---
'toast.dismiss': 'Ausblenden',
'toast.undo': 'Rückgängig',
'toast.uploadFailed': 'Upload fehlgeschlagen',
// --- Modal ---
@@ -220,6 +221,25 @@ export const de: Dict = {
'lightbox.videoPreview': 'Videovorschau',
'lightbox.videoUnavailable': 'Video nicht verfügbar',
'lightbox.zoom100': '100%',
'lastFrame.export': 'Letztes Bild exportieren',
'lastFrame.exporting': 'Letztes Bild wird exportiert…',
'lastFrame.hint': 'Speichert das letzte Bild in Originalgröße als PNG, z. B. als Startbild-Referenz',
'lastFrame.fileTag': 'letztes-bild',
'lastFrame.done': 'Letztes Bild exportiert {name} ({size})',
'lastFrame.errTimeout': 'Das Video lädt zu lange. Bitte später erneut versuchen.',
'lastFrame.errNotFound': 'Die Videodatei fehlt oder wurde entfernt.',
'lastFrame.errForbidden': 'Keine Berechtigung, dieses Video zu lesen.',
'lastFrame.errNetwork': 'Das Video konnte nicht gelesen werden (Netzwerkfehler).',
'lastFrame.errDecode': 'Das Video ist beschädigt; das Ende lässt sich nicht dekodieren.',
'lastFrame.errUnsupported': 'Dieser Browser unterstützt das Videoformat nicht, oder die Datei ist beschädigt.',
'lastFrame.errCrossOrigin':
'Das Video stammt von einer anderen Website, die das Auslesen von Bildern sperrt (Cross-Origin). Video zuerst herunterladen.',
'lastFrame.errNoPicture': 'Diese Datei enthält kein Videobild.',
'lastFrame.errUnseekable':
'Das Videoende ist nicht erreichbar (Länge unbekannt oder Quelle erlaubt kein Springen); kein Bild exportiert.',
'lastFrame.errBlack': 'Die letzte Sekunde des Videos ist schwarz; kein Bild exportiert.',
'lastFrame.errEmpty': 'Der Browser konnte das Ende nicht dekodieren. Erneut versuchen oder Desktop-Chrome / Edge nutzen.',
'lastFrame.errUnknown': 'Export des letzten Bildes fehlgeschlagen: {msg}',
// --- Artifact / canvas nodes ---
'artifact.artifactLabel': 'Artefakt',
@@ -948,6 +968,8 @@ export const de: Dict = {
'library.selectAll': 'Alle auswählen',
'library.deselectAll': 'Auswahl aufheben',
'library.itemCount': '{count} Element(e)',
'library.loadError': 'Bibliothek konnte nicht geladen werden',
'library.subtitle': 'Generierte Assets an einem Ort – Bilder und Videos ohne Projektbezug ansehen, filtern und löschen',
'library.deleteSelected': 'Löschen ({count})',
'library.addToProject': 'Zum Projekt hinzufügen',
'library.filterLabel': 'Nach Art filtern',
@@ -1259,15 +1281,46 @@ export const de: Dict = {
'shell.navAgents': 'Agenten',
'shell.navConnections': 'ComfyUI-Verbindungen',
'shell.navModels': 'Modelle',
'shell.navLibrary': 'Asset-Bibliothek',
'shell.navSettings': 'Einstellungen',
'shell.wip': 'In Entwicklung',
'shell.wipHint': 'Diese Seite ist noch im Bau und folgt später',
'shell.comfy_ok': '{name} verbunden',
'shell.comfy_fail': '{name} nicht erreichbar',
'shell.comfy_testing': '{name} wird getestet…',
'shell.comfyUnset': 'ComfyUI · nicht konfiguriert',
'shell.comfyManage': 'ComfyUI-Adresse in den Einstellungen pflegen',
'shell.mobileNotice': 'Bitte öffne diese Werkbank in einem Desktop-Browser (mindestens 768px breit); Handys werden noch nicht unterstützt.',
'shell.scopeHint': 'Projekt wechseln',
'shell.scopeAll': 'Alle Projekte',
'shell.scopeAllList': 'Liste aller Projekte',
'shell.scopeRecent': 'Letzte Projekte',
'shell.scopeLoadFailed': 'Projekte konnten nicht geladen werden',
'shell.scopeEmpty': 'Noch keine Projekte – jetzt anlegen',
'shell.scopeStage': '{n}/5 bestätigt',
'shell.queueRunning': '{n} in Arbeit',
'shell.queueQueued': '{n} in der Warteschlange',
'shell.queueIdle': 'Warteschlange leer',
'shell.queueActive': '{n} aktiv',
'shell.trayTitle': 'Auftragsablage',
'shell.trayRecent': 'Letzte Aufträge · klicken für alle Aufträge',
'shell.trayEmpty': 'Noch keine Generierungsaufträge',
'shell.trayAll': 'Alle Aufträge anzeigen',
'shell.searchTitle': 'Seiten und Projekte suchen',
'shell.searchPlaceholder': 'Seiten, Projekte suchen',
'shell.searchShortcut': 'Suchen (Strg/⌘ + K)',
'shell.searchEmpty': 'Keine Seite und kein Projekt passt zu „{q}“',
'shell.searchGroupPages': 'Seiten',
'shell.searchGroupProjects': 'Projekte · Werkbank öffnen',
'shell.searchHintMove': 'auswählen',
'shell.searchHintOpen': 'öffnen',
'shell.searchHintClose': 'schließen',
'shell.reduceMotion': 'Weniger Bewegung',
'shell.reduceHint': 'Bewegungs-, Zoom- und Glüheffekte aus; nur kurze Überblendungen',
'shell.reduceSystem': 'Folgt der Systemeinstellung „Bewegung reduzieren“',
'shell.tabAria': 'Hauptnavigation (schmal)',
'shell.tabProjects': 'Projekte',
'shell.tabCreate': 'Erstellen',
'shell.tabGenerate': 'Generieren',
'shell.tabMore': 'Mehr',
'shell.morePages': 'Weitere Seiten',
'shell.moreTools': 'Werkzeuge',
'home.title': 'Projektübersicht',
'home.subtitle': 'Verwalte deine Projekte — starte mit einer Idee oder einem fertigen Drehbuch.',
'home.projectCount': '{n} Projekte · {m} in Arbeit',
@@ -1456,8 +1509,6 @@ export const de: Dict = {
'studio.genParams': 'Parameter',
'studio.modeTextToImage': 'Text zu Bild',
'studio.modeTextToVideo': 'Text zu Video',
'studio.chipIdle': '{name} · Warteschlange frei',
'studio.chipBusy': '{name} · {n} in Warteschlange',
'studio.video.resultsTitle': 'Vorschau',
'studio.image.historyTitle': 'Bildverlauf',
'studio.video.historyTitle': 'Videoverlauf',
@@ -1507,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',
@@ -1581,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',
@@ -1600,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',
@@ -1616,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',
@@ -1841,12 +1902,14 @@ export const de: Dict = {
'home.stageProgress': 'Fünf-Phasen-Fortschritt',
'tasks.sourceWorkbench': 'Kreativ-Fluss',
'jobst.idSuffix': ' · ComfyUI-Prompt-ID {id}',
'jobst.done': 'Fertig',
'jobst.done': 'Fixiert',
'jobst.doneDetail': 'ComfyUI ist fertig, Ergebnisse gespeichert',
'jobst.dryRun': 'Platzhalter',
'jobst.dryRunDetail': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
'jobst.noOutput': 'Keine Ausgabe',
'jobst.noOutputDetail': 'Der Job endete ohne Ergebnisdatei – keine fertige Generierung',
'jobst.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',
@@ -1924,4 +1987,114 @@ export const de: Dict = {
'wft.errListMin': '„{label}“ benötigt mindestens {n} {unit}',
'tasks.testBadge': 'Test',
'tasks.sourceWorkflowTest': 'Workflow-Test',
// --- 生成台 (HEL-653) ---
'cdf.orig': 'Original {v}',
'desk.title': 'Generieren',
'desk.advanced': 'Erweitert · {n} · unverändert gelten die Workflow-Werte',
'desk.chipResolution': 'Auflösung {v}',
'desk.chipSeconds': '{v} s',
'desk.chipSeed': 'Seed {v}',
'desk.closeInspector': 'Inspektor schließen',
'desk.countActive': '{n} laufen',
'desk.countTakes': '{n} Einträge',
'desk.ctxAsset': '{kind} „{name}“',
'desk.ctxShot': 'Einstellung {no}',
'desk.ctxBack': 'Zurück zu {stage}',
'desk.ctxDismiss': 'Herkunftshinweis schließen',
'desk.ctxFrom': 'Aus „{project}“',
'desk.ctxNoBackfill': 'Ergebnisse bleiben im Verlauf und werden nicht in {stage} übernommen',
'desk.dayDate': '{d}.{m}.',
'desk.download': 'Herunterladen',
'desk.dropNoSlot': '{n} Datei(en) ohne passenden Slot: dieser Workflow hat keinen solchen Eingang',
'desk.dryRun': 'Probelauf',
'desk.durMin': '{m} min {s} s',
'desk.durSec': '{s} s',
'desk.elapsed': 'Dauer',
'desk.emptyFailed': 'Keine fehlgeschlagenen Einträge',
'desk.emptyFeed': 'Noch keine Einträge. Prompt unten eingeben und „Generieren“ drücken.',
'desk.filterAria': 'Einträge filtern',
'desk.filterAll': 'Alle',
'desk.filterImage': 'Bilder',
'desk.filterVideo': 'Videos',
'desk.filterFailed': 'Fehlgeschlagen',
'desk.generate': 'Generieren',
'desk.genHint': 'Generieren (Strg/⌘ + Enter)',
'desk.inputs': 'Eingaben',
'desk.inspector': 'Inspektor',
'desk.keyClose': 'Schließen',
'desk.keyInspector': 'Ein- / ausblenden',
'desk.keySelect': 'Auswählen',
'desk.keyUse': 'Verwenden',
'desk.kvCreated': 'Eingereicht',
'desk.kvName': 'Name',
'desk.kvPromptId': 'ComfyUI-Auftrags-ID',
'desk.kvUsage': 'Zweck',
'desk.loadEarlier': '{n} ältere Takes anzeigen',
'desk.missingSub': 'Mit gleichen Parametern wiederholen oder diesen Take entfernen',
'desk.missingTitle': 'Die Ergebnisdateien sind nicht mehr auf dem Server',
'desk.needWorkflow': 'Fehlt: Workflow',
'desk.noConn': 'Nicht verbunden',
'desk.noPromptInput': 'Dieser Workflow hat keinen Prompt-Eingang und läuft mit seinen eigenen Werten.',
'desk.notGenerated': 'Nicht erzeugt',
'desk.noWorkflows': 'Noch kein Bild- oder Video-Workflow bereit',
'desk.openInDesk': 'Im Generator anpassen',
'desk.openInDeskHint': 'Generator mit diesem Prompt öffnen, Workflow und Parameter selbst wählen; Ergebnisse bleiben im Verlauf',
'desk.openInspector': 'Inspektor',
'desk.origValue': 'Workflow-Wert',
'desk.outputN': 'Ergebnis {n}',
'desk.outputs': 'Alle Ergebnisse',
'desk.paramError': 'Ungültiger Parameter: {msg}',
'desk.paramsAll': 'Alle Parameter',
'desk.paramsChanged': 'Parameter · {n} geändert',
'desk.paramsCommon': 'Häufige Parameter',
'desk.pickTake': 'Eintrag im Verlauf wählen, um das Ergebnis zu sehen',
'desk.pickWorkflow': 'Workflow wählen',
'desk.promptOnly': 'Nur Prompt',
'desk.promptPh': 'Motiv beschreiben… (Strg/⌘ + Enter generiert, / fokussiert)',
'desk.restoreAll': 'Alle Workflow-Werte wiederherstellen',
'desk.redo': 'Mit gleichen Parametern wiederholen',
'desk.removeRecord': 'Eintrag entfernen',
'desk.restoreOrig': 'Original wiederherstellen',
'desk.reuse': 'Wiederverwenden',
'desk.reuseToComposer': 'In Eingabe übernehmen',
'desk.seedRandom': 'Zufall',
'desk.sendToSlot': 'In Bild-Slot senden',
'desk.sent': 'Eingereicht',
'desk.sentToSlotToast': 'In den Bild-Slot des aktuellen Workflows gesendet',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': 'Größe',
'desk.stillNeed': 'Fehlt: {what}',
'desk.tabParams': 'Parameter',
'desk.tabResult': 'Ergebnis',
'desk.today': 'Heute',
'desk.took': 'Dauer {d}',
'desk.wfBroken': 'Nicht nutzbar',
'desk.wfCurrent': 'aktuell',
'desk.wfDefaultConn': 'Standardverbindung',
'desk.wfImport': 'Workflows importieren / verwalten',
'desk.wfLastFailed': 'Letzter Lauf fehlgeschlagen',
'desk.wfNoMatch': 'Kein passender Workflow',
'desk.wfOk': 'Bereit',
'desk.wfSearchPh': 'Workflows oder Zweck suchen: Bild zu Video, Referenz…',
'desk.wfTesting': 'Testlauf läuft',
'desk.wfUntested': 'Nicht getestet',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Gestern',
'wfd.stepParse': 'JSON parsen',
'wfd.stepNodes': 'Knoten erkennen',
'wfd.stepModels': 'Modelle prüfen',
'wfd.stepSlots': 'Eingangsslots aufbauen',
'wfd.noOutput': 'Der Workflow hat keinen Ausgabeknoten — Import nicht möglich',
'wfd.nodesAutoOk': 'Verwendung automatisch erkannt: {usage}',
'wfd.nodesUserOk': 'Verwendung bestätigt: {usage}',
'wfd.modelsOk': '„{name}“ hat alle benötigten Knoten und Modelle',
'wfd.slotsOk': '{n} Eingangsslot(s) aufgebaut ({m} Medien)',
'wfd.checksAria': 'Import-Prüfergebnisse',
'wfd.showDetails': 'Fehlerdetails anzeigen',
'wfd.hideDetails': 'Fehlerdetails ausblenden',
'wfd.unreachableSuggest': 'Eine misslungene Prüfung verhindert das Speichern nicht; nach Reparatur erneut prüfen.',
'wfd.nodesPickSuggest': 'Wähle unten die Verwendung und schließe die Ausgangszuordnung ab, bevor du speicherst.',
'wfd.showRaw': 'Rohes JSON anzeigen',
'wfd.hideRaw': 'Rohes JSON ausblenden',
};
+180 -7
View File
@@ -62,6 +62,7 @@ export const en = {
// --- Toast ---
'toast.dismiss': 'Dismiss',
'toast.undo': 'Undo',
'toast.uploadFailed': 'Upload failed',
// --- Modal ---
@@ -220,6 +221,25 @@ export const en = {
'lightbox.videoPreview': 'Video preview',
'lightbox.videoUnavailable': 'Video unavailable',
'lightbox.zoom100': '100%',
'lastFrame.export': 'Export last frame',
'lastFrame.exporting': 'Exporting last frame…',
'lastFrame.hint': 'Save the final frame as a full-size PNG, e.g. as a first-frame reference',
'lastFrame.fileTag': 'last-frame',
'lastFrame.done': 'Exported last frame {name} ({size})',
'lastFrame.errTimeout': 'The video took too long to load. Try again later.',
'lastFrame.errNotFound': 'The video file is missing or was removed.',
'lastFrame.errForbidden': 'You do not have permission to read this video.',
'lastFrame.errNetwork': 'Reading the video failed (network error). Check the connection and retry.',
'lastFrame.errDecode': 'The video is damaged; its ending cannot be decoded.',
'lastFrame.errUnsupported': 'This browser cannot play the video format, or the file is damaged.',
'lastFrame.errCrossOrigin':
'The video comes from another site that blocks reading its frames (cross-origin). Download the video first.',
'lastFrame.errNoPicture': 'This file has no video picture.',
'lastFrame.errUnseekable':
'Cannot reach the end of the video (unknown length, or the source does not allow seeking); no frame exported.',
'lastFrame.errBlack': 'The last second of the video is black; no frame exported.',
'lastFrame.errEmpty': 'The browser could not decode the ending. Retry, or use desktop Chrome / Edge.',
'lastFrame.errUnknown': 'Exporting the last frame failed: {msg}',
// --- Artifact / canvas nodes ---
'artifact.artifactLabel': 'Artifact',
@@ -948,6 +968,8 @@ export const en = {
'library.selectAll': 'Select all',
'library.deselectAll': 'Deselect all',
'library.itemCount': '{count} item(s)',
'library.loadError': 'Failed to load the library',
'library.subtitle': 'Generated assets in one place — preview, filter and remove images and videos not tied to a project',
'library.deleteSelected': 'Delete ({count})',
'library.addToProject': 'Add to Project',
'library.filterLabel': 'Filter by kind',
@@ -1261,15 +1283,46 @@ export const en = {
'shell.navAgents': 'Agents',
'shell.navConnections': 'ComfyUI Connections',
'shell.navModels': 'Models',
'shell.navLibrary': 'Asset Library',
'shell.navSettings': 'Settings',
'shell.wip': 'In development',
'shell.wipHint': 'This page is under construction and will open later',
'shell.comfy_ok': '{name} connected',
'shell.comfy_fail': '{name} unreachable',
'shell.comfy_testing': 'Testing {name}…',
'shell.comfyUnset': 'ComfyUI · not configured',
'shell.comfyManage': 'Configure the ComfyUI address in Settings',
'shell.mobileNotice': 'Please open this workbench in a desktop browser (at least 768px wide). Phones are not supported yet.',
'shell.scopeHint': 'Switch project',
'shell.scopeAll': 'All projects',
'shell.scopeAllList': 'All projects list',
'shell.scopeRecent': 'Recent projects',
'shell.scopeLoadFailed': 'Could not load projects',
'shell.scopeEmpty': 'No projects yet — create one',
'shell.scopeStage': '{n}/5 confirmed',
'shell.queueRunning': '{n} generating',
'shell.queueQueued': '{n} queued',
'shell.queueIdle': 'Queue idle',
'shell.queueActive': '{n} active',
'shell.trayTitle': 'Job tray',
'shell.trayRecent': 'Recent jobs · click to see all jobs',
'shell.trayEmpty': 'No generation jobs yet',
'shell.trayAll': 'View all jobs',
'shell.searchTitle': 'Search pages and projects',
'shell.searchPlaceholder': 'Search pages, projects',
'shell.searchShortcut': 'Search (Ctrl/⌘ + K)',
'shell.searchEmpty': 'No page or project matches “{q}”',
'shell.searchGroupPages': 'Pages',
'shell.searchGroupProjects': 'Projects · open the workbench',
'shell.searchHintMove': 'select',
'shell.searchHintOpen': 'open',
'shell.searchHintClose': 'close',
'shell.reduceMotion': 'Reduce motion',
'shell.reduceHint': 'Turn off movement, scaling and glow animations; keep only short fades',
'shell.reduceSystem': 'Following the system “Reduce motion” setting',
'shell.tabAria': 'Primary (narrow screens)',
'shell.tabProjects': 'Projects',
'shell.tabCreate': 'Create',
'shell.tabGenerate': 'Generate',
'shell.tabMore': 'More',
'shell.morePages': 'More pages',
'shell.moreTools': 'Tools',
'home.title': 'Projects overview',
'home.subtitle': 'Manage your projects — start from an idea or a ready-made script.',
'home.projectCount': '{n} projects · {m} in progress',
@@ -1455,8 +1508,6 @@ export const en = {
'studio.genParams': 'Parameters',
'studio.modeTextToImage': 'Text to image',
'studio.modeTextToVideo': 'Text to video',
'studio.chipIdle': '{name} · queue idle',
'studio.chipBusy': '{name} · {n} in queue',
'studio.video.resultsTitle': 'Preview',
'studio.image.historyTitle': 'Image history',
'studio.video.historyTitle': 'Video history',
@@ -1506,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',
@@ -1580,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',
@@ -1599,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',
@@ -1615,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',
@@ -1840,12 +1901,14 @@ export const en = {
'home.stageProgress': 'Five-stage progress',
'tasks.sourceWorkbench': 'Creation flow',
'jobst.idSuffix': ' · ComfyUI prompt ID {id}',
'jobst.done': 'Done',
'jobst.done': 'Fixed',
'jobst.doneDetail': 'ComfyUI finished and the results are saved',
'jobst.dryRun': 'Placeholder',
'jobst.dryRunDetail': 'Placeholder only: Dry-run is on, ComfyUI was not called',
'jobst.noOutput': 'No output',
'jobst.noOutputDetail': 'The job ended without any result file — not a finished generation',
'jobst.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',
@@ -1923,6 +1986,116 @@ export const en = {
'wft.errListMin': '"{label}" needs at least {n} {unit}',
'tasks.testBadge': 'Test',
'tasks.sourceWorkflowTest': 'Workflow test',
// --- 生成台 (HEL-653) ---
'cdf.orig': 'Original {v}',
'desk.title': 'Generate',
'desk.advanced': 'Advanced · {n} · untouched ones keep the workflow values',
'desk.chipResolution': 'Resolution {v}',
'desk.chipSeconds': '{v}s',
'desk.chipSeed': 'Seed {v}',
'desk.closeInspector': 'Close inspector',
'desk.countActive': '{n} running',
'desk.countTakes': '{n} takes',
'desk.ctxAsset': '{kind} “{name}”',
'desk.ctxShot': 'Shot {no}',
'desk.ctxBack': 'Back to {stage}',
'desk.ctxDismiss': 'Dismiss source note',
'desk.ctxFrom': 'From “{project}”',
'desk.ctxNoBackfill': 'Results stay in the desk feed and are not filled back into {stage}',
'desk.dayDate': '{m}/{d}',
'desk.download': 'Download',
'desk.dropNoSlot': '{n} file(s) have no slot: this workflow has no input of that kind',
'desk.dryRun': 'Dry run',
'desk.durMin': '{m}m {s}s',
'desk.durSec': '{s}s',
'desk.elapsed': 'Time',
'desk.emptyFailed': 'No failed takes',
'desk.emptyFeed': 'No takes yet. Write a prompt below and press Generate.',
'desk.filterAria': 'Filter takes',
'desk.filterAll': 'All',
'desk.filterImage': 'Images',
'desk.filterVideo': 'Videos',
'desk.filterFailed': 'Failed',
'desk.generate': 'Generate',
'desk.genHint': 'Generate (Ctrl/⌘ + Enter)',
'desk.inputs': 'Inputs',
'desk.inspector': 'Inspector',
'desk.keyClose': 'Close',
'desk.keyInspector': 'Show / hide',
'desk.keySelect': 'Select',
'desk.keyUse': 'Use',
'desk.kvCreated': 'Submitted',
'desk.kvName': 'Name',
'desk.kvPromptId': 'ComfyUI prompt id',
'desk.kvUsage': 'Usage',
'desk.loadEarlier': 'Show {n} earlier takes',
'desk.missingSub': 'Redo with the same params, or remove this take',
'desk.missingTitle': 'The result files are no longer on the server',
'desk.needWorkflow': 'Missing: workflow',
'desk.noConn': 'Not connected',
'desk.noPromptInput': 'This workflow takes no prompt; it runs with its own values.',
'desk.notGenerated': 'Not generated',
'desk.noWorkflows': 'No image or video workflow is ready yet',
'desk.openInDesk': 'Tune in desk',
'desk.openInDeskHint': 'Open the desk with this prompt and pick the workflow and params yourself; results stay in the desk feed',
'desk.openInspector': 'Inspector',
'desk.origValue': 'Workflow value',
'desk.outputN': 'Result {n}',
'desk.outputs': 'All results',
'desk.paramError': 'Invalid parameter: {msg}',
'desk.paramsAll': 'All params',
'desk.paramsChanged': 'Params · {n} changed',
'desk.paramsCommon': 'Common params',
'desk.pickTake': 'Pick a take in the feed to see its result',
'desk.pickWorkflow': 'Choose workflow',
'desk.promptOnly': 'Prompt only',
'desk.promptPh': 'Describe the shot… (Ctrl/⌘ + Enter to generate, / to focus)',
'desk.restoreAll': 'Restore all workflow values',
'desk.redo': 'Redo with same params',
'desk.removeRecord': 'Remove record',
'desk.restoreOrig': 'Restore original',
'desk.reuse': 'Reuse',
'desk.reuseToComposer': 'Reuse in composer',
'desk.seedRandom': 'Random',
'desk.sendToSlot': 'Send to image slot',
'desk.sent': 'Submitted',
'desk.sentToSlotToast': 'Sent to the current workflow’s image slot',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': 'Size',
'desk.stillNeed': 'Missing: {what}',
'desk.tabParams': 'Params',
'desk.tabResult': 'Result',
'desk.today': 'Today',
'desk.took': 'Took {d}',
'desk.wfBroken': 'Unavailable',
'desk.wfCurrent': 'current',
'desk.wfDefaultConn': 'Default connection',
'desk.wfImport': 'Import / manage workflows',
'desk.wfLastFailed': 'Last run failed',
'desk.wfNoMatch': 'No matching workflow',
'desk.wfOk': 'Ready',
'desk.wfSearchPh': 'Search workflows or a usage: image to video, reference…',
'desk.wfTesting': 'Test running',
'desk.wfUntested': 'Not tested',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Yesterday',
'wfd.stepParse': 'Parse JSON',
'wfd.stepNodes': 'Identify nodes',
'wfd.stepModels': 'Verify models',
'wfd.stepSlots': 'Build input slots',
'wfd.noOutput': 'The workflow has no output node — cannot import',
'wfd.nodesAutoOk': 'Usage auto-detected: {usage}',
'wfd.nodesUserOk': 'Usage confirmed: {usage}',
'wfd.modelsOk': '"{name}" has all required nodes and models',
'wfd.slotsOk': '{n} input slot(s) built ({m} media)',
'wfd.checksAria': 'Import check results',
'wfd.showDetails': 'Show error details',
'wfd.hideDetails': 'Hide error details',
'wfd.unreachableSuggest': 'Being unverifiable does not block saving; re-check after fixing the connection.',
'wfd.nodesPickSuggest': 'Pick the usage and finish the output mapping below before saving.',
'wfd.showRaw': 'View raw JSON',
'wfd.hideRaw': 'Hide raw JSON',
};
export type Dict = typeof en;
+180 -7
View File
@@ -64,6 +64,7 @@ export const es: Dict = {
// --- Toast ---
'toast.dismiss': 'Descartar',
'toast.undo': 'Deshacer',
'toast.uploadFailed': 'Error al subir',
// --- Modal ---
@@ -220,6 +221,25 @@ export const es: Dict = {
'lightbox.videoPreview': 'Vista previa de vídeo',
'lightbox.videoUnavailable': 'Vídeo no disponible',
'lightbox.zoom100': '100%',
'lastFrame.export': 'Exportar último fotograma',
'lastFrame.exporting': 'Exportando último fotograma…',
'lastFrame.hint': 'Guarda el último fotograma como PNG a tamaño completo, p. ej. como referencia de primer fotograma',
'lastFrame.fileTag': 'ultimo-fotograma',
'lastFrame.done': 'Último fotograma exportado {name} ({size})',
'lastFrame.errTimeout': 'El vídeo tardó demasiado en cargar. Inténtalo más tarde.',
'lastFrame.errNotFound': 'El archivo de vídeo no existe o fue eliminado.',
'lastFrame.errForbidden': 'No tienes permiso para leer este vídeo.',
'lastFrame.errNetwork': 'No se pudo leer el vídeo (error de red).',
'lastFrame.errDecode': 'El vídeo está dañado; su final no se puede decodificar.',
'lastFrame.errUnsupported': 'Este navegador no admite el formato del vídeo o el archivo está dañado.',
'lastFrame.errCrossOrigin':
'El vídeo procede de otro sitio que no permite leer sus fotogramas (origen cruzado). Descarga primero el vídeo.',
'lastFrame.errNoPicture': 'Este archivo no tiene imagen de vídeo.',
'lastFrame.errUnseekable':
'No se puede llegar al final del vídeo (duración desconocida o el origen no permite buscar); no se exportó nada.',
'lastFrame.errBlack': 'El último segundo del vídeo es negro; no se exportó ningún fotograma.',
'lastFrame.errEmpty': 'El navegador no pudo decodificar el final. Reintenta o usa Chrome / Edge de escritorio.',
'lastFrame.errUnknown': 'Error al exportar el último fotograma: {msg}',
// --- Artifact / canvas nodes ---
'artifact.artifactLabel': 'Artefacto',
@@ -948,6 +968,8 @@ export const es: Dict = {
'library.selectAll': 'Seleccionar todo',
'library.deselectAll': 'Deseleccionar todo',
'library.itemCount': '{count} elemento(s)',
'library.loadError': 'No se pudo cargar la biblioteca',
'library.subtitle': 'Recursos generados en un solo lugar: previsualiza, filtra y elimina imágenes y vídeos sin proyecto',
'library.deleteSelected': 'Eliminar ({count})',
'library.addToProject': 'Añadir al proyecto',
'library.filterLabel': 'Filtrar por tipo',
@@ -1259,15 +1281,46 @@ export const es: Dict = {
'shell.navAgents': 'Agentes',
'shell.navConnections': 'Conexiones ComfyUI',
'shell.navModels': 'Modelos',
'shell.navLibrary': 'Biblioteca de assets',
'shell.navSettings': 'Ajustes',
'shell.wip': 'En desarrollo',
'shell.wipHint': 'Esta página está en construcción y llegará más adelante',
'shell.comfy_ok': '{name} conectado',
'shell.comfy_fail': '{name} sin conexión',
'shell.comfy_testing': 'Probando {name}…',
'shell.comfyUnset': 'ComfyUI · sin configurar',
'shell.comfyManage': 'Configura la dirección de ComfyUI en Ajustes',
'shell.mobileNotice': 'Abre este banco de trabajo en un navegador de escritorio (mínimo 768 px); los móviles aún no se admiten.',
'shell.scopeHint': 'Cambiar de proyecto',
'shell.scopeAll': 'Todos los proyectos',
'shell.scopeAllList': 'Lista de proyectos',
'shell.scopeRecent': 'Proyectos recientes',
'shell.scopeLoadFailed': 'No se pudieron cargar los proyectos',
'shell.scopeEmpty': 'Aún no hay proyectos: crea uno',
'shell.scopeStage': '{n}/5 confirmadas',
'shell.queueRunning': '{n} generando',
'shell.queueQueued': '{n} en cola',
'shell.queueIdle': 'Cola libre',
'shell.queueActive': '{n} activos',
'shell.trayTitle': 'Bandeja de tareas',
'shell.trayRecent': 'Tareas recientes · clic para ver todas',
'shell.trayEmpty': 'Aún no hay tareas de generación',
'shell.trayAll': 'Ver todas las tareas',
'shell.searchTitle': 'Buscar páginas y proyectos',
'shell.searchPlaceholder': 'Buscar páginas, proyectos',
'shell.searchShortcut': 'Buscar (Ctrl/⌘ + K)',
'shell.searchEmpty': 'Ninguna página ni proyecto coincide con «{q}»',
'shell.searchGroupPages': 'Páginas',
'shell.searchGroupProjects': 'Proyectos · abrir el banco de trabajo',
'shell.searchHintMove': 'elegir',
'shell.searchHintOpen': 'abrir',
'shell.searchHintClose': 'cerrar',
'shell.reduceMotion': 'Reducir movimiento',
'shell.reduceHint': 'Desactiva desplazamientos, escalados y brillos; solo quedan fundidos breves',
'shell.reduceSystem': 'Se sigue el ajuste del sistema «Reducir movimiento»',
'shell.tabAria': 'Navegación principal (estrecha)',
'shell.tabProjects': 'Proyectos',
'shell.tabCreate': 'Crear',
'shell.tabGenerate': 'Generar',
'shell.tabMore': 'Más',
'shell.morePages': 'Más páginas',
'shell.moreTools': 'Herramientas',
'home.title': 'Resumen de proyectos',
'home.subtitle': 'Gestiona tus proyectos: empieza con una idea o un guion terminado.',
'home.projectCount': '{n} proyectos · {m} en curso',
@@ -1456,8 +1509,6 @@ export const es: Dict = {
'studio.genParams': 'Parámetros',
'studio.modeTextToImage': 'Texto a imagen',
'studio.modeTextToVideo': 'Texto a vídeo',
'studio.chipIdle': '{name} · cola libre',
'studio.chipBusy': '{name} · {n} en cola',
'studio.video.resultsTitle': 'Vista previa',
'studio.image.historyTitle': 'Historial de imágenes',
'studio.video.historyTitle': 'Historial de vídeos',
@@ -1507,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',
@@ -1581,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',
@@ -1600,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',
@@ -1616,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',
@@ -1841,12 +1902,14 @@ export const es: Dict = {
'home.stageProgress': 'Progreso de cinco etapas',
'tasks.sourceWorkbench': 'Flujo de creación',
'jobst.idSuffix': ' · ID de prompt de ComfyUI {id}',
'jobst.done': 'Listo',
'jobst.done': 'Fijado',
'jobst.doneDetail': 'ComfyUI terminó y los resultados se guardaron',
'jobst.dryRun': 'Marcador',
'jobst.dryRunDetail': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
'jobst.noOutput': 'Sin salida',
'jobst.noOutputDetail': 'El trabajo terminó sin archivos de resultado: no cuenta como generado',
'jobst.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',
@@ -1924,4 +1987,114 @@ export const es: Dict = {
'wft.errListMin': '«{label}» necesita al menos {n} {unit}',
'tasks.testBadge': 'Prueba',
'tasks.sourceWorkflowTest': 'Prueba de flujo',
// --- 生成台 (HEL-653) ---
'cdf.orig': 'Original {v}',
'desk.title': 'Generar',
'desk.advanced': 'Avanzado · {n} · sin cambios se usan los valores del flujo',
'desk.chipResolution': 'Resolución {v}',
'desk.chipSeconds': '{v} s',
'desk.chipSeed': 'Semilla {v}',
'desk.closeInspector': 'Cerrar inspector',
'desk.countActive': '{n} en curso',
'desk.countTakes': '{n} registros',
'desk.ctxAsset': '{kind} «{name}»',
'desk.ctxShot': 'Plano {no}',
'desk.ctxBack': 'Volver a {stage}',
'desk.ctxDismiss': 'Cerrar aviso de origen',
'desk.ctxFrom': 'De «{project}»',
'desk.ctxNoBackfill': 'Los resultados quedan en el registro y no se devuelven a {stage}',
'desk.dayDate': '{d}/{m}',
'desk.download': 'Descargar',
'desk.dropNoSlot': '{n} archivo(s) sin ranura: este flujo no tiene esa entrada',
'desk.dryRun': 'Simulación',
'desk.durMin': '{m} min {s} s',
'desk.durSec': '{s} s',
'desk.elapsed': 'Tiempo',
'desk.emptyFailed': 'No hay registros fallidos',
'desk.emptyFeed': 'Aún no hay registros. Escribe un prompt abajo y pulsa Generar.',
'desk.filterAria': 'Filtrar registros',
'desk.filterAll': 'Todo',
'desk.filterImage': 'Imágenes',
'desk.filterVideo': 'Vídeos',
'desk.filterFailed': 'Fallidos',
'desk.generate': 'Generar',
'desk.genHint': 'Generar (Ctrl/⌘ + Enter)',
'desk.inputs': 'Entradas',
'desk.inspector': 'Inspector',
'desk.keyClose': 'Cerrar',
'desk.keyInspector': 'Mostrar / ocultar',
'desk.keySelect': 'Elegir',
'desk.keyUse': 'Usar',
'desk.kvCreated': 'Enviado',
'desk.kvName': 'Nombre',
'desk.kvPromptId': 'ID de ComfyUI',
'desk.kvUsage': 'Uso',
'desk.loadEarlier': 'Mostrar {n} tomas anteriores',
'desk.missingSub': 'Rehacer con los mismos parámetros, o quitar esta toma',
'desk.missingTitle': 'Los archivos de resultado ya no están en el servidor',
'desk.needWorkflow': 'Falta: flujo',
'desk.noConn': 'Sin conexión',
'desk.noPromptInput': 'Este flujo no tiene prompt; se ejecuta con sus propios valores.',
'desk.notGenerated': 'No generado',
'desk.noWorkflows': 'Aún no hay flujos de imagen o vídeo listos',
'desk.openInDesk': 'Ajustar en el generador',
'desk.openInDeskHint': 'Abre el generador con este prompt y elige flujo y parámetros; los resultados quedan en el registro',
'desk.openInspector': 'Inspector',
'desk.origValue': 'Valor del flujo',
'desk.outputN': 'Resultado {n}',
'desk.outputs': 'Todos los resultados',
'desk.paramError': 'Parámetro no válido: {msg}',
'desk.paramsAll': 'Todos los parámetros',
'desk.paramsChanged': 'Parámetros · {n} cambiados',
'desk.paramsCommon': 'Parámetros comunes',
'desk.pickTake': 'Elige un registro para ver su resultado',
'desk.pickWorkflow': 'Elegir flujo',
'desk.promptOnly': 'Solo prompt',
'desk.promptPh': 'Describe la escena… (Ctrl/⌘ + Enter genera, / enfoca)',
'desk.restoreAll': 'Restaurar todos los valores del flujo',
'desk.redo': 'Rehacer con los mismos parámetros',
'desk.removeRecord': 'Quitar el registro',
'desk.restoreOrig': 'Restaurar original',
'desk.reuse': 'Reutilizar',
'desk.reuseToComposer': 'Reutilizar en el editor',
'desk.seedRandom': 'Aleatoria',
'desk.sendToSlot': 'Enviar a la ranura de imagen',
'desk.sent': 'Enviado',
'desk.sentToSlotToast': 'Enviado a la ranura de imagen del flujo actual',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': 'Tamaño',
'desk.stillNeed': 'Falta: {what}',
'desk.tabParams': 'Parámetros',
'desk.tabResult': 'Resultado',
'desk.today': 'Hoy',
'desk.took': 'Tardó {d}',
'desk.wfBroken': 'No disponible',
'desk.wfCurrent': 'actual',
'desk.wfDefaultConn': 'Conexión predeterminada',
'desk.wfImport': 'Importar / gestionar flujos',
'desk.wfLastFailed': 'Falló la última vez',
'desk.wfNoMatch': 'Ningún flujo coincide',
'desk.wfOk': 'Listo',
'desk.wfSearchPh': 'Busca flujos o un uso: imagen a vídeo, referencia…',
'desk.wfTesting': 'Prueba en curso',
'desk.wfUntested': 'Sin probar',
'desk.workflow': 'Flujo',
'desk.yesterday': 'Ayer',
'wfd.stepParse': 'Analizar JSON',
'wfd.stepNodes': 'Identificar nodos',
'wfd.stepModels': 'Verificar modelos',
'wfd.stepSlots': 'Crear slots de entrada',
'wfd.noOutput': 'El flujo no tiene nodos de salida; no se puede importar',
'wfd.nodesAutoOk': 'Uso detectado automáticamente: {usage}',
'wfd.nodesUserOk': 'Uso confirmado: {usage}',
'wfd.modelsOk': '«{name}» tiene todos los nodos y modelos necesarios',
'wfd.slotsOk': '{n} slot(s) de entrada creados ({m} de medios)',
'wfd.checksAria': 'Resultados de la comprobación de importación',
'wfd.showDetails': 'Mostrar detalles del error',
'wfd.hideDetails': 'Ocultar detalles del error',
'wfd.unreachableSuggest': 'No poder verificar no impide guardar; vuelve a comprobar tras arreglar la conexión.',
'wfd.nodesPickSuggest': 'Elige el uso y completa el mapeo de salida abajo antes de guardar.',
'wfd.showRaw': 'Ver JSON original',
'wfd.hideRaw': 'Ocultar JSON original',
};
+180 -7
View File
@@ -64,6 +64,7 @@ export const fr: Dict = {
// --- Toast ---
'toast.dismiss': 'Ignorer',
'toast.undo': 'Annuler',
'toast.uploadFailed': "Échec de l'import",
// --- Modal ---
@@ -220,6 +221,25 @@ export const fr: Dict = {
'lightbox.videoPreview': 'Aperçu de la vidéo',
'lightbox.videoUnavailable': 'Vidéo indisponible',
'lightbox.zoom100': '100 %',
'lastFrame.export': 'Exporter la dernière image',
'lastFrame.exporting': 'Export de la dernière image…',
'lastFrame.hint': 'Enregistre la dernière image en PNG pleine taille, p. ex. comme référence de première image',
'lastFrame.fileTag': 'derniere-image',
'lastFrame.done': 'Dernière image exportée {name} ({size})',
'lastFrame.errTimeout': 'Le chargement de la vidéo a expiré. Réessayez plus tard.',
'lastFrame.errNotFound': 'Le fichier vidéo est introuvable ou a été supprimé.',
'lastFrame.errForbidden': "Vous n'avez pas l'autorisation de lire cette vidéo.",
'lastFrame.errNetwork': 'Lecture de la vidéo impossible (erreur réseau).',
'lastFrame.errDecode': 'La vidéo est endommagée ; sa fin ne peut pas être décodée.',
'lastFrame.errUnsupported': 'Ce navigateur ne prend pas en charge ce format vidéo, ou le fichier est endommagé.',
'lastFrame.errCrossOrigin':
"La vidéo provient d'un autre site qui bloque la lecture des images (cross-origin). Téléchargez d'abord la vidéo.",
'lastFrame.errNoPicture': "Ce fichier ne contient pas d'image vidéo.",
'lastFrame.errUnseekable':
"Impossible d'atteindre la fin de la vidéo (durée inconnue ou source sans recherche) ; aucune image exportée.",
'lastFrame.errBlack': 'La dernière seconde de la vidéo est noire ; aucune image exportée.',
'lastFrame.errEmpty': "Le navigateur n'a pas pu décoder la fin. Réessayez ou utilisez Chrome / Edge sur ordinateur.",
'lastFrame.errUnknown': "Échec de l'export de la dernière image : {msg}",
// --- Artifact / canvas nodes ---
'artifact.artifactLabel': 'Artefact',
@@ -948,6 +968,8 @@ export const fr: Dict = {
'library.selectAll': 'Tout sélectionner',
'library.deselectAll': 'Tout désélectionner',
'library.itemCount': '{count} élément(s)',
'library.loadError': 'Échec du chargement de la bibliothèque',
'library.subtitle': 'Les ressources générées au même endroit : prévisualisez, filtrez et supprimez les images et vidéos sans projet',
'library.deleteSelected': 'Supprimer ({count})',
'library.addToProject': 'Ajouter au projet',
'library.filterLabel': 'Filtrer par type',
@@ -1259,15 +1281,46 @@ export const fr: Dict = {
'shell.navAgents': 'Agents',
'shell.navConnections': 'Connexions ComfyUI',
'shell.navModels': 'Modèles',
'shell.navLibrary': 'Bibliothèque d’assets',
'shell.navSettings': 'Réglages',
'shell.wip': 'En développement',
'shell.wipHint': 'Cette page est en construction et arrivera plus tard',
'shell.comfy_ok': '{name} connecté',
'shell.comfy_fail': '{name} injoignable',
'shell.comfy_testing': 'Test de {name}…',
'shell.comfyUnset': 'ComfyUI · non configuré',
'shell.comfyManage': 'Configurez l\'adresse ComfyUI dans les Réglages',
'shell.mobileNotice': 'Ouvrez cet atelier dans un navigateur de bureau (largeur minimum 768 px) ; les téléphones ne sont pas encore pris en charge.',
'shell.scopeHint': 'Changer de projet',
'shell.scopeAll': 'Tous les projets',
'shell.scopeAllList': 'Liste des projets',
'shell.scopeRecent': 'Projets récents',
'shell.scopeLoadFailed': 'Impossible de charger les projets',
'shell.scopeEmpty': 'Aucun projet — en créer un',
'shell.scopeStage': '{n}/5 validées',
'shell.queueRunning': '{n} en cours',
'shell.queueQueued': '{n} en file',
'shell.queueIdle': 'File libre',
'shell.queueActive': '{n} actifs',
'shell.trayTitle': 'Plateau des tâches',
'shell.trayRecent': 'Tâches récentes · cliquer pour tout voir',
'shell.trayEmpty': 'Aucune tâche de génération',
'shell.trayAll': 'Voir toutes les tâches',
'shell.searchTitle': 'Rechercher pages et projets',
'shell.searchPlaceholder': 'Rechercher pages, projets',
'shell.searchShortcut': 'Rechercher (Ctrl/⌘ + K)',
'shell.searchEmpty': 'Aucune page ni projet ne correspond à « {q} »',
'shell.searchGroupPages': 'Pages',
'shell.searchGroupProjects': 'Projets · ouvrir l’atelier',
'shell.searchHintMove': 'choisir',
'shell.searchHintOpen': 'ouvrir',
'shell.searchHintClose': 'fermer',
'shell.reduceMotion': 'Réduire les animations',
'shell.reduceHint': 'Coupe déplacements, zooms et halos ; garde seulement de courts fondus',
'shell.reduceSystem': 'Réglage système « Réduire les animations » appliqué',
'shell.tabAria': 'Navigation principale (étroite)',
'shell.tabProjects': 'Projets',
'shell.tabCreate': 'Créer',
'shell.tabGenerate': 'Générer',
'shell.tabMore': 'Plus',
'shell.morePages': 'Autres pages',
'shell.moreTools': 'Outils',
'home.title': 'Aperçu des projets',
'home.subtitle': 'Gérez vos projets — commencez avec une idée ou un scénario prêt.',
'home.projectCount': '{n} projets · {m} en cours',
@@ -1456,8 +1509,6 @@ export const fr: Dict = {
'studio.genParams': 'Paramètres',
'studio.modeTextToImage': 'Texte vers image',
'studio.modeTextToVideo': 'Texte vers vidéo',
'studio.chipIdle': '{name} · file libre',
'studio.chipBusy': '{name} · {n} en file',
'studio.video.resultsTitle': 'Aperçu',
'studio.image.historyTitle': 'Historique images',
'studio.video.historyTitle': 'Historique vidéos',
@@ -1507,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',
@@ -1581,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',
@@ -1600,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',
@@ -1616,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',
@@ -1841,12 +1902,14 @@ export const fr: Dict = {
'home.stageProgress': 'Progression en cinq étapes',
'tasks.sourceWorkbench': 'Flux de création',
'jobst.idSuffix': ' · ID de prompt ComfyUI {id}',
'jobst.done': 'Terminé',
'jobst.done': 'Fixé',
'jobst.doneDetail': 'ComfyUI a terminé, résultats enregistrés',
'jobst.dryRun': 'Substitut',
'jobst.dryRunDetail': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
'jobst.noOutput': 'Aucune sortie',
'jobst.noOutputDetail': 'La tâche s\'est terminée sans fichier de résultat : pas une génération',
'jobst.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',
@@ -1924,4 +1987,114 @@ export const fr: Dict = {
'wft.errListMin': '« {label} » exige au moins {n} {unit}',
'tasks.testBadge': 'Test',
'tasks.sourceWorkflowTest': 'Test de flux',
// --- 生成台 (HEL-653) ---
'cdf.orig': 'Original {v}',
'desk.title': 'Générer',
'desk.advanced': 'Avancé · {n} · inchangés, les valeurs du workflow s’appliquent',
'desk.chipResolution': 'Résolution {v}',
'desk.chipSeconds': '{v} s',
'desk.chipSeed': 'Graine {v}',
'desk.closeInspector': 'Fermer l’inspecteur',
'desk.countActive': '{n} en cours',
'desk.countTakes': '{n} prises',
'desk.ctxAsset': '{kind} « {name} »',
'desk.ctxShot': 'Plan {no}',
'desk.ctxBack': 'Retour à {stage}',
'desk.ctxDismiss': 'Fermer la note d’origine',
'desk.ctxFrom': 'Depuis « {project} »',
'desk.ctxNoBackfill': 'Les résultats restent dans le fil et ne sont pas renvoyés vers {stage}',
'desk.dayDate': '{d}/{m}',
'desk.download': 'Télécharger',
'desk.dropNoSlot': '{n} fichier(s) sans emplacement : ce workflow n’a pas cette entrée',
'desk.dryRun': 'Simulation',
'desk.durMin': '{m} min {s} s',
'desk.durSec': '{s} s',
'desk.elapsed': 'Durée',
'desk.emptyFailed': 'Aucune prise échouée',
'desk.emptyFeed': 'Aucune prise. Écrivez un prompt ci-dessous et cliquez sur Générer.',
'desk.filterAria': 'Filtrer les prises',
'desk.filterAll': 'Tout',
'desk.filterImage': 'Images',
'desk.filterVideo': 'Vidéos',
'desk.filterFailed': 'Échecs',
'desk.generate': 'Générer',
'desk.genHint': 'Générer (Ctrl/⌘ + Entrée)',
'desk.inputs': 'Entrées',
'desk.inspector': 'Inspecteur',
'desk.keyClose': 'Fermer',
'desk.keyInspector': 'Afficher / masquer',
'desk.keySelect': 'Choisir',
'desk.keyUse': 'Utiliser',
'desk.kvCreated': 'Soumis',
'desk.kvName': 'Nom',
'desk.kvPromptId': 'ID ComfyUI',
'desk.kvUsage': 'Usage',
'desk.loadEarlier': 'Afficher {n} prises plus anciennes',
'desk.missingSub': 'Refaire avec les mêmes paramètres, ou retirer cette prise',
'desk.missingTitle': 'Les fichiers de résultat ne sont plus sur le serveur',
'desk.needWorkflow': 'Manque : workflow',
'desk.noConn': 'Non connecté',
'desk.noPromptInput': 'Ce workflow n’a pas de prompt ; il s’exécute avec ses propres valeurs.',
'desk.notGenerated': 'Non généré',
'desk.noWorkflows': 'Aucun workflow image ou vidéo prêt',
'desk.openInDesk': 'Régler dans le générateur',
'desk.openInDeskHint': 'Ouvre le générateur avec ce prompt ; choisissez workflow et paramètres, les résultats restent dans le fil',
'desk.openInspector': 'Inspecteur',
'desk.origValue': 'Valeur du workflow',
'desk.outputN': 'Résultat {n}',
'desk.outputs': 'Tous les résultats',
'desk.paramError': 'Paramètre invalide : {msg}',
'desk.paramsAll': 'Tous les paramètres',
'desk.paramsChanged': 'Paramètres · {n} modifiés',
'desk.paramsCommon': 'Paramètres courants',
'desk.pickTake': 'Choisissez une prise pour voir son résultat',
'desk.pickWorkflow': 'Choisir un workflow',
'desk.promptOnly': 'Prompt seul',
'desk.promptPh': 'Décrivez l’image… (Ctrl/⌘ + Entrée pour générer, / pour cibler)',
'desk.restoreAll': 'Rétablir toutes les valeurs du workflow',
'desk.redo': 'Refaire avec les mêmes paramètres',
'desk.removeRecord': 'Retirer la ligne',
'desk.restoreOrig': 'Rétablir l’original',
'desk.reuse': 'Réutiliser',
'desk.reuseToComposer': 'Réutiliser dans l’éditeur',
'desk.seedRandom': 'Aléatoire',
'desk.sendToSlot': 'Envoyer vers l’emplacement image',
'desk.sent': 'Soumis',
'desk.sentToSlotToast': 'Envoyé vers l’emplacement image du workflow actuel',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': 'Taille',
'desk.stillNeed': 'Manque : {what}',
'desk.tabParams': 'Paramètres',
'desk.tabResult': 'Résultat',
'desk.today': 'Aujourd’hui',
'desk.took': 'Durée {d}',
'desk.wfBroken': 'Indisponible',
'desk.wfCurrent': 'actuel',
'desk.wfDefaultConn': 'Connexion par défaut',
'desk.wfImport': 'Importer / gérer les workflows',
'desk.wfLastFailed': 'Dernier essai échoué',
'desk.wfNoMatch': 'Aucun workflow correspondant',
'desk.wfOk': 'Prêt',
'desk.wfSearchPh': 'Chercher un workflow ou un usage : image vers vidéo, référence…',
'desk.wfTesting': 'Test en cours',
'desk.wfUntested': 'Non testé',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Hier',
'wfd.stepParse': 'Analyser le JSON',
'wfd.stepNodes': 'Identifier les nœuds',
'wfd.stepModels': 'Vérifier les modèles',
'wfd.stepSlots': 'Créer les slots d’entrée',
'wfd.noOutput': "Le flux n'a aucun nœud de sortie — import impossible",
'wfd.nodesAutoOk': 'Usage détecté automatiquement : {usage}',
'wfd.nodesUserOk': 'Usage confirmé : {usage}',
'wfd.modelsOk': '« {name} » possède tous les nœuds et modèles requis',
'wfd.slotsOk': "{n} slot(s) d'entrée créés ({m} média)",
'wfd.checksAria': "Résultats de la vérification d'import",
'wfd.showDetails': "Afficher les détails de l'erreur",
'wfd.hideDetails': "Masquer les détails de l'erreur",
'wfd.unreachableSuggest': "L'impossibilité de vérifier n'empêche pas l'enregistrement ; revérifiez après avoir réparé la connexion.",
'wfd.nodesPickSuggest': "Choisissez l'usage et terminez le mappage des sorties ci-dessous avant d'enregistrer.",
'wfd.showRaw': 'Voir le JSON brut',
'wfd.hideRaw': 'Masquer le JSON brut',
};
+178 -7
View File
@@ -64,6 +64,7 @@ export const ja: Dict = {
// --- Toast ---
'toast.dismiss': '閉じる',
'toast.undo': '元に戻す',
'toast.uploadFailed': 'アップロードに失敗しました',
// --- Modal ---
@@ -220,6 +221,23 @@ export const ja: Dict = {
'lightbox.videoPreview': 'ビデオプレビュー',
'lightbox.videoUnavailable': 'ビデオを再生できません',
'lightbox.zoom100': '100%',
'lastFrame.export': '最終フレームを書き出す',
'lastFrame.exporting': '最終フレームを書き出し中…',
'lastFrame.hint': '動画の最後のフレームを元のサイズの PNG で保存します(先頭フレーム参照用)',
'lastFrame.fileTag': '最終フレーム',
'lastFrame.done': '最終フレームを書き出しました {name}({size})',
'lastFrame.errTimeout': '動画の読み込みがタイムアウトしました。後でもう一度お試しください。',
'lastFrame.errNotFound': '動画ファイルが存在しないか削除されています。',
'lastFrame.errForbidden': 'この動画へのアクセス権限がありません。',
'lastFrame.errNetwork': '動画の読み込みに失敗しました(ネットワークエラー)。',
'lastFrame.errDecode': '動画が破損しており、末尾をデコードできません。',
'lastFrame.errUnsupported': 'このブラウザーは動画形式に対応していないか、ファイルが破損しています。',
'lastFrame.errCrossOrigin': '他サイトの動画のためフレームを読み取れません(クロスオリジン制限)。先に動画をダウンロードしてください。',
'lastFrame.errNoPicture': 'このファイルには映像がありません。',
'lastFrame.errUnseekable': '動画の末尾に移動できません(長さ不明、またはシーク非対応)。書き出しませんでした。',
'lastFrame.errBlack': '動画の最後の 1 秒が黒画面のため、書き出しませんでした。',
'lastFrame.errEmpty': '末尾をデコードできませんでした。再試行するか、PC の Chrome / Edge をお使いください。',
'lastFrame.errUnknown': '最終フレームの書き出しに失敗しました:{msg}',
// --- Artifact / canvas nodes ---
'artifact.artifactLabel': '成果物',
@@ -948,6 +966,8 @@ export const ja: Dict = {
'library.selectAll': 'すべて選択',
'library.deselectAll': '選択を解除',
'library.itemCount': '{count} 件',
'library.loadError': 'ライブラリの読み込みに失敗しました',
'library.subtitle': '生成素材を一元管理——プロジェクトに紐付いていない画像・動画をプレビュー・絞り込み・削除できます',
'library.deleteSelected': '削除({count})',
'library.addToProject': 'プロジェクトに追加',
'library.filterLabel': '種類で絞り込み',
@@ -1259,15 +1279,46 @@ export const ja: Dict = {
'shell.navAgents': 'エージェント',
'shell.navConnections': 'ComfyUI 接続',
'shell.navModels': 'モデル設定',
'shell.navLibrary': 'アセットライブラリ',
'shell.navSettings': '設定',
'shell.wip': '開発中',
'shell.wipHint': 'このページは準備中です。後ほど公開されます',
'shell.comfy_ok': '{name} 接続済み',
'shell.comfy_fail': '{name} 接続失敗',
'shell.comfy_testing': '{name} テスト中…',
'shell.comfyUnset': 'ComfyUI · 未設定',
'shell.comfyManage': '「設定」で ComfyUI のアドレスを設定できます',
'shell.mobileNotice': 'このワークベンチはデスクトップブラウザ(幅 768px 以上)で開いてください。スマートフォンには未対応です。',
'shell.scopeHint': 'プロジェクト切替',
'shell.scopeAll': 'すべてのプロジェクト',
'shell.scopeAllList': 'プロジェクト一覧',
'shell.scopeRecent': '最近のプロジェクト',
'shell.scopeLoadFailed': 'プロジェクトを読み込めませんでした',
'shell.scopeEmpty': 'プロジェクトはまだありません — 作成する',
'shell.scopeStage': '{n}/5 確定',
'shell.queueRunning': '{n} 件生成中',
'shell.queueQueued': '{n} 件待機',
'shell.queueIdle': 'キューは空です',
'shell.queueActive': '{n} 件進行中',
'shell.trayTitle': 'タスクトレイ',
'shell.trayRecent': '最近のタスク · クリックで全タスク',
'shell.trayEmpty': '生成タスクはまだありません',
'shell.trayAll': 'すべてのタスクを見る',
'shell.searchTitle': 'ページとプロジェクトを検索',
'shell.searchPlaceholder': 'ページ・プロジェクトを検索',
'shell.searchShortcut': '検索(Ctrl/⌘ + K)',
'shell.searchEmpty': '「{q}」に一致するページやプロジェクトはありません',
'shell.searchGroupPages': 'ページ',
'shell.searchGroupProjects': 'プロジェクト · ワークベンチを開く',
'shell.searchHintMove': '選択',
'shell.searchHintOpen': '開く',
'shell.searchHintClose': '閉じる',
'shell.reduceMotion': '動きを減らす',
'shell.reduceHint': '移動・拡大・発光アニメーションを止め、短いフェードのみ残します',
'shell.reduceSystem': 'システムの「視差効果を減らす」設定に従っています',
'shell.tabAria': 'メインナビ(狭い画面)',
'shell.tabProjects': 'プロジェクト',
'shell.tabCreate': '創作',
'shell.tabGenerate': '生成',
'shell.tabMore': 'その他',
'shell.morePages': 'その他のページ',
'shell.moreTools': 'ツール',
'home.title': 'プロジェクト概要',
'home.subtitle': '創作プロジェクトを管理 — アイデアでも完成台本でも始められます。',
'home.projectCount': 'プロジェクト {n} 件 · 進行中 {m} 件',
@@ -1456,8 +1507,6 @@ export const ja: Dict = {
'studio.genParams': '生成パラメータ',
'studio.modeTextToImage': 'テキストから画像',
'studio.modeTextToVideo': 'テキストから動画',
'studio.chipIdle': '{name} · キュー空き',
'studio.chipBusy': '{name} · キュー {n} 件',
'studio.video.resultsTitle': 'プレビュー',
'studio.image.historyTitle': '画像生成履歴',
'studio.video.historyTitle': '動画生成履歴',
@@ -1507,6 +1556,7 @@ export const ja: Dict = {
'studio.slotMask': 'マスク',
'studio.slotFirst': '始端フレーム',
'studio.slotLast': '終端フレーム',
'studio.slotOptional': '任意',
'studio.slotRefVideo': '参照動画',
'studio.slotAudio': '参照音声',
'studio.slotPick': 'クリックまたはドロップ',
@@ -1581,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 接続」で取り込む',
@@ -1600,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': '独立動画',
@@ -1616,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': 'エージェント',
@@ -1841,12 +1900,14 @@ export const ja: Dict = {
'home.stageProgress': '五段階の進捗',
'tasks.sourceWorkbench': '制作フロー',
'jobst.idSuffix': ' · ComfyUI プロンプト ID {id}',
'jobst.done': '完了',
'jobst.done': '定着',
'jobst.doneDetail': 'ComfyUI の実行が完了し、結果を保存しました',
'jobst.dryRun': 'プレースホルダー',
'jobst.dryRunDetail': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
'jobst.noOutput': '出力なし',
'jobst.noOutputDetail': '結果ファイルなしで終了しました(生成完了ではありません)',
'jobst.missing': 'ファイル欠落',
'jobst.missingDetail': 'タスクは成功しましたが、結果ファイルはサーバー上にありません',
'jobst.queued': '待機中',
'jobst.queuedDetail': 'ワークベンチのキューで ComfyUI への送信を待っています',
'jobst.connecting': '接続中',
@@ -1924,4 +1985,114 @@ export const ja: Dict = {
'wft.errListMin': '「{label}」は最低 {n} {unit} 必要です',
'tasks.testBadge': 'テスト',
'tasks.sourceWorkflowTest': 'ワークフローテスト',
// --- 生成台 (HEL-653) ---
'cdf.orig': '元の値 {v}',
'desk.title': '生成台',
'desk.advanced': '詳細パラメータ {n} 項目 · 変更しなければワークフローの値を使用',
'desk.chipResolution': '解像度 {v}',
'desk.chipSeconds': '{v} 秒',
'desk.chipSeed': 'シード {v}',
'desk.closeInspector': 'インスペクターを閉じる',
'desk.countActive': '{n} 件実行中',
'desk.countTakes': '{n} 件の記録',
'desk.ctxAsset': '{kind}「{name}」',
'desk.ctxShot': 'ショット {no}',
'desk.ctxBack': '{stage}に戻る',
'desk.ctxDismiss': '出典の表示を閉じる',
'desk.ctxFrom': '『{project}』から',
'desk.ctxNoBackfill': '結果は生成台の記録にのみ残り、{stage}へ自動で戻りません',
'desk.dayDate': '{m}月{d}日',
'desk.download': 'ダウンロード',
'desk.dropNoSlot': '{n} 件のファイルを入れる枠がありません:このワークフローにはその入力がありません',
'desk.dryRun': 'ドライラン',
'desk.durMin': '{m} 分 {s} 秒',
'desk.durSec': '{s} 秒',
'desk.elapsed': '所要時間',
'desk.emptyFailed': '失敗した記録はありません',
'desk.emptyFeed': 'まだ記録がありません。下にプロンプトを書いて「生成」を押してください。',
'desk.filterAria': '記録を絞り込む',
'desk.filterAll': 'すべて',
'desk.filterImage': '画像',
'desk.filterVideo': '動画',
'desk.filterFailed': '失敗',
'desk.generate': '生成',
'desk.genHint': '生成(Ctrl/⌘ + Enter)',
'desk.inputs': '入力',
'desk.inspector': 'インスペクター',
'desk.keyClose': '閉じる',
'desk.keyInspector': '表示 / 非表示',
'desk.keySelect': '選択',
'desk.keyUse': '使用',
'desk.kvCreated': '送信時刻',
'desk.kvName': '名前',
'desk.kvPromptId': 'ComfyUI タスク ID',
'desk.kvUsage': '用途',
'desk.loadEarlier': '過去の {n} 件を表示',
'desk.missingSub': '同じパラメータでやり直すか、この記録を削除できます',
'desk.missingTitle': '結果ファイルはサーバー上にありません',
'desk.needWorkflow': '不足:ワークフロー',
'desk.noConn': '未接続',
'desk.noPromptInput': 'このワークフローにはプロンプト入力がなく、元の値で生成します。',
'desk.notGenerated': '未生成',
'desk.noWorkflows': '使える画像 / 動画ワークフローがまだありません',
'desk.openInDesk': '生成台で調整',
'desk.openInDeskHint': 'このプロンプトで生成台を開き、ワークフローとパラメータを選びます。結果は生成台の記録に残ります',
'desk.openInspector': 'インスペクター',
'desk.origValue': 'ワークフローの値',
'desk.outputN': '結果 {n}',
'desk.outputs': 'すべての結果',
'desk.paramError': 'パラメータが不正です:{msg}',
'desk.paramsAll': 'すべてのパラメータ',
'desk.paramsChanged': 'パラメータ · {n} 項目変更',
'desk.paramsCommon': 'よく使うパラメータ',
'desk.pickTake': '記録を選ぶと結果を表示します',
'desk.pickWorkflow': 'ワークフローを選択',
'desk.promptOnly': 'プロンプトのみ',
'desk.promptPh': '欲しい画面を説明…(Ctrl/⌘ + Enter で生成、/ でフォーカス)',
'desk.restoreAll': 'すべてワークフローの値に戻す',
'desk.redo': '同じパラメータでやり直す',
'desk.removeRecord': '記録を削除',
'desk.restoreOrig': '元に戻す',
'desk.reuse': '再利用',
'desk.reuseToComposer': '入力台で再利用',
'desk.seedRandom': 'ランダム',
'desk.sendToSlot': '現在の画像枠へ送る',
'desk.sent': '送信済み',
'desk.sentToSlotToast': '現在のワークフローの画像枠へ送りました',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': 'サイズ',
'desk.stillNeed': '不足:{what}',
'desk.tabParams': 'パラメータ',
'desk.tabResult': '結果',
'desk.today': '今日',
'desk.took': '所要 {d}',
'desk.wfBroken': '使用不可',
'desk.wfCurrent': '現在',
'desk.wfDefaultConn': '既定の接続に従う',
'desk.wfImport': 'ワークフローを取り込む / 管理',
'desk.wfLastFailed': '前回失敗',
'desk.wfNoMatch': '一致するワークフローがありません',
'desk.wfOk': '使用可',
'desk.wfSearchPh': 'ワークフローや用途を検索:画像から動画、参照画像…',
'desk.wfTesting': 'テスト実行中',
'desk.wfUntested': '未テスト',
'desk.workflow': 'ワークフロー',
'desk.yesterday': '昨日',
'wfd.stepParse': 'JSON を解析',
'wfd.stepNodes': 'ノードを識別',
'wfd.stepModels': 'モデルを確認',
'wfd.stepSlots': '入力スロットを構築',
'wfd.noOutput': '出力ノードがないためインポートできません',
'wfd.nodesAutoOk': '用途を自動判定:{usage}',
'wfd.nodesUserOk': '用途を確認済み:{usage}',
'wfd.modelsOk': '「{name}」に必要なノードとモデルが揃っています',
'wfd.slotsOk': '入力スロット {n} 件を構築(うちメディア {m} 件)',
'wfd.checksAria': 'インポート検査結果',
'wfd.showDetails': 'エラー詳細を展開',
'wfd.hideDetails': 'エラー詳細を折りたたむ',
'wfd.unreachableSuggest': '検査できなくても保存は可能です。接続を直して「再チェック」してください。',
'wfd.nodesPickSuggest': '保存前に下で用途を選び、出力マッピングを完了してください。',
'wfd.showRaw': '元の JSON を見る',
'wfd.hideRaw': '元の JSON を閉じる',
};
+178 -7
View File
@@ -64,6 +64,7 @@ export const ko: Dict = {
// --- Toast ---
'toast.dismiss': '닫기',
'toast.undo': '실행 취소',
'toast.uploadFailed': '업로드 실패',
// --- Modal ---
@@ -220,6 +221,23 @@ export const ko: Dict = {
'lightbox.videoPreview': '영상 미리보기',
'lightbox.videoUnavailable': '영상을 표시할 수 없습니다',
'lightbox.zoom100': '100%',
'lastFrame.export': '마지막 프레임 내보내기',
'lastFrame.exporting': '마지막 프레임 내보내는 중…',
'lastFrame.hint': '영상의 마지막 프레임을 원본 크기 PNG로 저장합니다(첫 프레임 참조용)',
'lastFrame.fileTag': '마지막프레임',
'lastFrame.done': '마지막 프레임을 내보냈습니다 {name} ({size})',
'lastFrame.errTimeout': '영상 로딩 시간이 초과되었습니다. 잠시 후 다시 시도하세요.',
'lastFrame.errNotFound': '영상 파일이 없거나 삭제되었습니다.',
'lastFrame.errForbidden': '이 영상에 접근할 권한이 없습니다.',
'lastFrame.errNetwork': '영상을 읽지 못했습니다(네트워크 오류).',
'lastFrame.errDecode': '영상이 손상되어 끝부분을 디코딩할 수 없습니다.',
'lastFrame.errUnsupported': '이 브라우저가 영상 형식을 지원하지 않거나 파일이 손상되었습니다.',
'lastFrame.errCrossOrigin': '다른 사이트의 영상이라 프레임을 읽을 수 없습니다(교차 출처 제한). 먼저 영상을 다운로드하세요.',
'lastFrame.errNoPicture': '이 파일에는 영상 화면이 없습니다.',
'lastFrame.errUnseekable': '영상 끝으로 이동할 수 없습니다(길이 불명 또는 탐색 미지원). 내보내지 않았습니다.',
'lastFrame.errBlack': '영상의 마지막 1초가 검은 화면이라 내보내지 않았습니다.',
'lastFrame.errEmpty': '끝부분을 디코딩하지 못했습니다. 다시 시도하거나 PC Chrome / Edge를 사용하세요.',
'lastFrame.errUnknown': '마지막 프레임 내보내기 실패: {msg}',
// --- Artifact / canvas nodes ---
'artifact.artifactLabel': '아티팩트',
@@ -948,6 +966,8 @@ export const ko: Dict = {
'library.selectAll': '모두 선택',
'library.deselectAll': '선택 해제',
'library.itemCount': '{count}개 항목',
'library.loadError': '라이브러리를 불러오지 못했습니다',
'library.subtitle': '생성 자산을 한곳에서 관리——프로젝트에 연결되지 않은 이미지와 동영상을 미리보기·필터·삭제',
'library.deleteSelected': '삭제 ({count})',
'library.addToProject': '프로젝트에 추가',
'library.filterLabel': '종류로 필터',
@@ -1259,15 +1279,46 @@ export const ko: Dict = {
'shell.navAgents': '에이전트',
'shell.navConnections': 'ComfyUI 연결',
'shell.navModels': '모델 설정',
'shell.navLibrary': '애셋 라이브러리',
'shell.navSettings': '설정',
'shell.wip': '개발 중',
'shell.wipHint': '이 페이지는 준비 중이며 곧 열립니다',
'shell.comfy_ok': '{name} 연결됨',
'shell.comfy_fail': '{name} 연결 실패',
'shell.comfy_testing': '{name} 테스트 중…',
'shell.comfyUnset': 'ComfyUI · 미설정',
'shell.comfyManage': '「설정」에서 ComfyUI 주소를 설정하세요',
'shell.mobileNotice': '이 워크벤치는 데스크톱 브라우저(폭 768px 이상)에서 열어 주세요. 휴대폰은 아직 지원되지 않습니다.',
'shell.scopeHint': '프로젝트 전환',
'shell.scopeAll': '전체 프로젝트',
'shell.scopeAllList': '프로젝트 목록',
'shell.scopeRecent': '최근 프로젝트',
'shell.scopeLoadFailed': '프로젝트를 불러오지 못했습니다',
'shell.scopeEmpty': '아직 프로젝트가 없습니다 — 만들기',
'shell.scopeStage': '{n}/5 확정',
'shell.queueRunning': '{n}개 생성 중',
'shell.queueQueued': '{n}개 대기',
'shell.queueIdle': '대기열 비어 있음',
'shell.queueActive': '{n}개 진행 중',
'shell.trayTitle': '작업 트레이',
'shell.trayRecent': '최근 작업 · 클릭하면 전체 작업',
'shell.trayEmpty': '아직 생성 작업이 없습니다',
'shell.trayAll': '전체 작업 보기',
'shell.searchTitle': '페이지와 프로젝트 검색',
'shell.searchPlaceholder': '페이지, 프로젝트 검색',
'shell.searchShortcut': '검색 (Ctrl/⌘ + K)',
'shell.searchEmpty': '‘{q}’와 일치하는 페이지나 프로젝트가 없습니다',
'shell.searchGroupPages': '페이지',
'shell.searchGroupProjects': '프로젝트 · 워크벤치 열기',
'shell.searchHintMove': '선택',
'shell.searchHintOpen': '열기',
'shell.searchHintClose': '닫기',
'shell.reduceMotion': '동작 줄이기',
'shell.reduceHint': '이동·확대·발광 애니메이션을 끄고 짧은 페이드만 남깁니다',
'shell.reduceSystem': '시스템의 ‘동작 줄이기’ 설정을 따르는 중',
'shell.tabAria': '주 탐색 (좁은 화면)',
'shell.tabProjects': '프로젝트',
'shell.tabCreate': '창작',
'shell.tabGenerate': '생성',
'shell.tabMore': '더보기',
'shell.morePages': '더 많은 페이지',
'shell.moreTools': '도구',
'home.title': '프로젝트 개요',
'home.subtitle': '창작 프로젝트를 관리하세요 — 아이디어나 완성된 대본으로 시작할 수 있습니다.',
'home.projectCount': '프로젝트 {n}개 · 진행 중 {m}개',
@@ -1456,8 +1507,6 @@ export const ko: Dict = {
'studio.genParams': '생성 매개변수',
'studio.modeTextToImage': '텍스트→이미지',
'studio.modeTextToVideo': '텍스트→영상',
'studio.chipIdle': '{name} · 대기열 비어 있음',
'studio.chipBusy': '{name} · 대기열 {n}건',
'studio.video.resultsTitle': '미리보기',
'studio.image.historyTitle': '이미지 생성 기록',
'studio.video.historyTitle': '영상 생성 기록',
@@ -1507,6 +1556,7 @@ export const ko: Dict = {
'studio.slotMask': '마스크',
'studio.slotFirst': '첫 프레임',
'studio.slotLast': '마지막 프레임',
'studio.slotOptional': '선택',
'studio.slotRefVideo': '참조 비디오',
'studio.slotAudio': '참조 오디오',
'studio.slotPick': '클릭하거나 끌어다 놓기',
@@ -1581,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 연결에서 가져오기',
@@ -1600,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': '독립 비디오',
@@ -1616,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': '에이전트',
@@ -1841,12 +1900,14 @@ export const ko: Dict = {
'home.stageProgress': '5단계 진행',
'tasks.sourceWorkbench': '제작 흐름',
'jobst.idSuffix': ' · ComfyUI 프롬프트 ID {id}',
'jobst.done': '완료',
'jobst.done': '정착',
'jobst.doneDetail': 'ComfyUI 실행이 끝나 결과를 저장했습니다',
'jobst.dryRun': '자리표시자',
'jobst.dryRunDetail': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
'jobst.noOutput': '출력 없음',
'jobst.noOutputDetail': '결과 파일 없이 끝났습니다 — 생성 완료가 아닙니다',
'jobst.missing': '파일 없음',
'jobst.missingDetail': '작업은 성공했지만 결과 파일이 서버에 없습니다',
'jobst.queued': '대기 중',
'jobst.queuedDetail': '워크벤치 대기열에서 ComfyUI 전송을 기다리는 중',
'jobst.connecting': '연결 중',
@@ -1924,4 +1985,114 @@ export const ko: Dict = {
'wft.errListMin': '«{label}»에 최소 {n}{unit}이(가) 필요합니다',
'tasks.testBadge': '테스트',
'tasks.sourceWorkflowTest': '워크플로 테스트',
// --- 生成台 (HEL-653) ---
'cdf.orig': '원래 값 {v}',
'desk.title': '생성대',
'desk.advanced': '고급 매개변수 {n}개 · 바꾸지 않으면 워크플로 값을 사용',
'desk.chipResolution': '해상도 {v}',
'desk.chipSeconds': '{v}초',
'desk.chipSeed': '시드 {v}',
'desk.closeInspector': '인스펙터 닫기',
'desk.countActive': '{n}개 진행 중',
'desk.countTakes': '기록 {n}개',
'desk.ctxAsset': '{kind} 「{name}」',
'desk.ctxShot': '샷 {no}',
'desk.ctxBack': '{stage}(으)로 돌아가기',
'desk.ctxDismiss': '출처 안내 닫기',
'desk.ctxFrom': '《{project}》에서',
'desk.ctxNoBackfill': '결과는 생성대 기록에만 남고 {stage}에 자동으로 반영되지 않습니다',
'desk.dayDate': '{m}월 {d}일',
'desk.download': '다운로드',
'desk.dropNoSlot': '파일 {n}개를 넣을 슬롯이 없습니다: 이 워크플로에는 해당 입력이 없습니다',
'desk.dryRun': '드라이런',
'desk.durMin': '{m}분 {s}초',
'desk.durSec': '{s}초',
'desk.elapsed': '소요 시간',
'desk.emptyFailed': '실패한 기록이 없습니다',
'desk.emptyFeed': '아직 기록이 없습니다. 아래에 프롬프트를 쓰고 「생성」을 누르세요.',
'desk.filterAria': '기록 필터',
'desk.filterAll': '전체',
'desk.filterImage': '이미지',
'desk.filterVideo': '동영상',
'desk.filterFailed': '실패',
'desk.generate': '생성',
'desk.genHint': '생성 (Ctrl/⌘ + Enter)',
'desk.inputs': '입력',
'desk.inspector': '인스펙터',
'desk.keyClose': '닫기',
'desk.keyInspector': '표시 / 숨기기',
'desk.keySelect': '선택',
'desk.keyUse': '사용',
'desk.kvCreated': '제출 시각',
'desk.kvName': '이름',
'desk.kvPromptId': 'ComfyUI 작업 ID',
'desk.kvUsage': '용도',
'desk.loadEarlier': '이전 {n}개 표시',
'desk.missingSub': '같은 설정으로 다시 만들거나 이 기록을 제거할 수 있습니다',
'desk.missingTitle': '결과 파일이 서버에 없습니다',
'desk.needWorkflow': '부족: 워크플로',
'desk.noConn': '연결 안 됨',
'desk.noPromptInput': '이 워크플로에는 프롬프트 입력이 없어 원래 값으로 생성합니다.',
'desk.notGenerated': '생성 안 됨',
'desk.noWorkflows': '사용할 수 있는 이미지 / 동영상 워크플로가 아직 없습니다',
'desk.openInDesk': '생성대에서 조정',
'desk.openInDeskHint': '이 프롬프트로 생성대를 열고 워크플로와 매개변수를 직접 고릅니다. 결과는 생성대 기록에 남습니다',
'desk.openInspector': '인스펙터',
'desk.origValue': '워크플로 값',
'desk.outputN': '결과 {n}',
'desk.outputs': '모든 결과',
'desk.paramError': '잘못된 매개변수: {msg}',
'desk.paramsAll': '모든 매개변수',
'desk.paramsChanged': '매개변수 · {n}개 변경',
'desk.paramsCommon': '자주 쓰는 매개변수',
'desk.pickTake': '기록을 선택하면 결과가 표시됩니다',
'desk.pickWorkflow': '워크플로 선택',
'desk.promptOnly': '프롬프트만',
'desk.promptPh': '원하는 장면을 설명… (Ctrl/⌘ + Enter 생성, / 포커스)',
'desk.restoreAll': '모두 워크플로 값으로 되돌리기',
'desk.redo': '같은 설정으로 다시 생성',
'desk.removeRecord': '기록 제거',
'desk.restoreOrig': '원래 값으로',
'desk.reuse': '재사용',
'desk.reuseToComposer': '입력대에서 재사용',
'desk.seedRandom': '무작위',
'desk.sendToSlot': '현재 이미지 슬롯으로 보내기',
'desk.sent': '제출됨',
'desk.sentToSlotToast': '현재 워크플로의 이미지 슬롯으로 보냈습니다',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': '크기',
'desk.stillNeed': '부족: {what}',
'desk.tabParams': '매개변수',
'desk.tabResult': '결과',
'desk.today': '오늘',
'desk.took': '소요 {d}',
'desk.wfBroken': '사용 불가',
'desk.wfCurrent': '현재',
'desk.wfDefaultConn': '기본 연결 따름',
'desk.wfImport': '워크플로 가져오기 / 관리',
'desk.wfLastFailed': '지난번 실패',
'desk.wfNoMatch': '일치하는 워크플로가 없습니다',
'desk.wfOk': '사용 가능',
'desk.wfSearchPh': '워크플로나 용도 검색: 이미지→동영상, 참조 이미지…',
'desk.wfTesting': '테스트 실행 중',
'desk.wfUntested': '테스트 안 함',
'desk.workflow': '워크플로',
'desk.yesterday': '어제',
'wfd.stepParse': 'JSON 파싱',
'wfd.stepNodes': '노드 식별',
'wfd.stepModels': '모델 확인',
'wfd.stepSlots': '입력 슬롯 구성',
'wfd.noOutput': '출력 노드가 없어 가져올 수 없습니다',
'wfd.nodesAutoOk': '용도 자동 판정: {usage}',
'wfd.nodesUserOk': '용도 확인됨: {usage}',
'wfd.modelsOk': "'{name}'에 필요한 노드와 모델이 모두 있습니다",
'wfd.slotsOk': '입력 슬롯 {n}개 구성(미디어 {m}개)',
'wfd.checksAria': '가져오기 검사 결과',
'wfd.showDetails': '오류 자세히 보기',
'wfd.hideDetails': '오류 자세히 접기',
'wfd.unreachableSuggest': '확인 실패는 저장을 막지 않습니다. 연결을 고친 뒤 다시 검사하세요.',
'wfd.nodesPickSuggest': '저장하기 전에 아래에서 용도를 선택하고 출력 매핑을 완료하세요.',
'wfd.showRaw': '원본 JSON 보기',
'wfd.hideRaw': '원본 JSON 접기',
};
+179 -8
View File
@@ -64,6 +64,7 @@ export const zh: Dict = {
// --- Toast ---
'toast.dismiss': '关闭',
'toast.undo': '撤销',
'toast.uploadFailed': '上传失败',
// --- Modal ---
@@ -220,6 +221,23 @@ export const zh: Dict = {
'lightbox.videoPreview': '视频预览',
'lightbox.videoUnavailable': '视频不可用',
'lightbox.zoom100': '100%',
'lastFrame.export': '导出尾帧',
'lastFrame.exporting': '正在导出尾帧…',
'lastFrame.hint': '把视频最后一帧按原尺寸保存为 PNG,可作首帧参考',
'lastFrame.fileTag': '尾帧',
'lastFrame.done': '已导出尾帧 {name}({size})',
'lastFrame.errTimeout': '视频加载超时,未能导出尾帧,请稍后重试',
'lastFrame.errNotFound': '视频文件不存在或已被移除,无法导出尾帧',
'lastFrame.errForbidden': '没有访问该视频的权限,无法导出尾帧',
'lastFrame.errNetwork': '视频读取失败(网络中断),请检查连接后重试',
'lastFrame.errDecode': '视频文件已损坏,片尾画面无法解码',
'lastFrame.errUnsupported': '当前浏览器不支持该视频格式,或文件已损坏,无法导出尾帧',
'lastFrame.errCrossOrigin': '该视频来自其他站点,浏览器禁止读取画面(跨域限制),请先下载原视频',
'lastFrame.errNoPicture': '该文件没有视频画面,无法导出尾帧',
'lastFrame.errUnseekable': '无法定位到视频结尾(时长未知或来源不支持拖动进度),未导出尾帧',
'lastFrame.errBlack': '视频结尾 1 秒内都是黑屏,未导出尾帧',
'lastFrame.errEmpty': '浏览器未能解码出片尾画面,请重试或换用电脑端 Chrome / Edge',
'lastFrame.errUnknown': '导出尾帧失败:{msg}',
// --- Artifact / canvas nodes ---
'artifact.artifactLabel': '产物',
@@ -948,6 +966,8 @@ export const zh: Dict = {
'library.selectAll': '全选',
'library.deselectAll': '取消全选',
'library.itemCount': '{count} 个项目',
'library.loadError': '素材库加载失败',
'library.subtitle': '生成素材集中在这里——未关联项目的图片和视频可预览、筛选和删除',
'library.deleteSelected': '删除({count})',
'library.addToProject': '添加到项目',
'library.filterLabel': '按类型筛选',
@@ -1261,15 +1281,46 @@ export const zh: Dict = {
'shell.navAgents': '智能体',
'shell.navConnections': 'ComfyUI 连接',
'shell.navModels': '模型设置',
'shell.navLibrary': '素材库',
'shell.navSettings': '设置',
'shell.wip': '开发中',
'shell.wipHint': '该页面正在开发中,稍后开放',
'shell.comfy_ok': '{name} 已连接',
'shell.comfy_fail': '{name} 连接失败',
'shell.comfy_testing': '{name} 测试中…',
'shell.comfyUnset': 'ComfyUI · 未配置',
'shell.comfyManage': '管理 ComfyUI 连接',
'shell.mobileNotice': '请用桌面浏览器(宽 768px 以上)打开影工坊,手机端暂不适配。',
'shell.scopeHint': '切换项目',
'shell.scopeAll': '全部项目',
'shell.scopeAllList': '全部项目列表',
'shell.scopeRecent': '最近项目',
'shell.scopeLoadFailed': '项目列表加载失败',
'shell.scopeEmpty': '还没有项目,去创建',
'shell.scopeStage': '已确认 {n}/5',
'shell.queueRunning': '{n} 个生成中',
'shell.queueQueued': '{n} 个排队',
'shell.queueIdle': '队列空闲',
'shell.queueActive': '{n} 个进行中',
'shell.trayTitle': '任务托盘',
'shell.trayRecent': '最近任务 · 点击查看全部任务',
'shell.trayEmpty': '还没有生成任务',
'shell.trayAll': '查看全部任务',
'shell.searchTitle': '搜索页面和项目',
'shell.searchPlaceholder': '搜索页面、项目',
'shell.searchShortcut': '搜索(Ctrl/⌘ + K)',
'shell.searchEmpty': '没有找到与「{q}」相关的页面或项目',
'shell.searchGroupPages': '页面',
'shell.searchGroupProjects': '项目 · 打开创作工作台',
'shell.searchHintMove': '选择',
'shell.searchHintOpen': '打开',
'shell.searchHintClose': '关闭',
'shell.reduceMotion': '减少动态',
'shell.reduceHint': '关闭位移、缩放和光晕动画,只保留短暂淡入淡出',
'shell.reduceSystem': '已跟随系统的「减少动态效果」设置',
'shell.tabAria': '主导航(窄屏)',
'shell.tabProjects': '项目',
'shell.tabCreate': '创作',
'shell.tabGenerate': '生成',
'shell.tabMore': '更多',
'shell.morePages': '更多页面',
'shell.moreTools': '工具',
'home.title': '项目总览',
'home.subtitle': '管理创作项目,从一个创意或一份现成剧本开始。',
'home.projectCount': '{n} 个项目 · {m} 个进行中',
@@ -1455,8 +1506,6 @@ export const zh: Dict = {
'studio.genParams': '生成参数',
'studio.modeTextToImage': '文生图',
'studio.modeTextToVideo': '文生视频',
'studio.chipIdle': '{name} · 队列空闲',
'studio.chipBusy': '{name} · 队列 {n} 个任务',
'studio.video.resultsTitle': '成片预览',
'studio.image.historyTitle': '生图历史',
'studio.video.historyTitle': '生视频历史',
@@ -1506,6 +1555,7 @@ export const zh: Dict = {
'studio.slotMask': '遮罩',
'studio.slotFirst': '首帧',
'studio.slotLast': '尾帧',
'studio.slotOptional': '可选',
'studio.slotRefVideo': '参考视频',
'studio.slotAudio': '参考音频',
'studio.slotPick': '点击或拖入',
@@ -1581,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 连接」导入',
@@ -1600,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': '独立生视频',
@@ -1616,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': '智能体',
@@ -1841,12 +1900,14 @@ export const zh: Dict = {
'home.stageProgress': '五段进度',
'tasks.sourceWorkbench': '创作流程',
'jobst.idSuffix': ' · ComfyUI 任务 ID {id}',
'jobst.done': '完成',
'jobst.doneDetail': 'ComfyUI 已执行完成,结果已保存',
'jobst.done': '定影',
'jobst.doneDetail': '生成完成,结果已定影保存',
'jobst.dryRun': '占位结果',
'jobst.dryRunDetail': '占位结果:当前开启了 Dry-run,没有调用 ComfyUI,不是真实生成',
'jobst.noOutput': '无产出',
'jobst.noOutputDetail': '任务结束但没有任何结果文件,不算生成完成',
'jobst.missing': '文件缺失',
'jobst.missingDetail': '生成成功,但结果文件已不在服务器上',
'jobst.queued': '排队中',
'jobst.queuedDetail': '在工作台队列中等待提交到 ComfyUI',
'jobst.connecting': '连接中',
@@ -1924,4 +1985,114 @@ export const zh: Dict = {
'wft.errListMin': '「{label}」需要至少 {n} {unit}',
'tasks.testBadge': '测试',
'tasks.sourceWorkflowTest': '工作流测试',
// --- 生成台 (HEL-653) ---
'cdf.orig': '原值 {v}',
'desk.title': '生成台',
'desk.advanced': '高级参数 {n} 项 · 不改就沿用工作流原值',
'desk.chipResolution': '分辨率 {v}',
'desk.chipSeconds': '{v} 秒',
'desk.chipSeed': '种子 {v}',
'desk.closeInspector': '关闭检视区',
'desk.countActive': '{n} 个进行中',
'desk.countTakes': '{n} 条记录',
'desk.ctxAsset': '{kind}「{name}」',
'desk.ctxShot': '镜头 {no}',
'desk.ctxBack': '返回{stage}',
'desk.ctxDismiss': '关闭来源提示',
'desk.ctxFrom': '来自《{project}》',
'desk.ctxNoBackfill': '结果只留在生成台记录,不会自动回填到{stage}',
'desk.dayDate': '{m}月{d}日',
'desk.download': '下载',
'desk.dropNoSlot': '{n} 个文件没有可放的槽位:当前工作流没有接这类输入',
'desk.dryRun': '演练模式',
'desk.durMin': '{m} 分 {s} 秒',
'desk.durSec': '{s} 秒',
'desk.elapsed': '用时',
'desk.emptyFailed': '没有失败的记录',
'desk.emptyFeed': '还没有记录。在下方写提示词,点「生成」。',
'desk.filterAria': '筛选记录',
'desk.filterAll': '全部',
'desk.filterImage': '图片',
'desk.filterVideo': '视频',
'desk.filterFailed': '失败',
'desk.generate': '生成',
'desk.genHint': '生成(Ctrl/⌘ + Enter)',
'desk.inputs': '输入',
'desk.inspector': '检视区',
'desk.keyClose': '关闭',
'desk.keyInspector': '显示 / 收起',
'desk.keySelect': '选择',
'desk.keyUse': '使用',
'desk.kvCreated': '提交时间',
'desk.kvName': '名称',
'desk.kvPromptId': 'ComfyUI 任务号',
'desk.kvUsage': '用途',
'desk.loadEarlier': '显示更早的 {n} 条',
'desk.missingSub': '可用相同参数重做,或移除这条记录',
'desk.missingTitle': '结果文件已不在服务器上',
'desk.needWorkflow': '还缺:工作流',
'desk.noConn': '未连接',
'desk.noPromptInput': '这个工作流没有提示词输入,按工作流原值生成。',
'desk.notGenerated': '未生成',
'desk.noWorkflows': '还没有可用的生图 / 生视频工作流',
'desk.openInDesk': '在生成台调参',
'desk.openInDeskHint': '带上提示词打开生成台,自选工作流和参数;结果留在生成台记录',
'desk.openInspector': '检视区',
'desk.origValue': '工作流原值',
'desk.outputN': '第 {n} 个结果',
'desk.outputs': '全部结果',
'desk.paramError': '参数有误:{msg}',
'desk.paramsAll': '全部参数',
'desk.paramsChanged': '参数 · 已改 {n} 项',
'desk.paramsCommon': '常用参数',
'desk.pickTake': '在记录流里选一条查看结果',
'desk.pickWorkflow': '选择工作流',
'desk.promptOnly': '仅提示词',
'desk.promptPh': '描述想要的画面…(Ctrl/⌘ + Enter 生成,/ 快速聚焦)',
'desk.restoreAll': '全部恢复工作流原值',
'desk.redo': '用相同参数重做',
'desk.removeRecord': '移除记录',
'desk.restoreOrig': '恢复原值',
'desk.reuse': '复用',
'desk.reuseToComposer': '复用到输入台',
'desk.seedRandom': '随机',
'desk.sendToSlot': '送入当前图片槽',
'desk.sent': '已提交',
'desk.sentToSlotToast': '已送入当前工作流的图片槽',
'desk.slotOne': '{label} ×1',
'desk.slotRange': '{label} {min}–{max}',
'desk.specSize': '尺寸',
'desk.stillNeed': '还缺:{what}',
'desk.tabParams': '参数',
'desk.tabResult': '结果',
'desk.today': '今天',
'desk.took': '用时 {d}',
'desk.wfBroken': '不可用',
'desk.wfCurrent': '当前',
'desk.wfDefaultConn': '跟随默认连接',
'desk.wfImport': '导入 / 管理工作流',
'desk.wfLastFailed': '上次失败',
'desk.wfNoMatch': '没有匹配的工作流',
'desk.wfOk': '可用',
'desk.wfSearchPh': '搜索工作流,或输入用途:图生视频、参考图…',
'desk.wfTesting': '试运行中',
'desk.wfUntested': '未试运行',
'desk.workflow': '工作流',
'desk.yesterday': '昨天',
'wfd.stepParse': '解析 JSON',
'wfd.stepNodes': '识别节点',
'wfd.stepModels': '核对模型',
'wfd.stepSlots': '建立输入槽',
'wfd.noOutput': '工作流没有任何输出节点,无法导入',
'wfd.nodesAutoOk': '自动判定用途:{usage}',
'wfd.nodesUserOk': '用途已确认:{usage}',
'wfd.modelsOk': '「{name}」已装齐所需节点与模型',
'wfd.slotsOk': '已建立 {n} 个输入槽(含 {m} 个媒体槽)',
'wfd.checksAria': '导入检查结果',
'wfd.showDetails': '展开错误详情',
'wfd.hideDetails': '收起错误详情',
'wfd.unreachableSuggest': '无法核查不会阻止保存;修好连接后点「重新检查」复查。',
'wfd.nodesPickSuggest': '请在下方选择用途并完成输出映射后再保存。',
'wfd.showRaw': '查看原始 JSON',
'wfd.hideRaw': '收起原始 JSON',
};
+343
View File
@@ -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);
}
+50
View File
@@ -0,0 +1,50 @@
import { t } from "$lib/i18n.svelte";
import { toast } from "$lib/toast";
import {
captureLastFrame,
downloadBlob,
FrameExportError,
type FrameErrorCode,
} from "./exportFrame";
const ERROR_KEYS: Record<FrameErrorCode, string> = {
timeout: "lastFrame.errTimeout",
"not-found": "lastFrame.errNotFound",
forbidden: "lastFrame.errForbidden",
network: "lastFrame.errNetwork",
decode: "lastFrame.errDecode",
unsupported: "lastFrame.errUnsupported",
"cross-origin": "lastFrame.errCrossOrigin",
"no-picture": "lastFrame.errNoPicture",
unseekable: "lastFrame.errUnseekable",
black: "lastFrame.errBlack",
empty: "lastFrame.errEmpty",
};
/**
* 导出尾帧 for one video URL: capture, download the PNG and report the
* outcome as a toast. Never throws. Resolves true when a file was saved.
*/
export async function exportLastFrame(src: string): Promise<boolean> {
try {
const frame = await captureLastFrame(src, {
fileTag: t("lastFrame.fileTag"),
});
downloadBlob(frame.blob, frame.fileName);
toast.success(
t("lastFrame.done", {
name: frame.fileName,
size: `${frame.width}×${frame.height}`,
}),
);
return true;
} catch (err) {
if (err instanceof FrameExportError) {
toast.error(t(ERROR_KEYS[err.code]), 6000);
} else {
const msg = err instanceof Error ? err.message : String(err);
toast.error(t("lastFrame.errUnknown", { msg }), 6000);
}
return false;
}
}
@@ -0,0 +1,38 @@
import { SvelteSet } from 'svelte/reactivity';
import type { Job } from '$lib/comfy/types';
/**
* Output files a done job claims but the asset endpoint could not serve
* (spec-v2 §3.4 红/琥珀界线): the backend task status stays the single
* verdict for red — a FAILED job is red no matter what. Amber means the
* job SUCCEEDED and only its product file is gone (cleaned up, moved,
* storage hiccup). We learn that the only way the frontend can: the
* media element itself failed to load. A later successful load clears
* the mark.
*/
const missing = new SvelteSet<string>();
/** Mark one output path as unreachable (img/video onerror). */
export function markOutputMissing(path: string): void {
if (path) missing.add(path);
}
/** Clear the mark when the same path loads fine after all. */
export function clearOutputMissing(path: string): void {
missing.delete(path);
}
/** Paths of this job's outputs that are currently unreachable. */
export function missingOutputsOf(job: Job | null | undefined): string[] {
if (!job || job.status !== 'done' || !(job.output_paths?.length ?? 0))
return [];
return job.output_paths.filter((p) => missing.has(p));
}
/**
* The take reads as 文件缺失 when it succeeded but at least one claimed
* product file cannot be fetched.
*/
export function isFileMissing(job: Job | null | undefined): boolean {
return missingOutputsOf(job).length > 0;
}
@@ -0,0 +1,45 @@
import { storageGet, storageSet } from './storage';
/**
* 减少动态效果 (HEL-649 §五). The OS setting always wins (app.css also reacts
* to `prefers-reduced-motion` directly, so it cannot be switched off here);
* otherwise the top-bar switch decides. The effective value is mirrored to
* `<html data-reduce="1">`, which is what every reduced-motion rule keys on.
*/
const STORAGE_KEY = 'aigc-reduce-motion';
const QUERY = '(prefers-reduced-motion: reduce)';
export const reduceMotion = $state({ system: false, manual: false });
let stop: (() => void) | null = null;
function apply() {
document.documentElement.dataset.reduce =
reduceMotion.system || reduceMotion.manual ? '1' : '0';
}
/** Call once on the client (root layout). Returns the teardown. */
export function initReduceMotion(): () => void {
if (typeof window === 'undefined') return () => {};
stop?.();
const mq = window.matchMedia(QUERY);
reduceMotion.system = mq.matches;
reduceMotion.manual = storageGet(STORAGE_KEY) === '1';
apply();
const onChange = (e: MediaQueryListEvent) => {
reduceMotion.system = e.matches;
apply();
};
mq.addEventListener('change', onChange);
stop = () => mq.removeEventListener('change', onChange);
return () => {
stop?.();
stop = null;
};
}
export function setReduceMotion(on: boolean) {
reduceMotion.manual = on;
storageSet(STORAGE_KEY, on ? '1' : '0');
apply();
}
@@ -305,13 +305,13 @@
z-index: 2;
}
.keyframe.cam {
background: #e0b34d;
background: var(--accent-lo);
}
.keyframe.obj {
background: #4ade80;
background: var(--ok);
}
.keyframe.obj.selected {
background: #f87171;
background: var(--accent);
}
.keyframe:hover {
filter: brightness(1.15);
+9 -1
View File
@@ -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;
+4
View File
@@ -1,13 +1,17 @@
<script lang="ts">
import './app.css';
import { onMount } from 'svelte';
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
import ToastHost from '$lib/components/ToastHost.svelte';
import { initReduceMotion } from '$lib/reduceMotion.svelte';
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: 1 },
},
});
onMount(() => initReduceMotion());
</script>
<QueryClientProvider client={queryClient}>
+134 -230
View File
@@ -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>
+22 -12
View File
@@ -589,6 +589,11 @@
function avatarStyle(n: number): string {
return `var(--avatar-${Math.min(Math.max(n, 1), 5)})`;
}
// HEL-680 灰阶头像:1–2 亮阶配深字,3–5 暗阶配浅字,按阶数决定。
function avatarLight(n: number | undefined): boolean {
return Math.min(Math.max(n ?? 1, 1), 5) <= 2;
}
</script>
{#snippet skillRow(skill: SkillAdminEntry, detail: string)}
@@ -655,7 +660,7 @@
class:selected
onclick={() => selectAgent(agent.key)}
>
<span class="avatar" class:dark={agent.avatar_dark} style:background={avatarStyle(agent.avatar)}>
<span class="avatar" class:dark={avatarLight(agent.avatar)} style:background={avatarStyle(agent.avatar)}>
{agent.char}
</span>
<span class="agent-meta">
@@ -693,7 +698,7 @@
<section class="mid">
{#if activeAgent}
<header class="chat-head">
<span class="avatar lg" class:dark={activeAgent.avatar_dark} style:background={avatarStyle(activeAgent.avatar)}>
<span class="avatar lg" class:dark={avatarLight(activeAgent.avatar)} style:background={avatarStyle(activeAgent.avatar)}>
{activeAgent.char}
</span>
<div class="chat-head-meta">
@@ -1185,17 +1190,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 +1226,7 @@
text-overflow: ellipsis;
}
.skill-name:hover {
color: var(--accent);
color: var(--text);
}
.skill-tags {
display: flex;
@@ -1390,13 +1398,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 +1496,7 @@
color: var(--text-2);
}
.f-label em {
color: var(--accent);
color: var(--err);
font-style: normal;
margin-left: 2px;
}
@@ -1660,7 +1670,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 +1679,7 @@
display: block;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: var(--overlay);
z-index: 55;
}
.session-select {
@@ -1677,7 +1687,7 @@
}
}
@media (max-width: 1279px) {
@media (max-width: 1179px) {
.model-pick .model-label {
display: none;
}
+544 -55
View File
@@ -4,58 +4,67 @@
:root {
/* ------------------------------------------------------------------
* 融合视觉 design tokens — HEL-649 整站施工规范 §一(流水台结构 ×
* 放映厅配色)。Single source of truth for the whole app: pages and
* components reference these variables only — no hardcoded colors,
* no ad-hoc overrides. Gold is for key actions and the current
* selection only; status colors each carry exactly one meaning.
* 无彩灰阶 design tokens — HEL-680 S1(白栖知 theme-1「暗房放大机」
* × HEL-679 已确认融合样稿)。Single source of truth for the whole
* app: pages and components reference these variables only — no
* hardcoded colors, no ad-hoc overrides. 界面只有暖调黑白灰,作品是
* 屏幕上唯一成片的颜色;状态色仅按主题卡的两处有限例外(琥珀
* #A3915F、红 #B3564A)使用,不放大成全站主色。选中=取景框角线,
* 完成=「定影」,结果=相纸白边。
* ------------------------------------------------------------------ */
/* 底色:蓝黑四阶,逐层浮起 (§1.1) */
--bg: #07090e;
--bg-2: #0c0f16;
--bg-3: #121724;
--bg-4: #1a2133;
--line: #1b2233;
--line-2: #2a3450;
--page-glow: radial-gradient(1200px 500px at 70% -10%, rgba(38, 52, 92, 0.25), transparent 60%);
--float-bg: linear-gradient(180deg, #10152a, var(--bg-2));
--overlay: rgba(4, 6, 10, 0.6);
--media-scrim: rgba(7, 9, 14, 0.65);
/* 底色:显影灰阶四阶,逐层浮起(theme-1:#212120 底 / #2C2C2A 面板) */
--bg: #212120;
--bg-2: #262625;
--bg-3: #2c2c2a;
--bg-4: #383835;
--line: #313130;
--line-2: #454542;
--page-glow: radial-gradient(1200px 500px at 70% -10%, rgba(245, 244, 238, 0.035), transparent 60%);
--float-bg: linear-gradient(180deg, #2e2e2c, var(--bg-2));
--overlay: rgba(18, 18, 17, 0.62);
--media-scrim: rgba(18, 18, 17, 0.66);
--on-media: #fff;
--on-media-line: rgba(255, 255, 255, 0.05);
--composer-bg: linear-gradient(180deg, #2a2a28, var(--bg-2));
--composer-focus: 0 0 0 1px rgba(245, 244, 238, 0.12), 0 8px 28px rgba(0, 0, 0, 0.4);
/* 放映金:主按钮、当前选中、改过的参数、聚焦框、Toast 动作 (§1.2) */
--accent: #f2c784;
--accent-ink: #23170a;
--accent-soft: rgba(242, 199, 132, 0.13);
--accent-hi: #f7d9a2;
--accent-lo: #ecaf63;
--accent-glow: rgba(236, 175, 99, 0.28);
--accent-line: rgba(242, 199, 132, 0.55);
/* 相纸白(重点色):主按钮、当前选中、改过的参数、聚焦框、Toast 动作
(theme-1 #F5F4EE 重点) */
--accent: #f5f4ee;
--accent-ink: #212120;
--accent-soft: rgba(245, 244, 238, 0.08);
--accent-hi: #ffffff;
--accent-lo: #ddddd7;
--accent-glow: rgba(240, 239, 233, 0.2);
--accent-line: rgba(245, 244, 238, 0.55);
--accent-grad: linear-gradient(180deg, var(--accent-hi), var(--accent-lo));
--accent-grad-hover: linear-gradient(180deg, #fbe3b6, #f0b968);
--accent-ring: 0 0 0 1px rgba(242, 199, 132, 0.7), 0 0 22px var(--accent-glow);
--btn-glow: 0 2px 14px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.35);
--btn-glow-hover: 0 4px 18px var(--accent-glow), inset 0 1px 0 rgba(255, 255, 255, 0.4);
--accent-grad-hover: linear-gradient(180deg, #ffffff, #e8e7df);
--accent-ring: 0 0 0 1px rgba(245, 244, 238, 0.7), 0 0 22px var(--accent-glow);
--btn-glow: 0 2px 10px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.55);
--btn-glow-hover: 0 3px 14px rgba(0, 0, 0, 0.44), inset 0 1px 0 rgba(255, 255, 255, 0.65);
--accent-wash: linear-gradient(180deg, rgba(245, 244, 238, 0.05), transparent 40%);
--sel-shadow: 0 0 0 1px rgba(245, 244, 238, 0.14), 0 6px 24px rgba(0, 0, 0, 0.35);
/* 信号色:绿=完成/已确认/通过,青=进行中/在线,红=失败/危险,
琥珀=待确认/文件缺失/限额 (§1.3) */
--ok: #49cb90;
--ok-soft: rgba(73, 203, 144, 0.13);
--run: #54dfd2;
--run-soft: rgba(84, 223, 210, 0.13);
--run-grad: linear-gradient(90deg, #3bbfb4, var(--run));
--err: #f0645a;
--err-soft: rgba(240, 100, 90, 0.13);
--err-line: rgba(240, 100, 90, 0.6);
--warn: #f0b45c;
--warn-soft: rgba(240, 180, 92, 0.13);
/* 信号色:完成=显影亮灰(「定影」,配图标/文字双编码),进行中=灰阶
+动效;红/琥珀只按 theme-1 卡片的有限例外使用(红=失败/危险
#B3564A 系,琥珀=待确认/文件缺失 #A3915F) */
--ok: #ddddd7;
--ok-soft: rgba(221, 221, 215, 0.1);
--run: #b8b6ac;
--run-soft: rgba(184, 182, 172, 0.12);
--run-grad: linear-gradient(90deg, #8f8e85, #c6c5c0);
--err: #d07a6b;
--err-solid: #b3564a;
--err-soft: rgba(181, 86, 74, 0.15);
--err-line: rgba(208, 122, 107, 0.55);
--warn: #a3915f;
--warn-soft: rgba(163, 145, 95, 0.13);
/* 文字与字体 (§1.4);--text-2 是深底可读性下限,不得再暗 */
--text: #f4f6fa;
--text-2: #aab2c5;
--text-3: #6d7690;
/* 文字与字体;--text-3 是深底可读性下限(≥4.5:1 on --bg-3),不得再暗 */
--text: #f0efe9;
--text-2: #a8a69c;
--text-3: #94938c;
--font: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;
--mono: 'JetBrains Mono', ui-monospace, monospace;
--fs-title: 15px;
@@ -83,11 +92,22 @@
--r-pill: 999px;
--shadow-float: 0 14px 44px rgba(0, 0, 0, 0.6), 0 0 0 1px var(--line-2);
/* 壳层尺寸 (§二):60px 图标轨、顶栏、≤760px 底部标签栏 */
--rail-w: 60px;
--topbar-h: 52px;
--tabbar-h: 56px;
/* 专属符号 (§1.7):齿孔只在记录流/输入台分隔带,光晕只在播放舞台 */
--sprocket: radial-gradient(circle 2px, var(--line-2) 98%, transparent);
--stage-bg:
radial-gradient(60% 45% at 50% 38%, rgba(242, 199, 132, 0.07), transparent 70%),
radial-gradient(80% 70% at 50% 50%, #0d1220, #05070c);
radial-gradient(60% 45% at 50% 38%, rgba(245, 244, 238, 0.045), transparent 70%),
radial-gradient(80% 70% at 50% 50%, #232322, #171716);
/* 取景框角线与相纸白边 (HEL-680):内容级选中/结果呈现的统一语言 */
--frame-w: 2px;
--frame-arm: 12px;
--frame-inset: -4px;
--paper-w: 4px;
/* 动效 (§1.6,取 HEL-648 实测值);只动 transform / opacity */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
@@ -104,13 +124,13 @@
--d-progress: 400ms;
--press-scale: 0.97;
/* Avatar five-color set — five agents only; the one colorful gradient
family the spec keeps (§3.6). */
--avatar-1: linear-gradient(135deg, #e8a33d, #b96a1b);
--avatar-2: linear-gradient(135deg, #5b8def, #3a5fa9);
--avatar-3: linear-gradient(135deg, #3fb97f, #2a7a54);
--avatar-4: linear-gradient(135deg, #b98be0, #7a4fa9);
--avatar-5: linear-gradient(135deg, #d98e6a, #a95535);
/* Avatar five-step grayscale (HEL-680 无彩化):1–2 亮阶配深字,
3–5 暗阶配浅字;仍是五个身份位,只是从彩色渐变改为显影灰阶。 */
--avatar-1: linear-gradient(135deg, #e8e7df, #cdcbc4);
--avatar-2: linear-gradient(135deg, #b5b3aa, #9c9a91);
--avatar-3: linear-gradient(135deg, #6b6a66, #55544f);
--avatar-4: linear-gradient(135deg, #55544f, #424140);
--avatar-5: linear-gradient(135deg, #424140, #2e2e2c);
/* ------------------------------------------------------------------
* Legacy token names (§六 映射表) — aliases only, never new values,
@@ -204,7 +224,7 @@ body {
}
.a-darkroom {
background: var(--bg);
background: var(--page-glow), var(--bg);
color: var(--text);
font-family: var(--font);
font-size: var(--fs-body);
@@ -288,7 +308,7 @@ code,
color: var(--err);
}
.a-btn.danger-solid {
background: var(--err);
background: var(--err-solid);
color: var(--on-media);
}
@media (hover: hover) and (pointer: fine) {
@@ -344,6 +364,38 @@ code,
border-color: var(--accent-line);
color: var(--accent);
}
a.status-chip,
button.status-chip {
font-family: inherit;
text-decoration: none;
cursor: pointer;
transition:
border-color var(--d-state) ease,
color var(--d-state) ease,
transform var(--d-press) var(--ease-out);
}
a.status-chip:active,
button.status-chip:active {
transform: scale(var(--press-scale));
}
@media (hover: hover) and (pointer: fine) {
a.status-chip:hover,
button.status-chip:hover {
border-color: var(--text-3);
color: var(--text);
}
}
/* Keyboard shortcut hint, e.g. the Ctrl K badge on the search capsule. */
.a-kbd {
font: var(--fs-mini) var(--mono);
color: var(--text-3);
border: 1px solid var(--line-2);
border-bottom-width: 2px;
border-radius: var(--r-sm);
padding: 0 var(--sp-2);
line-height: 16px;
white-space: nowrap;
}
.status-chip-dot {
width: 6px;
height: 6px;
@@ -434,6 +486,311 @@ code,
opacity: 0.3;
}
/* Segmented control (§四 分段): the pressed option is lifted, never gold. */
.a-seg {
display: inline-flex;
padding: 2px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-2);
}
.a-seg > button {
height: 26px;
padding: 0 var(--sp-4);
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-3);
font-family: inherit;
font-size: var(--fs-body-sm);
white-space: nowrap;
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.a-seg > button[aria-pressed='true'] {
background: var(--bg-4);
color: var(--text);
box-shadow: inset 0 0 0 1px var(--line-2);
}
@media (hover: hover) and (pointer: fine) {
.a-seg > button:hover {
color: var(--text);
}
}
/* Composer chips (§3.3 输入台底行): workflow / connection / parameter chips.
Gold (`changed`) only marks a parameter that differs from the workflow value. */
.a-chip {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
height: 28px;
max-width: 280px;
padding: 0 var(--sp-4);
border: 0;
border-radius: var(--r-pill);
background: var(--bg-3);
color: var(--text-2);
font-family: inherit;
font-size: var(--fs-aux);
white-space: nowrap;
text-decoration: none;
cursor: pointer;
box-sizing: border-box;
transition:
background-color var(--d-state) ease,
color var(--d-state) ease,
transform var(--d-press) var(--ease-out);
}
.a-chip:active {
transform: scale(var(--press-scale));
}
.a-chip > .lbl {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.a-chip.wf {
background: var(--bg-4);
color: var(--text);
font-weight: 500;
box-shadow: inset 0 0 0 1px var(--line-2);
}
.a-chip.changed {
color: var(--accent);
}
/* Selected filter chip (§3.5 筛选芯片组): 当前选中 = 取景框角线缩影 +
相纸白文字 + accent-soft 底 (HEL-680) */
.a-chip.sel {
position: relative;
background: var(--accent-soft);
color: var(--accent);
}
.a-chip.sel::after {
content: '';
position: absolute;
inset: 2px;
pointer-events: none;
background-image:
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0);
background-position:
left top,
left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
background-size:
6px 1.5px,
1.5px 6px,
6px 1.5px,
1.5px 6px,
6px 1.5px,
1.5px 6px,
6px 1.5px,
1.5px 6px;
background-repeat: no-repeat;
}
.a-chip.warn {
color: var(--warn);
}
.a-chip.err {
color: var(--err);
}
@media (hover: hover) and (pointer: fine) {
.a-chip:hover {
background: var(--bg-4);
color: var(--text);
}
.a-chip.changed:hover {
color: var(--accent);
}
}
/* Composer input slots (§3.3 输入槽): 58px tiles — empty dashed / drag gold /
uploading ring / filled with hover ✕. MediaSlot and RefImageGrid render these. */
.a-slot {
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-width: 0;
}
.a-slot-label {
display: flex;
align-items: center;
gap: var(--sp-2);
min-height: 20px;
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
}
.a-slot-label .req {
color: var(--warn);
}
.a-slot-row {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.a-slot-hint {
font-size: var(--fs-mini);
color: var(--text-3);
}
.a-slot-hint.err {
color: var(--err);
}
.a-tile {
position: relative;
flex: none;
display: grid;
place-items: center;
width: 58px;
height: 58px;
padding: 0;
border: 1px solid var(--line-2);
border-radius: var(--r-md);
background: var(--bg-3);
color: var(--text-3);
font-family: inherit;
box-sizing: border-box;
transition:
border-color var(--d-state) ease,
background-color var(--d-state) ease,
color var(--d-state) ease,
transform var(--d-press) var(--ease-out);
}
button.a-tile {
cursor: pointer;
}
button.a-tile:disabled {
cursor: default;
}
.a-tile.add {
border-style: dashed;
}
.a-tile.add:active:not(:disabled) {
transform: scale(var(--press-scale));
}
.a-tile.drag {
border-color: var(--accent);
background-color: var(--accent-soft);
color: var(--accent);
}
.a-tile.need {
border-color: var(--warn);
}
.a-tile.bad {
border-color: var(--err);
color: var(--err);
}
.a-tile-hit {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: inherit;
overflow: hidden;
background: transparent;
color: inherit;
cursor: pointer;
}
.a-tile-hit img,
.a-tile-hit video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Uploads have no byte progress (fetch), so the ring spins instead of filling. */
.a-tile-ring {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: inherit;
background: var(--media-scrim);
pointer-events: none;
}
.a-tile-ring > i {
width: 26px;
height: 26px;
border-radius: 50%;
background: conic-gradient(var(--accent) 0.3turn, var(--line-2) 0);
-webkit-mask: radial-gradient(circle, transparent 9.5px, #000 10.5px);
mask: radial-gradient(circle, transparent 9.5px, #000 10.5px);
animation: a-tile-spin 900ms linear infinite;
}
@keyframes a-tile-spin {
to {
transform: rotate(1turn);
}
}
:root[data-reduce='1'] .a-tile-ring > i {
animation: none;
}
.a-tile-x {
position: absolute;
top: -6px;
right: -6px;
z-index: 1;
display: grid;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: 1px solid var(--line-2);
border-radius: 50%;
background: var(--bg-4);
color: var(--text-2);
cursor: pointer;
opacity: 0;
transition:
opacity var(--d-state) ease,
color var(--d-state) ease;
}
.a-tile:focus-within .a-tile-x {
opacity: 1;
}
.a-tile-idx {
position: absolute;
left: 3px;
top: 2px;
font: 10px var(--mono);
color: var(--on-media);
text-shadow: 0 1px 2px var(--bg);
pointer-events: none;
}
@media (hover: hover) and (pointer: fine) {
.a-tile:hover .a-tile-x {
opacity: 1;
}
.a-tile-x:hover {
color: var(--err);
}
button.a-tile.add:hover:not(:disabled) {
border-color: var(--text-3);
color: var(--text);
}
}
@media (hover: none) {
.a-tile-x {
opacity: 1;
}
}
/* Projection stage — only behind media in the inspector (§1.7). */
.a-stage {
position: relative;
@@ -443,6 +800,81 @@ code,
box-shadow: 0 0 0 1px var(--line);
}
/* ------------------------------------------------------------------
* 取景框角线选中 (HEL-680):内容级选中态的唯一语言——四角短线取景,
* 不整框描边。挂在需要角线的元素上(元素自身可 overflow 可见;被
* overflow:hidden 裁切的容器改用 --frame-inset 正值画在内侧)。
* ------------------------------------------------------------------ */
.a-frame {
position: relative;
}
.a-frame::after {
content: '';
position: absolute;
inset: var(--frame-inset, -4px);
z-index: 3;
pointer-events: none;
background-image:
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0);
background-position:
left top,
left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
background-size:
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px);
background-repeat: no-repeat;
}
/* 相纸白边 (HEL-680):结果媒体外的相纸白框,底边略厚如手工相纸。
挂在承载 img/video 的媒体容器上;容器尺寸由调用方决定。 */
.a-photo {
background: var(--accent);
border: var(--paper-w, 4px) solid var(--accent);
border-bottom-width: calc(var(--paper-w, 4px) + 2px);
border-radius: 2px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
box-sizing: border-box;
}
/* 「定影」完成章 (HEL-680):已定影结果右上角的完成印记,随 jobst.done
文案走(中文=定影)。absolute 定位,挂在有媒体的结果容器内。 */
.a-fix {
position: absolute;
top: var(--sp-3);
right: var(--sp-3);
z-index: 2;
padding: 1px 6px;
border: 1px solid currentColor;
border-radius: 3px;
background: var(--media-scrim);
color: var(--ok);
font-size: var(--fs-mini);
font-weight: 700;
letter-spacing: 0.1em;
text-indent: 0.1em;
transform: rotate(-4deg);
pointer-events: none;
}
/* Floating surface for pop / drawer / confirm / toast / lightbox (§四.3). */
.a-float {
background: var(--float-bg);
@@ -450,6 +882,63 @@ code,
box-shadow: var(--shadow-float);
}
/* Rows inside pops, sheets and the Ctrl+K palette (§四.3 pop). Gold marks
only the current page / project; keyboard and hover highlight is neutral. */
.a-menu-group {
padding: var(--sp-4) var(--sp-4) var(--sp-1);
font-size: var(--fs-mini);
color: var(--text-3);
}
.a-menu-item {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
min-height: 40px;
padding: var(--sp-3) var(--sp-4);
border: 0;
border-radius: var(--r-md);
background: transparent;
color: var(--text-2);
font-family: inherit;
font-size: var(--fs-body);
text-align: left;
text-decoration: none;
cursor: pointer;
box-sizing: border-box;
transition:
background-color var(--d-state) ease,
color var(--d-state) ease;
}
.a-menu-item.active,
.a-menu-item:focus-visible {
background: var(--bg-4);
color: var(--text);
}
@media (hover: hover) and (pointer: fine) {
.a-menu-item:hover {
background: var(--bg-4);
color: var(--text);
}
}
.a-menu-item[aria-current='page'],
.a-menu-item[aria-current='true'] {
color: var(--accent);
}
.a-menu-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.a-menu-meta {
flex-shrink: 0;
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
}
/* Card / panel surface (§1.5:卡片不投影,用底色差分层). */
.a-panel {
background: var(--bg-2);
@@ -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>
+1 -1
View File
@@ -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>
+24 -752
View File
@@ -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 });
};
+203 -245
View File
@@ -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,135 @@
}
</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 selectedCount > 0}
<Button variant="danger" onclick={() => (confirmingDelete = true)}>
<Icon name="close" size={14} />
{t('library.deleteSelected', { count: selectedCount })}
</Button>
{/if}
{/snippet}
{#if $mediaQuery.isLoading}
<div class="grid">
{#each Array(8) as _, i (i)}
<div class="card skeleton-card"><Skeleton height="220px" /></div>
{/each}
<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>
{: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>
{#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)}
class:a-frame={selected.has(item.path)}
style:--frame-inset="3px"
style:--frame-arm="16px"
>
<label class="check">
<input
type="checkbox"
checked={selected.has(item.path)}
onchange={() => toggle(item.path)}
/>
</label>
<button
type="button"
class="media"
class="media a-photo"
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>
<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>
<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}
{/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}
{/if}
</main>
</div>
</AppShell>
{#if preview}
<ImageLightbox
@@ -249,118 +269,85 @@
/>
{/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);
background: linear-gradient(180deg, var(--accent-soft), transparent 32%), var(--bg-2);
}
.card :global(.media-el) {
object-fit: cover;
@@ -370,22 +357,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,10 +381,18 @@
height: 150px;
padding: 0;
border: none;
background: rgba(0, 0, 0, 0.3);
background: var(--media-scrim);
cursor: zoom-in;
overflow: hidden;
}
/* 相纸白边 (HEL-680):素材墙里的作品按相纸呈现 */
.media.a-photo {
border: var(--paper-w, 4px) solid var(--accent);
border-bottom-width: calc(var(--paper-w, 4px) + 2px);
background: var(--accent);
border-radius: 0;
box-sizing: border-box;
}
.media :global(img),
.media :global(video) {
width: 100%;
@@ -408,22 +403,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 +426,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>
-6
View File
@@ -1,8 +1,2 @@
import { redirect } from '@sveltejs/kit';
export const ssr = false;
export const prerender = false;
export function load() {
redirect(308, '/');
}
+7 -11
View File
@@ -392,14 +392,8 @@
char: string;
dark?: boolean;
}[] = [
{
role: 'story',
labelKey: 'models.agentStory',
color: 'var(--avatar-1)',
char: '剧',
dark: true,
},
{ role: 'style', labelKey: 'models.agentStyle', color: 'var(--avatar-2)', char: '视' },
{ role: 'story', labelKey: 'models.agentStory', color: 'var(--avatar-1)', char: '剧', dark: true },
{ role: 'style', labelKey: 'models.agentStyle', color: 'var(--avatar-2)', char: '视', dark: true },
{ role: 'script', labelKey: 'models.agentScript', color: 'var(--avatar-3)', char: '分' },
{ role: 'assets', labelKey: 'models.agentAssets', color: 'var(--avatar-4)', char: '素' },
{ role: 'video', labelKey: 'models.agentVideo', color: 'var(--avatar-5)', char: '镜' },
@@ -1097,11 +1091,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 +1410,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 +1433,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;
+252 -137
View File
@@ -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>
+6 -22
View File
@@ -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"
@@ -471,6 +471,7 @@
{:else if stageKey === 'assets'}
<AssetsStagePanel
stage={panelProps.stage}
{projectId}
busy={busyFor('assets')}
onSave={(content) => $saveMutation.mutate({ key: 'assets', content })}
onRun={() => handleRun('assets')}
@@ -480,6 +481,7 @@
{:else}
<ShotStagePanel
stage={panelProps.stage}
{projectId}
busy={busyFor('shot')}
onSave={(content) => $saveMutation.mutate({ key: 'shot', content })}
onRun={() => handleRun('shot')}
@@ -494,7 +496,11 @@
<aside class="side-col">
{#if currentAgent}
<div class="side-card agent-card">
<span class="agent-avatar" style:background={`var(--avatar-${currentAgent.avatar})`}>
<span
class="agent-avatar"
class:light={(currentAgent.avatar ?? 1) <= 2}
style:background={`var(--avatar-${currentAgent.avatar})`}
>
{currentAgent.char}
</span>
<div class="agent-copy">
@@ -504,7 +510,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>
@@ -657,40 +663,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(--info);
background: var(--info-dim);
}
.error-banner {
display: flex;
align-items: flex-start;
@@ -731,7 +703,7 @@
gap: 10px;
align-items: center;
}
.instruction-row .wb-input {
.instruction-row .a-input {
flex: 1;
}
@@ -772,6 +744,10 @@
color: var(--text);
flex-shrink: 0;
}
/* HEL-680 灰阶头像:1–2 亮阶用深字 */
.agent-avatar.light {
color: var(--accent-ink);
}
.agent-copy {
display: flex;
flex-direction: column;
@@ -916,18 +892,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 {
@@ -942,67 +909,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;
@@ -1064,9 +970,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

-6
View File
@@ -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 条专项用
路由拦截验证。
+6
View File
@@ -0,0 +1,6 @@
logs-*.txt
run.log
shots/
fake_mode.json
pid-*
node_modules/

Some files were not shown because too many files have changed in this diff Show More